最近学习C++的时候,看到了一些关于全局变量使用上的优劣点,其中有些资料中认为单例模式是更好的实现,单例可认为是更优雅的全局变量。
因为此模式是用来确保一个类仅存在一个实例,并提供对此唯一实例的全局访问节点,更为方便的是,它为对象的分配、销毁提供了控制,同时也能避免污染全局命名空间。
在C++中实现单例
C++中实现单例方法主要是通过隐藏构造函数来实现的。
简单实现的基本步骤
单例模式对外提供的接口,每次调用时返回类的同一个实例,考虑到C++的语言特性,设计单例类的实现步骤可以如下:
- 为了防止客户端自由创建新的实例,应该防止编译器自动声明公有的构造函数,所以需要将默认构造函数声明为私有或保护。
- 防止通过复制方式生成第二个实例,则需要将拷贝构造函数和赋值运算符也声明为私有或保护。
- 增加一个静态私有的指针变量,指向当前类。
- 提供静态的对外接口,让客户端获得上一步中单例类的指针,从而获得实例对象。不过考虑到客户端可以通过指针删除此对象,因此返回引用似乎更加稳妥。
C++代码的简单实现
通过上面的步骤,写出的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| class Singleton { protected: Singleton(const std::string value) : m_name(value) {} static Singleton *instance; std::string m_name;
public: Singleton(Singleton &other) = delete; void operator=(const Singleton &) = delete;
static Singleton *getInstance(const std::string &value); std::string value() const { return m_name; } };
Singleton *Singleton::instance = nullptr;
Singleton *Singleton::getInstance(const std::string &value) { if (instance == nullptr) { instance = new Singleton(value); } return instance; }
|
通过上面的代码中的getInstance方法,尝试简单生成多个实例,会发现它们其实是同一个实例。因为其中的实例化操作检查,如果instance不为null,获得的就还是第一次实例化的instance,这样便达到了单例模式的目的。
线程安全的单例模式
上述的简单模式在单线程下可以正常运行,但是考虑到程序可能是在多线程的情况下执行,例如用下面的代码进行测试:
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
| void ThreadFoo() { // 延迟初始化 std::this_thread::sleep_for(std::chrono::milliseconds(1000)); Singleton *singleton = Singleton::getInstance("FOO"); std::cout << singleton->value() << " "; }
void ThreadBar() { std::this_thread::sleep_for(std::chrono::milliseconds(1000)); Singleton *singleton = Singleton::getInstance("BAR"); std::cout << singleton->value() << " "; }
int main() { std::cout << "RESULT: "; std::thread t1(ThreadFoo); std::thread t2(ThreadBar); t1.join(); t2.join();
return 0; }
|
最终执行结果可能是RESULT: BAR FOO
,也可能是RESULT: FOO BAR
,或者输出两个相同值。如果两个值相同,证明它们是同一个实例,不同的情况,则说明两个线程创建了两个不同的Singleton实例,所以它不是线程安全的。
为确保线程安全,常用的方案之一就是加锁,对Singletion类加锁改造的代码如下:
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 28 29 30 31 32 33
| class Singleton { private: static Singleton *instance; static std::mutex m_mutex;
protected: Singleton(const std::string value) : m_value(value) {} ~Singleton() {} std::string m_value;
public: Singleton(Singleton &other) = delete; void operator=(const Singleton &) = delete;
static Singleton *getInstance(const std::string &value); std::string value() const { return m_value; } };
Singleton *Singleton::instance = nullptr; std::mutex Singleton::m_mutex;
/** * 调用getInstance时会获得锁,保证线程安全 */ Singleton *Singleton::getInstance(const std::string &value) { std::lock_guard<std::mutex> lock(m_mutex); if (instance == nullptr) { instance = new Singleton(value); } return instance; }
|
这样测试结果就会变成RESULT: BAR BAR
或者RESULT: FOO FOO
,从而保证了线程安全。
使用双检查锁改善线程安全单例的性能问题
上面的代码虽然确保了线程安全,但是getInstance方法每次被调用时,都会请求加锁,如果实际场景中客户端并未频繁请求此方法,则不用考虑对它进行优化。
但是出于API设计的责任主要在设计者而不是客户端的考虑,不应该建议客户端只调用一次方法,而应该针对性能场景改进这个单例的设计,减少多次请求锁而造成的开销。
所以此时,可以在请求锁前再添加一次判断,如果实例存在,则直接返回实例对象。这样多线程在第一次调用方法时,会进行请求锁的操作,而在其后的同样操作中,就跳过了锁操作。
使用双检查锁改造后的getInstance方法如下:
1 2 3 4 5 6 7 8 9 10
| Singleton *Singleton::getInstance(const std::string &value) { if (instance == nullptr) { std::lock_guard<std::mutex> lock(m_mutex); if (instance == nullptr) { instance = new Singleton(value); } } return instance; }
|
双检查锁的弊端:编译器reorder
在instance = new Singleton(value)
这句代码中,其实是假定机器做了如下三件事:
- 在堆上为Singleton对象分配空间;
- 在此空间上初始化对象;
- 返回此对象的指针,即让instance指向这块空间。
历史上很长一段时间,编译器的优化策略,使得代码并不是完全按照这个顺序执行的(包括其它编程语言),有可能2、3步骤会互换次序,所以多线程环境,两个线程A、B第一次调用getInstance方法时,可能发生这样的事情:
- 线程A进入,此时instance为null,A获得锁,完成了step1、step3,此时A的时间片用完,线程A被挂起;
- 线程B进入,先判断instance,此时instance由于线程A的关系,并不是nullptr,则线程B直接获得了instance实例;
- 而此时instance只是指向了分配的空间,该空间并未执行初始化,所以线程B想利用instance执行对象操作,都是不合法的。
C++11后对于reorder的解决
像Java与C#之类的编程语言,在早期通过加入volatile解决reorder问题,msvc 2005版本也是加入了volatile,但这样的C++实现就无法跨平台。
在文章《Double-Checked Locking is Fixed In C++11》中,介绍了通过获取与释放内存fence的方法来避免reorder,改造后的Singleton类是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| std::atomic<Singleton *> Singleton::instance; std::mutex Singleton::m_mutex;
/** * 调用getInstance时会获得锁,保证线程安全 */ Singleton *Singleton::getInstance(const std::string &value) { Singleton *tmp = instance.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire); //获取内存fence if (instance == nullptr) { std::lock_guard<std::mutex> lock(m_mutex); tmp = instance.load(std::memory_order_release); if (tmp == nullptr) { tmp = new Singleton(value); std::atomic_thread_fence(std::memory_order_release); //释放内存fence instance.store(tmp, std::memory_order_relaxed); } } return tmp; }
|
从逻辑上这样处理,使得操作愈发复杂,它的流程示意如下:
C++11标准中更简洁的做法
在C++11标准中,静态局部变量是只会初始化一次,并且还是线程安全的,所以更简洁的思路是从语言特性方向简化,将上面的检查逻辑完全跳过,直接使用静态局部变量作为实例返回。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class Singleton { protected: Singleton(const std::string value) : m_value(value) {} std::string m_value;
public: Singleton(Singleton &other) = delete; void operator=(const Singleton &) = delete;
static Singleton &getInstance(const std::string &value) { // 在这里提供一个静态局部变量 static Singleton instance(value); return instance; } std::string value() const { return m_value; } };
|
这种写法大大减少了代码量,简洁而且跨平台,不过只能在支持C++11标准的编译器下执行。在C++11之前,这个写法的结果并不一定正确,因为C++11标准规定了,当一个线程正在初始化一个变量的时候,其他线程必须得等到该初始化完成以后才能访问它。
不使用延迟初始化的方案
再仔细思考上述实现方法,它们涉及到的线程安全问题,主要是在初始化的时候,由于资源竞争导致的,因为延迟初始化给了不同线程调用同一个接口的机会。如果使用的单例属性相对固定,可以考虑不使用延迟初始化,在主线程运行时直接先生成一个实例,就避免了不同线程之间的竞争。可以在最初的方案上简单修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class Singleton { protected: Singleton(const std::string value) : m_name(value) {} static Singleton *instance; std::string m_name;
public: Singleton(Singleton &other) = delete; void operator=(const Singleton &) = delete;
static Singleton *getInstance(); std::string value() const { return m_name; } };
// 这里提前初始化它 Singleton *Singleton::instance = new Singleton("FOO");
Singleton *Singleton::getInstance() { return instance; }
|
单例模式的缺点
虽然开始说了单例模式的的优点,但是单例模式也只是一种优雅的全局状态维护的方式,我们始终需要考虑,是否需要全局状态。
它可能会在API中引入难以重构的依赖,同时也使得单元测试变得困难。另外,单例模式违反了单一职责原则,因为它同时实现了了两个职责:只有一个实例,以及提供一个访问该实例的全局节点。
所以,是否使用单例模式,还是要考虑到实际的场景,再做决定。