Posts tagged 死引用
Singleton#2 关于全局对象,初始化依赖,死引用,线程安全
0此前曾经总结过一篇关于Singleton实现方式的文章,在这里:
http://www.windameister.org/blog/index.php/2009/01/18/singleton-initialization/
本文是对上文中未能涵盖的一些问题的补充。
首先是关于全局对象的,全局对象的某些缺陷正是促使我们使用Singleton的原因之一。看下面这个例子:
// xxx.cpp
GlobalData g_Global1;
GlobalData2 g_Global2;
在一个编译单元(某个CPP)中定义的全局变量,会按照定义的先后次序进行构造。如上定义的情况下,程序初始化阶段,会先构造g_Global1对象,而后构造g_Global2。然而我们曾经提到过,如果GlobalData的构造函数依赖于g_Global2则会产生问题,因为此时g_Global2还未被构造。
另外,在析构的时候,会按照与构造相反的顺序析构,也即先析构g_Global2再析构g_Global1。然而如果GlobalData的析构函数依赖于g_Global2,也会产生问题,因为到g_Global1析构的时候,此时的g_Global2已经析构完毕了,很可能所访问的地址已经是无效的。
当然我们可以按照对象相互之间初始化的依赖关系来确定其定义的顺序,然而这种感觉似乎不那么靠谱。首先,这种方法只在单个编译单元范围内起作用,而对跨编译单元的全局对象则无解(因为link的时候,会将各个cpp编译出来的obj文件链接到一起,而这个顺序并没有任何标准进行规定,各家编译器厂商都有自己的实现方式),如果cppA中有一个全局对象globalA,cppB中有一个globalB,我们不能推断出这两者的初始化顺序孰先孰后。
Imperfect C++的11.1.3中提到了一种控制全局对象初始化顺序的方案:
GlobalData* g_Global1;
GlobalData2* g_Global2;
int main()
{
GlobalData _global1;
GlobalData2 _global2;
g_Global1 = &_global1;
g_Global2 = &_global2;
…
}
通过将全局对象定义于main函数的栈空间中,我们可以获得对初始化顺序的完全控制权。然而这样的做法也有其特有的缺陷和局限性:
1、 全局指针可能被某个客户代码中的全局对象所拥有,并在析构中针对这个指针做些什么,如果是这样的话,由于全局对象析构时,main函数已经退出,并且其中的对象已经析构完毕,我们又一次遇到了针对已死亡对象的操作。
2、 我们可能并不总能拥有自己编写main函数的权利,当我们在利用某个现有框架进行编码的时候,很可能我们自己并不能拥有对入口函数的控制权,那么这种方法就很难奏效了。
以上是对全局对象的简单总结。事实上,全局对象和Singleton同样面临多线程竞争条件的问题,这也是全局对象不被推荐使用的原因之一。
下面再回到我们的正题Singleton:
Scott Meyers曾经描述过一种单件的实现途径,后被人们称为Meyers单件。大致的实现看起来如下:
class Thing
{
public:
Thing& GetInstance()
{
static Thing s_instance;
return s_instance;
}
private:
Thing(Thing const&);
Thing &operator = (Thing const&);
}
通过GetInstance方法,使用lazy-evaluation的方式来按需创建。(该方法在云风的《游戏之旅——我的编程感悟》一书中也有提及)。上述实现存在几个问题:
1、 局部静态对象——在多线程情况下将会导致竞争条件
2、 基于局部静态对象的前提条件,并且GetInstance方法被实现为内联函数,如果该头文件被两个模块共享,在某些编译器上会导致在不同模块中分别创建单件对象实例的情况。(感兴趣的话,可以用VC6做个实验)
3、 你无法控制该对象的生命期,该对象在第一次被使用时创建,与其他静态对象一起随进程关闭机制来销毁。(准确的说是在Main函数结束之后,会执行到一个专门做析构的代码块,这部分代码由编译器生成,作为程序的编写者,我们无从控制其析构顺序)可能另一个静态对象的析构函数,在s_instance析构之后再调用Thing::GetInstance(),这等于是在与一个已死去的人交谈,着被称为死引用(Dead Reference)。
针对死引用(Dead Reference)问题,Alexandrescu在Loki库中展示了一种Singleton技术,通过将单件对象挂钩到语言清理机制上,以确保他们按照一个相对寿命级别被销毁。这种方式,要求程序员对单件的寿命级别进行手工赋值,然而这很难保证不出错。
Imperfect C++(中文版166页)中提出了另一种针对死引用问题的解决方案,简单的说就是引用计数。假设A是一个单件,模块B,C都要依赖于A,A提供两个函数A_init()和A_Uninit(),这两个函数对A被依赖的次数进行计数,只有到被依赖值为0的时候,才释放自身。B和C在初始化以及销毁时分别调用A_init()和A_Uninit()。这样就可以保证任何一个依赖于A的模块都不会在A已经死亡之后再调用A。在这里,该方案确实能解决死引用问题,只要调用者记得在依赖A的模块中调用A_init()/A_Uninit()即可保证这一点。
关于多线程竞争条件:
此前已经多次提到单件对象存在多线程竞争条件的问题。比如Meyers单件中,情况看起来如下:
Thing& GetInstance()
{
static Thing s_instance;
return s_instance;
}
多线程条件下究竟会发生什么呢?
我们将上述代码改写一下,更容易看清楚发生了什么:经过编译器处理之后的上述代码,看起来类似于下面的样子:
Thing& GetInstance()
{
static bool _s_instance_initialized = false;
static byte _s_instance_mem[sizeof(Thing)];
if (_s_instance_initialized)
return *(Thing*)_s_instance_mem;
new(_s_instance_mem) Thing();
_s_instance_initialized = true;
}
为什么会产生类似于上面这样的代码?
首先,s_instance是一个局部静态变量,其内存会被分配于数据段中,这是为什么会有如下变量:static byte _s_instance_mem[sizeof(Thing)];的原因。
其次,局部静态变量在第一次被访问到的时候初始化,因此编译器必须通过某种手段记录该局部静态变量是否已经被初始化了,这是为什么有static bool _s_instance_initialized = false;的原因。
好了,接下来我们假设有两个线程A,B执行GetInstance(),此前GetInstance尚未被执行过,因此s_instance尚未被构造。
首先,A执行到if (_s_instance_initialized),发现对象尚未被初始化,准备jmp到构造对象的代码(A可能执行到从构造对象到赋值_s_instance_initialized之前的任意代码,不影响接下来所要描述的问题)。此时,线程切换,B执行,B再次发现_s_instance_initialized为false,于是也跳到构造对象的代码,调用了new(_s_instance_mem) Thing();,成功构造对象之后,B将_s_instance_initialized赋值为true,然后继续执行。不久,A线程被唤醒,并再次调用new(_s_instance_mem) Thing();构造对象,且将_s_instance_initialized赋值为true。
至此,实际上该s_instance被构造了两次。
如果想避免上述情况,我们可以对该static对象的构造过程加锁。确保如果有线程正在构造该对象的过程中的话,其他线程必须等待。
Imperfect C++中给出的做法如下:
Thing& GetInstance()
{
static int guard; // 该静态变量加载时会被初始化为0
spin_mutex smx(&guard); // 自旋互斥体,使用对整数guard的原子操作
// 保证当前只有一个线程进入接下来的代码段
lock_scope<spin_mutex> lock(smx); // 一个简单的外附类,
// 调用smx的lock及unlock
static Thing s_instance;
return s_instance;
}
遗憾的是,这种做法使得我们在调用GetInstance()时,无可避免的每次都要做线程同步操作,虽然正常情况下,这种竞争不大可能频繁到让人察觉,上述做法可以称得上是解决竞争条件问题的典型做法了。
前段时间,在阅读《程序员的自我修养——链接装载与库》一书中,我又看到另一个有意思的解决方案,名为double-check。一个典型的double-check的Singleton对象的代码如下:
volatile T* pInst = 0;
T* GetInstance()
{
if (pInst != NULL)
return pInst;
lock();
if (pInst == NULL)
pInst = new T();
unlock();
return pInst;
}
这种写法,一旦pInst被构造完成,后续的访问就不再需要同步操作了。
该书中提出了一个问题,为什么要有第二个对pInst的判断。因为按道理来说,如果此前已经判断过pInst!=NULL,那么后续只管构造对象就好,为什么在lock()之后,需要再一次判断呢?问题就出在“在lock()之后”,因为lock()是同步操作,如果线程B判断pInst != NULL的时候,另一个线程A已经lock()了,并且正在构造对象,但是尚未赋值给pInst,这时候线程B对pInst的判断会导致它继续走到lock(),并且由于线程A已经lock了,此时,线程B进入等待。直到线程A完成对象构造并将pInst赋好值,并且unlock。
如果线程B在lock之后不对pInst进行判断的话,同样可能会出现构造两次的情况。
不过书中提到了基于double-check的代码的另一个问题,该问题实际上源于CPU的乱序执行优化能力。
C++中的new包含了两个过程,分配内存,在分配好的内存上调用构造函数。所以pInst = new T()包含三个步骤:
1. 分配内存
2. 调用构造函数
3. 将内存地址赋值给pInst
问题出在第2,3步上,因为2,3的顺序是可以颠倒的,因此完全可能出现,内存地址先被赋值给了pInst,而对象尚未完全构造完成的状态。此时如果另一个线程获取了CPU,并调用了GetInstance,会发现pInst已经不为0,于是返回了一个尚未完全构造的对象给用户使用。那么这个时候程序会发生什么就取决于该类如何设计了。
该书中还提到,可以使用barrier指令,禁止CPU的乱序执行功能对该逻辑的破坏。具体可以参考《程序员的自我修养》一书。
话说回来,double-check的办法有没有可能被应用在局部静态对象上,从而将同步操作的开销降至最低?答案是显然的,方法如下:
Thing& GetInstance()
{
static Thing* s_pointer = NULL;
if (s_pointer)
return *s_pointer;
lock();
if (!s_pointer)
{
static Thing s_instance;
s_pointer = &s_instance;
}
unlock();
return *s_pointer;
}
不过CPU乱序执行的能力依然可能打乱这个逻辑:可能s_pointer已经被赋值了&s_instance,而此时s_instance的构造函数尚未返回。情况与此前的new T()版本的double-check的Singleton所描述的一致。
自从上次将Singleton常见的形式进行一次总结之后,一直想对Singleton的初始化,竞争条件,以及死引用的问题进行一番总结,本文拖拖拉拉写了几个月,终于在连续一整天没有网络的情况下得以完成,非常安慰~
最后一个收获是,好书应该反复读,每次重读都可能发现此前被忽略的知识,都可能有新的领悟,对原先理解不深刻的知识,有可能会加深理解。