回 帖 发 新 帖 刷新版面

主题:[转帖]如何解决静态变量的线程安全问题

这是一位同事写的文章, 在此转贴, 大家讨论.

通常C++中很常见的Singleton实现,是利用一个模版类中的静态函数(或者一个直接的模版函数),在其中定义一个静态变量来实现的,例如:

template <class T>
class Singleton
{
public:
 static T& Instance()
 {
  static T obj;
  return obj;
 }
};

而这里存在一个著名的隐患,就是static变量的初始化时间为函数被首次调用时。其汇编码如下(在VS2008下生成):

004115BD  mov         eax,dword ptr [`Singleton<SomeClass>::Instance'::`2'::`local static guard' (41A154h)] 
004115C2  and         eax,1 
004115C5  jne         Singleton<SomeClass>::Instance+79h (4115F9h) 
004115C7  mov         eax,dword ptr [`Singleton<SomeClass>::Instance'::`2'::`local static guard' (41A154h)] 
004115CC  or          eax,1 
004115CF  mov         dword ptr [`Singleton<SomeClass>::Instance'::`2'::`local static guard' (41A154h)],eax 
004115D4  mov         dword ptr [ebp-4],0 
004115DB  mov         ecx,offset obj (41A148h) 
004115E0  call        SomeClass::SomeClass (41100Ah) 
004115E5  push        offset `Singleton<SomeClass>::Instance'::`2'::`dynamic atexit destructor for 'obj'' (411226h) 
004115EA  call        @ILT+125(_atexit) (411082h) 
004115EF  add         esp,4 
004115F2  mov         dword ptr [ebp-4],0FFFFFFFFh 
,有一个dword类型的local static guard,初始值为0,函数每次被调用时,都检查这个数的最后一个二进制位是否为1,如果不为1,则调用构造函数004115E0  call        SomeClass::SomeClass (41100Ah) 。

注:在C++0x标准中,规定了编译器必须完成static变量的线程安全问题,所以在支持0x标准的编译器下,直接使用原始的Singleton定义即可。

因此,如果存在多线程同时首次访问该函数,多个线程获取到的local static guard均为0,然后分别调用了一次构造函数。如果对象中包含内存的分配,或者包含重要资源的分配,则这些内存/资源会发生泄漏。而且更严重的问题是,如果对象的构造函数较为复杂,例如往容器中填写了某些数据,那么甚至可能会产生严重错误或崩溃,甚至即使加锁也不能解决问题!

举例:

构造函数流程为:

1、调用成员的构造函数(这通常是无法改变的)
2、初始化锁
3、加锁
4、往成员的某个容器里填写数据
5、解锁。

那么,以下情况下会导致十分严重的连锁错误,最后极度可能发生崩溃:

1、A线程调用成员的构造函数
2、A线程初始化锁
3、A线程加锁
4、A容器更改了某个成员,并还未来得及完全保存下来,发生了线程切换,或者是在另一个CPU核心上B线程开始了工作
5、B线程调用成员的构造函数
6、B线程初始化锁,并覆盖了A线程所初始化的锁,使得A线程所初始化的锁泄漏。
7、B线程加锁,不会被A线程阻塞(因为是不同的锁)
8、B线程试图更改某个成员,但是成员数据已经发生了不一致,从而导致生成错误的数据,甚至崩溃
9、B线程解锁
10、A线程试图解锁,但所初始化的锁已经被覆盖,结果使得线程锁发生异常。

从上面的流程可以看出,即使加了临界区锁也不能解决该对象被构造两次的问题(因为锁本身也需要构造)。除非采用命名锁之类的避免受静态内存影响的锁,那样既繁琐又不方便使用。

但是,利用数值等类型的静态变量构造方式的不同(直接在静态区写入数值),我们可以很方便的构造出一个安全的Singleton类:

template <typename T>
class ThreadSafeSingleton
{
public:
 static T& Instance()
 {
  static long state = 0;
  static char obj[sizeof(T)];

  if (state == 0)
  {
   long val = InterlockedAdd(&state, 2);
   if (val == 2)
   {
    new (obj) SomeClass();
    InterlockedDecrement(&state);
   }
   else
   {
    InterlockedAdd(&state, -2);
   }
  }
  while ( state > 1 )//也可以是while ((state & 1) == 0)
  {
   Sleep(0);
  }

  return reinterpret_cast<T&>(obj[0]);
 }
};

 

其原理是用state变量代替local static guard,state为0表示对象尚未构造,state为1表示对象已经构造,state大于1通常表示对象正在构造中。另外定义了一个obj空间,用于盛放该对象。
当开始有多个线程同时进入时,state首先为0,所有线程均进入if之后的语句块。此时,所有线程开始InterlockedAdd,因为操作系统保证此操作的原子性,因此有且仅有一个线程会得到val == 2(第一个调用该函数的线程)。此时,这个线程调用该对象的构造函数,并将state减1。其它线程将state再减2,并退出这个语句块,假装自己没有来过。并且,之后进入此函数的线程都会获得state != 0,从而不进入这个语句块。
随后,其他线程均处在后一个循环中,等待构造的线程构造完毕。Sleep(0);主要是为了主动让出CPU控制权,避免构造的线程得不到CPU控制权。构造线程构造完毕后 ,将state减1,并退出这个语句块。此时,所有线程检测到state为1,因此将直接返回。

以上代码还存在一个BUG,就是程序退出时析构函数没有被调用。还有一个比较头疼的地方就是InterlockedAdd这个函数在32位下通常没有。因此小小的修补一下,改用了另一种思路:

template <typename T>
class ThreadSafeSingleton
{
 class holder
 {
  char m_space[sizeof(T)];
  long &m_state;
 public:
  holder(long &state)
   : m_state(state)
  {

  }
  ~holder()
  {
   if ( m_state < 0) //通常静态对象的析构应在所有线程结束后,因此不考虑还有线程停留在构造函数中。
    reinterpret_cast<T*>(this)->~T();
  }
 };
public:
 
 static T& Instance()
 {
  static long state = 0;
  static holder obj(state);

  if (state == 0)
  {
   long val = InterlockedIncrement(&state);
   if (val == 1)
   {
    new (&obj) T();
    state = (1<<31);
   }
  }
  while ( state > 0 )
  {
   Sleep(0);
  }

  return reinterpret_cast<T&>(obj);
 }
};

主要就是信任不会有0x80000000个并发线程,因此在构造完成后,state应当一直是负值,不可能被并发线程突然使得state达到0。

以上版本为最终版本,欢迎大家讨论。

回复列表 (共17个回复)

11 楼

[quote]能不能用中文简单的介绍一下啊!这么多的英文见了头大![/quote]

只看代码就可以啦。

12 楼

既然只是创建的时候出问题,那么在并发之前,所有Singleton先调用get触发一下构造好了,我的思路跟sorrow一致。get里面就不需要lock了。

要lock,就用指针。指针比较麻烦,还得找一个集中析构的地方。。。。

我看公司用的singleton是用的指针,集中创建,集中销毁,也蛮好用的。

13 楼

多线程接触不多,从细节上提一点个人看法。
1. 对于“while ( state > 0 )”循环,似乎应该对state使用volatile关键字?否则若编译器优化把state的值放到寄存器中,则循环卡死。
2. 考虑对异常的处理。最终版中“new (&obj) T();”这句,若构造函数出现异常,则“state = (1<<31);”不会执行,于是其它线程会卡死在“while ( state > 0 )”这个循环中。虽然对于Singleton来说构造函数出现异常的概率较低,但是“异常时导致其它线程卡死”这个代价也太不应该了。
3. 有建议说不要用char space[sizeof(TheClass)]; 这个关系到字节对齐等问题。以VC来说,char数组的起始地址一般是4的倍数,但如果TheClass的各个成员都是double的话,起始地址为8的倍数会更佳。缓解的办法是用union: 如下:
union {
    char space[sizeof(TheClass)];
    int dummy1;
    char dummy2;
    double dummy3;
    // 其它各种基本类型、void指针、函数指针、类成员指针,等等
};
(虽然标准没有规定,但几乎所有编译器都会设置space, dummy1, dummy2, ...的首地址为同一位置。如此一来,编译器就会尽可能的满足各种类型的字节对齐要求。)



我觉得临界区不是没用,而是要看加在什么位置。以下的代码没有测试,但我认为是可行的~(至少是基本可行吧?没有多线程编程经验,专家们不要拍砖)

CCriticalSection g_cs;           // 全局变量
                                 // 也可以用类似物代替
                                 // 注意在多线程开始前必须初始化好
TheClass& GetSingleton()
{
    CSingleLock lock(&g_cs);     // 加锁
    static TheClass s_singleton; // 若需要,进行构造
    return s_singleton;          // 返回
}                                // 利用析构函数解锁。即使构造出现异常也可顺利解锁

总的思路就是“在单线程时就初始化锁,在多线程时可利用锁实现线程安全”。

接着看看反汇编。
从楼主贴出的反汇编代码来看,代码中出现了两条call指令。一条是调用了构造函数,另一条则调用了atexit。
C++规定全局变量、static变量的析构顺序必须与构造顺序完全相反,看来VC2008是使用atexit来实现这一点了。跟踪汇编代码进去看了看,发现atexit内部使用了EnterCriticalSection之类的函数来确保线程安全。不过我在跟踪过程中并未找到InitializeCriticalSection之类的函数。可以想象这个CRITICAL_SECTION是在之前就已经初始化好了的,不必在每个线程中判断是否需要初始化。

看来VC的CRT实现也采用了这种做法:在单线程时就初始化锁,在多线程时可利用锁实现线程安全。只要不是每次都去检查是否需要初始化锁,小心的操作应该可以确保正确。

不过CRT作为一个函数库,它的实现是被动的(它无法得知目前是单线程还是多线程),因此需要小心应付多线程带来的各种困扰。对于一般应用而言,自己的资源自己管理,没有必要弄那么复杂,1楼sarrow兄已经道出了真谛:就在单线程中把全局资源搞定!



Chipset兄贴了很多,暂时没有精力看完啦。觉得我自己的代码跟Chipset贴的第二段很像,只是不明白为什么要单独编写一个init函数?

14 楼

CCriticalSection g_cs;           // 全局变量

公司规定不充许用全局变量............如果没这限制,就完全没这问题了..............

15 楼

CCriticalSection g_cs;           // 全局变量

这将导致模块的依赖性问题。

16 楼

那就把全局变量移动到类里面去作为类的静态成员,一回事情,只是外观上看起来不是全局的。

17 楼

我看到了有sarrow的出现……抓你一把,还记得我不?我天地之灵。
居然有惊讶的发现了iAkiAk。。。赞热心大牛……我都很久没泡论坛了。
楼主那篇文章是我写的……

关于Spinlock的解决方案:主要是模仿《Imperfect C++》中的相关片断,但我记得书中的代码似乎没有考虑在while循环中发生了线程切换(或者线程的并行执行),所以就写了那么一段代码。不过确实用InterlockedExchange更加美观、直白。

回复eastcowboy:
1、确实如此。应当增加这个关键字。
2、是的。这个代码我只简单的表达了一个意思,并非安全到可以直接在项目中使用。除了这个问题外,还有其他可能的问题,比如在构造函数直接或间接的又调用了Instance,会导致所有线程一起卡死。
3、字节对齐问题对于静态变量来说没有什么考虑的必要。尾部对不对齐我们不关心,首部对不对齐由编译器去处理。如果你是指在这里建议编译器进行对齐的话,确实,我没考虑到这一点。但如果只从功能上来说的话,不会导致问题。
4、用全局变量作为锁当然没问题。只是我在自己研究C++的时候,和《imperfect C++》的作者有个共同的偏好:库只用头文件,不用cpp文件。如果要用全局变量,就要打破这个“美好的愿望”了

to: sarrow 想你了,偶尔和我打个招呼嘛,你不会没我QQ号了吧。
to: iAkiAk 最近在哪混呢。。

咳嗽,翻了下主题列表,惊讶的发现了燕子姐= =# 
记得最后一次和燕子姐接触是关于一个算法问题,之前吹大了,说几毫秒能算出来,后来搞不出来,然后被狠狠地鄙视- -# 那也是我最后一次搞算法的代码,凄凉啊。。。

我来回复

您尚未登录,请登录后再回复。点此登录或注册