AI毛毛的blog

关于单例模式在C++下的实现方式

最近学习C++的时候,看到了一些关于全局变量使用上的优劣点,其中有些资料中认为单例模式是更好的实现,单例可认为是更优雅的全局变量。
因为此模式是用来确保一个类仅存在一个实例,并提供对此唯一实例的全局访问节点,更为方便的是,它为对象的分配、销毁提供了控制,同时也能避免污染全局命名空间。

在C++中实现单例

C++中实现单例方法主要是通过隐藏构造函数来实现的。

简单实现的基本步骤

单例模式对外提供的接口,每次调用时返回类的同一个实例,考虑到C++的语言特性,设计单例类的实现步骤可以如下:

  1. 为了防止客户端自由创建新的实例,应该防止编译器自动声明公有的构造函数,所以需要将默认构造函数声明为私有或保护。
  2. 防止通过复制方式生成第二个实例,则需要将拷贝构造函数和赋值运算符也声明为私有或保护。
  3. 增加一个静态私有的指针变量,指向当前类。
  4. 提供静态的对外接口,让客户端获得上一步中单例类的指针,从而获得实例对象。不过考虑到客户端可以通过指针删除此对象,因此返回引用似乎更加稳妥。

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)这句代码中,其实是假定机器做了如下三件事:

  1. 在堆上为Singleton对象分配空间;
  2. 在此空间上初始化对象;
  3. 返回此对象的指针,即让instance指向这块空间。

历史上很长一段时间,编译器的优化策略,使得代码并不是完全按照这个顺序执行的(包括其它编程语言),有可能2、3步骤会互换次序,所以多线程环境,两个线程A、B第一次调用getInstance方法时,可能发生这样的事情:

  1. 线程A进入,此时instance为null,A获得锁,完成了step1、step3,此时A的时间片用完,线程A被挂起;
  2. 线程B进入,先判断instance,此时instance由于线程A的关系,并不是nullptr,则线程B直接获得了instance实例;
  3. 而此时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;
}

从逻辑上这样处理,使得操作愈发复杂,它的流程示意如下:

two-cones-dclp.png

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中引入难以重构的依赖,同时也使得单元测试变得困难。另外,单例模式违反了单一职责原则,因为它同时实现了了两个职责:只有一个实例,以及提供一个访问该实例的全局节点。

所以,是否使用单例模式,还是要考虑到实际的场景,再做决定。