单线程中的单例模式
在单线程中,实现一个单例模式是简单的:
cpp
class Singleton
{
public:
static Singleton* get_instance()
{
if (instance_ == nullptr)
{
instance_ = new Singleton();
}
return instance_;
}
private:
Singleton() = default;
static Singleton* instance_;
};
// 在Singleton头文件中初始化静态成员变量
Singleton* Singleton::instance_ = nullptr;
在这个单例类中, 定义了静态(static)成员变量instance_,这意味着无论创建多少个类的对象,该静态成员都只有一个副本。由于instance_是私有成员,外部无法访问,需要在Singleton所在的头文件中进行初始化Singleton* Singleton::instance_ = nullptr;
。
在实例化对象时,通过Singleton::get_instance
获取实例,根据if (instance_ == nullptr)
判断是否完成了实例化,如果没有,则new一个对象出来。在单线程中,这个过程无需担心数据的竞争问题。
多线程中的单例模式
但是在多线程中,可能出现这样的情况:
thread1和thread2同时执行Singleton::get_instance
,这时if (instance_ == nullptr)
均成立,于是两个线程都会执行instance_ = new Singleton();
,导致两次分配。两个线程操作的是不同的对象,导致行为上的不一致和不同步。
在多线程中,一种方式是使用双检查实现单例:
cpp
class Singleton
{
public:
static Singleton* get_instance() {
Singleton* tmp = instance_.load(std::memory_order_acquire);
if (tmp == nullptr) {
std::unique_lock<std::mutex> lk(mutex_);
tmp = instance_;
if (tmp == nullptr) {
tmp = new Singleton();
instance_.store(std::memory_order_release);
}
}
return tmp;
}
private:
Singleton() = default;
static std::atomic<Singleton*> instance_;
static std::mutex mutex_;
};
在这个实现方式中,将单例的成员变量instance_
定义为原子变量,并增加了互斥量mutex_
。在get_instance()
中增加了带锁的双重检查。回过头来重新考虑之前的情况:
thread1和thread2同时执行Singleton::get_instance
,这时if (instance_ == nullptr)
均成立,两者分别执行std::unique_lock<std::mutex> lk(mutex_);
,假设thread2后执行,则由于先执行的thread1激活了锁,导致thread2被锁阻塞,thread1进入的则进行第二次检查,发现instance_ == nullptr
成立,于是进行实例化,并在退出函数后自动释放锁。
随后thread2不再被锁阻塞,重新获取instance_
的值,并进行第二次检查,此时instance_ == nullptr
不再成立,于是跳过实例化,并返回最新的instance_
。
原子操作与乱序重排
除了锁的设置,这里还将instance_
定义为原子变量,并通过load和store函数对其进行操作,这是为了防止编译优化过程中出现乱序重排。
程序在编译过程中,在不影响程序执行结果的前提下,通过重新编排代码的执行顺序,可以提高代码的执行效率,减少执行时间。
但重排后,在多线程的情况下,可能引发一些问题,比如下面这段代码:
cpp
int x = 0; // global variable
int y = 0; // global variable
Thread-1: Thread-2:
x = 100; while (y != 200)
y = 200; ;
std::cout << x;
正常情况下,在threa1中,执行完y=200;
后,x的值应该为100,所以thread2的的输出结果为100。但由于threa1中的两条代码在顺序上没有依赖关系,所以在优化过程中,编译器可能将两者的顺序调换,得到如下的顺序:
cpp
Thread-1:
y = 200;
x = 100;
在这种情况下,thread2的输出x的结果可能为原先的值,即0。
为防止出现编译优化引发的问题,在前面实现的多线程单例模式中,对instance_
操作时分别采用了load()
和store()
。这两者的除了读写instance_
外,通过指定参数,还提供了重排的保证:instance_.load(std::memory_order_acquire)
用于保证后面访存指令勿重排至此条指令之前 ,instance_.store(std::memory_order_release)
用于保证前面访存指令勿重排到此条指令之后。