“声名狼藉”的双重检查锁定
在学习《C++并发编程实战》的时候,作者提到了“声名狼藉”的双重检查锁定,但是对于其中潜在的条件竞争,有点模糊,这里稍微做点笔记。
懒汉模式与饿汉模式
我们经常使用到的单例模式中分为两种,一种懒汉模式,一种饿汉模式
- 饿汉模式:在类外直接定义初始化,不存在线程安全问题。
class Singleton {
private:
// 私有构造函数,防止外部创建对象
Singleton() {}
// 声明静态成员变量,用于保存单例实例
static Singleton* instance;
public:
// 获取单例实例的方法
static Singleton* getInstance() {
return instance;
}
// 其他成员函数
void doSomething() {
// 执行操作
}
};
// 在类外初始化静态成员变量
Singleton* Singleton::instance = new Singleton();
- 懒汉模式:第一次用到的时候才进行初始化,存在线程安全问题。
class Singleton {
private:
// 私有构造函数,防止外部创建对象
Singleton() {}
// 声明静态成员指针,用于保存单例实例
static Singleton* instance;
public:
// 获取单例实例的方法
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
// 其他成员函数
void doSomething() {
// 执行操作
}
};
双重检查锁定的来历
- 懒汉模式中涉及到的问题,就是多线程环境下的共享数据初始化过程的保护。
std::shared_ptr<some_resource> resource_ptr;
void foo() {
if (!resource_ptr) {
resource_ptr.reset(new some_resource); // 1
}
resource_ptr->do_something();
}
-
【1】处需要进行线程安全保护,避免对同一共享数据进行重复初始化
-
第一种方法,使用互斥锁保证线程安全
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo() {
std::unique_lock<std::mutex> lk(resource_mutex); // 所有线程在此序列化
if (!resource_ptr) {
resource_ptr.reset(new some_resource); // 只有初始化过程需要保护
}
lk.unlock();
resource_ptr->do_something();
}
- 但是这种方法性能消耗太大,每次调用都需要对共享数据进行多余的上锁,只为检查是否已经初始化,这对于完成初始化之后完全是多余的操作。所以就有了双重检查锁定。
- 双重检查锁定,只有在没有初始化的时候,才上锁进行初始化。
void undefined_behaviour_with_double_checked_locking() {
if (!resource_ptr) // 1
{
std::lock_guard<std::mutex> lk(resource_mutex);
if (!resource_ptr) // 2
{
resource_ptr.reset(new some_resource); // 3
}
}
resource_ptr->do_something(); // 4
}
- 指针第一次读取数据不需要获取锁①,并且只有在指针为空时才需要获取锁。
- 然后,当获取锁之后,会再检查一次指针② (这就是双重检查的部分),避免另一线程在第一次检查后再做初始化,并且让当前线程获取锁。
双重检查锁定的问题
-
双重检查锁定从代码部分看没问题,但是其实存在着潜在的条件竞争。
- 未被锁保护的读取操作①没有与其他线程里被锁保护 的写入操作③进行同步
- 也就是说判断①,可能在写入操作③的过程中进行。
-
resource_ptr.reset(new some_resource);
- 这条语句实际做了三件事情:
- 第一步:为Singleton对象分配一片内存
- 第二步:构造一个Singleton对象,存入已分配的内存区
- 第三步:将pInstance指向这片内存区
- 这里至关重要的一点是:我们发现编译器并不会被强制按照以上顺序执行!实际上,编译器有时会交换步骤2和步骤3的执行顺序。这也就是**“指令重排”**。
优化编译器会仔细地分析并重新排序你的代码,使得程序执行时,在可见行为的限制下,同一时间能做尽可能多的事情。在串行代码中发现并利用这种并行性是重新排列代码并引入乱序执行最重要的原因,但并不是唯一原因,以下几个原因也可能使编译器(和链接器)将指令重新排序:
- 避免寄存器数据溢出;
- 保持指令流水线连续;
- 公共子表达式消除;
- 降低生成的可执行文件的大小[4]。
C/C++的编译器和链接器执行这些优化操作时,只会受到C/C++标准文档中定义的抽象机器上可见行为的原则这唯一的限制。有一点很重要:这些抽象机器默认是单线程的。C/C++作为一种语言,二者都不存在线程这一概念,因此编译器在优化过程中无需考虑是否会破坏多线程程序。
- 所以重排后可能如下操作:
- 第一步:为Singleton对象分配一片内存
- 第二步:将pInstance指向这片内存区
- 第三步:构造一个Singleton对象,存入已分配的内存区
- 这条语句实际做了三件事情:
-
如果初始化过程中,在第二步之后,第三步之前,运行了另一个线程,线程检查
if (!resource_ptr)
,发现已经初始化了,于是调用函数resource_ptr->do_something();
,实际上resource_ptr
仅分配了内存区,还未完成初始化,于是出现未定义的报错。
线程安全的初始化方法
call_once和once_flag
- C++11 提供了
std::once_flag
和std::call_once
来保证对某个操作只执行一次
#include <memory>
#include <mutex>
#include <thread>
using namespace std;
class A {
public:
void fun() {}
};
shared_ptr<A> p;
once_flag once_flag_t;
void init() {
call_once(once_flag_t, [] {p = make_shared<A>();});
p->fun();
}
int main() {
thread t_1(init);
thread t_2(init);
t_1.join();
t_2.join();
}
std::once_flag
和std::call_once
也可以用在类中,表示这个实例化对象只调用一次
#include <memory>
#include <mutex>
#include <iostream>
#include <thread>
using namespace std;
class A {
public:
void fun() {
call_once(m_once, &A::print, this);
}
private:
once_flag m_once;
void print() const {
cout << 1 << endl;
}
};
int main() {
A a;
thread t_1(&A::fun, &a);
thread t_2(&A::fun, &a);
t_1.join();
t_2.join();
}
static初始化
- 在用于函数域中时,比如我们在函数体中定义并初始化了一个静态变量,同时对这个变量进行其他操作。这个静态变量是全局的,存放在静态区中,但是只在这个函数中可见,最重要的是这个变量只进行一次初始化,也就是说当我们重复调用这个函数的时候,初始化的那一句只有在最开始的时候执行一次分配内存并初始化,此后的函数不在调用,只会执行其他语句。
- 在 C++11 及以后的标准中,静态局部变量的初始化是线程安全的。所以懒汉模式的初始化可以使用static 局部变量
class my_class {
public:
static my_class& get_instance() {
static my_class instance; // 在程序启动时创建实例
return instance;
}
void do_something() {
// 实例方法的实现
}
private:
my_class() {
// 私有构造函数,防止外部实例化
}
// 阻止复制构造函数和赋值操作符
my_class(const my_class&) = delete;
my_class& operator=(const my_class&) = delete;
};