C++的单例模式

单例模式是设计模式里头比较简单的一个。实现起来也是20~30行就可以实现。
在实际场景中也会有一些应用,如我们从配置文件读取程序的配置,很自然这个配置应该是整个程序唯一的。
不过在C++中实现和Java的实现有些不同,在考虑内存安全和多线程安全上要特别注意细节。在C++11以后C++的单例模式实现起来更加方便了。

实现: (懒汉&饿汉)

看过Java面向对象设计模式的同学对懒汉模式和饿汉模式估计已经非常了解了,在这里就稍微做一下总结。饿汉模式即在程序开始运行的时候就已经分配好该单例的实例,直到程序运行完毕后再回收内存资源,而懒汉模式则是在调用到该单例类的时候才对实例进行初始化。
下面我们看一下在C++中懒汉模式和饿汉模式的较为完善的实现。

饿汉模式

最简单的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
/// Singleton.h
class MySingleton {
public:
static MySingleton& GetInstance() {
return *pObject_;
}
private:
static std::unique_ptr<MySingleton> pObject_; //!< 智能指针,程序执行完毕后该实例的内存空间
MySingleton()=default;
MySingleton(const MySingleton&)=delete;
MySingleton& operator=(const MySingleton&)=delete;
};
std::unique_ptr<MySingleton> MySingleton::pObject_(new MySingleton());

饿汉模式有其固有的缺点。由于C++编译器并没有规定静态变量的初始化顺序,此时如果MySingleton的构造函数中引用了另外一个单例实例,此时MySingleton有可能获取到一块未初始化的内存空间,导致非法的内存访问,程序Coredump,所以在Boost中,采用了局部变量的方法来解决这个问题。如下文所述

利用局部静态变量+全局变量来解决初始化顺序的问题

现代的编译器确保了局部静态变量在访问时已经初始化,利用这个特点,饿汉模式可以改成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// Implement reference: Boost singleton
/// Singleton.h
class MySingleton {
public:
static MySingleton* GetInstance() {
// 局部静态变量确保了初始化
static MySingleton object_;
return &object_;
}

protected:
struct object_creator {
object_creater() {
MySingleton::GetInstance();
}
}
static object_creator creator_object_;
private:
MySingleton() = default;
MySingleton(const MySingleton&) = delete;
MySingleton& operator=(const MySingleton&) = default;
};
/// Singleton.cc
MySingleton::object_creator MySingleton::creator_object_;

利用局部静态变量和 proxy-class 是饿汉模式的一个比较好的解决方法。但是该方法使用的是局部静态变量,程序结束以后析构顺序的问题会导致程序产生非法的内存访问。在程序结束以后,一般来说局部静态变量是比全局静态变量要先析构的。
考虑这样的一个场景,就是此时我们存在一个全局变量,这个全局变量的析构函数使用了这个单例模式,此时使用局部静态变量肯定已经析构了的。访问的是一个被析构的内存。
正如《代码大全2》中所述,我们要深入一种语言去编程(programming into a language) 而非在一种语言上编程(programming in a language)。关于局部静态变量的实现我们可以通过规定全局静态变量的析构函数中不允许使用单例的实例,来规避这个问题。

懒汉模式

C++11之前

在C++11之前,我们为了确保线程安全,在第一次调用GetInstance时,变量的创建需要加锁的形式来保证线程安全,通过二次检查的方案来保证只创建了一个实例,和保证内存安全。这个方法也被称为Double-Check Lock Pattern(DCLP)
使用智能指针的做法是确保程序在退出的时候能够正确地释放内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <mutex>
#include <memory>
#include <iostream>

class SingletonLayzer {
public:
static SingletonLayzer& GetInstance() {
if(!pObject_) { /// 1st test
std::lock_guard<std::mutex> lock(mutex_);
if(!pObject_) { /// 2nd test
pObject_.reset(new SingletonLayzer);
}
}
return *pObject_;
}

private:
static std::unique_ptr<SingletonLayzer> pObject_;
static std::mutex mutex_;

SingletonLayzer() = default;
SingletonLayzer(const SingletonLayzer&) = delete;
SingletonLayzer& operator=(const SingletonLayzer&) = delete;
};

std::unique_ptr<SingletonLayzer> SingletonLayzer::pObject_;
std::mutex SingletonLayzer::mutex_;

但是上面这块代码也会出现内存安全问题。 我们在 2nd test 之后初始化pObject_, 有如下三个步骤

  1. 申请内存空间
  2. 初始化内存空间
  3. 将内存空间的地址赋值给pObject_
    理论上步骤2是不应该和步骤3调换顺序,但是编译器如果发现步骤2不会抛出异常,那么步骤2和步骤3的执行顺序完全可能被调换的,另外的线程在 1st test 的时候就有可能返回一个未初始化的内存空间。
    为了防止编译器优化带来的指令调换,volatile可以排上大用途。具体如何使用可以参考 Meyers 大师的论文:C++ and the Perils of Double-Checked Locking

C++11标准后

在C++11标准以后,编译器会确保局部静态变量初始化线程安全。于是就有人想用局部静态变量来实现懒汉模式,的确下面这段代码就没有线程安全问题了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Singleton {
public:
static Singleton& GetInstance() {
static Singleton instance_;
return instance_;
}
~Singleton()=default;

private:
Singleton()=default;
Singleton(const Singleton&)=delete;
Singelton& operator=(const Singleton&)=delete;

};

同样的,使用局部静态变量会出现析构顺序的问题,上文中关于局部静态变量导致的内存安全问题已经做了详细说明了。所以即使C++11以后使用局部静态变量的方法,也是会存在内存安全问题的。

C++11 std::call_once

在C++11中提供一种方法,使得函数可以线程安全的只调用一次。即使用std::call_oncestd::once_flag。该方法应该是实现C++单例模式最简洁并且内存较为安全的代码了——前提条件是std::call_once足够安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <thread>
#include <mutex>

std::once_flag flag;

class Singleton{
public:
static Singleton& GetInstance() {
static std::once_flag s_flag;
std::call_once(s_flag, []() {instance_.reset(new Singleton()); });
return *instance_;
}

private:
static std::unique_ptr<Singleton> instance_;

Singleton() = default;
Singleton(const Singleton& other) = delete;
Singleton& operator=(const Singleton&) = delete;
};

std::unique_ptr<Singleton> Singleton::instance_;

多核处理器: 缓存一致性问题

在多核处理出现以后,C++的内存安全就多了更多的挑战,缓存一致性是多核处理器出现以后C++单例模式也需要面对的问题,由于每个核心都拥有自己的Cache,由于读取缓存的速度比读取内存的速度快很多,许多编译器会进行优化,将变量在缓存中修改,而非写回到共享的内存中。从而产生缓存一致性问题。
缓存一致性的问题,可以通过Memory Barrier来避免,而Memory Barrier的实现取决于不同的体系结构的实现,通常是汇编级别的实现。
有机会再深入去理解和学习Memory Barrier

Author: lisupy
Link: http://lisupy.github.io/2019/10/16/Cpp的单例模式/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
支付宝打赏
微信打赏