线程安全的单例模型的演变与Double-Check-Locking的安全性

来源:互联网 发布:网络电玩城网站 编辑:程序博客网 时间:2024/05/22 15:29
拜读了论文《C++ and the Perils of Double-Checked Locking》-- Scott Meyers and Andrei Alexandrescu September 2004
发现原来一个简单的单例模型,还有如此多的文章,上面提到的这篇文章值得反复读,从中可以衍生出很多并行编程的知识。
另外,C++11的内存模型和std::memory以及原子类,是我接下来关心的重点。

姑且把这个文章叫做《线程安全的单例模型演变》吧,从最简单的讲起

1、第一种实现,很简单
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. class Singleton {  
  2.  public:  
  3.    static Singleton* instance();  
  4.  private :  
  5.    static Singleton* pInstance;  
  6. };  
  7. Singleton* Singleton::instance() {  
  8.   if (pInstance == 0) { //(*)  
  9.     pInstance = new Singleton;  
  10.   }  
  11.   return pInstance;  
  12. }  
这种单例模式的实现,很简单,但是是存在问题的。在(*)行我们可以看到,这个 ==0 的的检测,是没有多线程互斥的。所以,一旦有多个线程同时检查 ==0 条件为true,那么每个线程都会去 new Singleton。这样结果就不对了,而且会导致内存泄露。

2、第二种实现,加锁
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. Singleton* Singleton::instance() {  
  2.   Lock lock; // acquire lock (params omitted for simplicity)  
  3.   if (pInstance == 0) { //(*)  
  4.     pInstance = new Singleton;  
  5.   }  
  6.   return pInstance;  
  7. // release lock (via Lock destructor)  
因为第一种实现方式错误的根源在于,==0的检测没有做到多线程的互斥,所以,我们可以在新的实现方式中,我们在检测 ==0 之前加锁了。这是一种解决问题很好的方法,但是每次获取这个单例对象的时候,都要加锁,性能会严重受到影响。在程序运行的过程中,除了第一次new发生的时候,(*)行这个==0的检测结果都是false的,这个加锁,有点浪费。
看下来,这种实现存在的是性能上的问题。那我们往后面看。

3、好了,我们大名鼎鼎的 Double-Checked-Locking来了,看代码:
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. Singleton* Singleton::instance() {  
  2.   if (pInstance == 0) { // 1st test  
  3.   Lock lock;  
  4.     if (pInstance == 0) { // 2nd test  
  5.       pInstance = new Singleton;  
  6.     }  
  7.   }  
  8.   return pInstance;  
  9. }  
这是一种很经典的实现,因为我们对指向单例对象的指针,写了两次的==0检测。因为程序运行过程中,大部分的时候,这个指针都是 !=0的,所以这个锁就可以避免了,只有在第一次new Singleton之前,可能会发生锁竞争。一旦new完成之后,锁就不会被用到了。

愿景是美好的,但是现实是残酷的,上面的实现仍然存在问题。问题源自于CPU的乱序执行。
简单讲一下乱序执行的概念,在现代CPU的实现中,允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。注意关键字:“不安程序规定的顺序”。
对于我们上面代码中一个语句: pInstance = new Singleton; 在计算机里面会被分解为3个指令:
  Step 1: 申请一块内存,大小为sizeof(Singleton)
  Step 2: 在这块内存上面构造Singleton
  Step 3: 把指针pInstance指向申请好的这块内存
我们可以细细考虑一下上面的Step2和Step3,如果这两步顺序调换了,对于这三部执行完的结果,是不是没有影响?确实没有影响,那么cpu就运行将这两步乱序。就是说,当执行这条语句时,程序员是不知道cpu是按123这个顺序执行的,还是132。
既然乱序执行的存在,我们不能保证Step2和Step3的执行顺序,那我们的程序执行过程有可能变成这样:
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. Singleton* Singleton::instance() {  
  2.   if (pInstance == 0) {  
  3.     Lock lock;  
  4.     if (pInstance == 0) {  
  5.       pInstance = // Step 2  
  6.       operator new(sizeof(Singleton)); // Step 1  
  7.       new (pInstance) Singleton; // Step 3  
  8.     }  
  9.   }  
  10.   return pInstance;  
  11. }  
在上面的示意代码中,先把新申请的内存赋给pInstance,然后再在上面构造对象。到这里,问题出现了。当我们执行到Step2的时候,也就是pInstance已经指向了申请的内存,但是是这块内存上面还没有构造Singleton。如果在Step
2执行之后,Step3执行之前,其他线程就会在进入instance()函数对第一个==0检测结果为false,然后就返回使用这个指针了并使用之。但是实际上,这个指针指向了未构造Singleton的内存,发生了不安全的使用,可能会段错误(段错误出core还算是幸运的结果)。

4、sequence point
在这里要讲一下sequence point的概念,有助于后面的理解。

大家知道,通常而言,我们写的计算机程序都是从上到下,从左到右依次执行。然而,我只是说通常,因为在编译的过程中,compiler并不仅仅是把source code翻译成binary code就算了,这个过程里面可能还会对代码进行优化,这种优化可能带来的结果是:代码或者表达式evaluation的顺序可能发生变化。这可是一个非常严重的问题,当某个表达式带有side-effect(比如改变了一个变量的值),那么它的执行顺序直接影响到了程序执行的结果。
为了保证程序执行具有确定性的结果,C++标准引入Sequence Point这个概念,按照ISO/IEC的定义:
At certain specified points in the execution sequence called sequence points. All side effects of previous evaluations shall be complete and no side effects of subsequent evaluations shall have taken place.

简而言之,Sequence Point就是这么一个位置,在它之前所有的side effect已经发生,在它之后的所有side effect仍未开始,而两个Sequence Point之间所有的表达式或者代码执行的顺序是未定义的!

而C++标准又进一步规定了Sequence Point出现的5种情况:
1) At the end of a full expression
在一个完整的表达式末尾是Sequence Point,所谓完整的表达式是指这个表达式不是另外一个表达式的一部分。所以如果有f(); g();这样两条语句,f()和g()是两个完整的表达式,f()的Side Effect必定在g()之前发生。
2) After the evaluation of all function arguments in a function call and before execution of any expressions in the function body 
调用一个函数时,在所有准备工作做完之后、函数调用开始之前是Sequence Point。比如调用foo(f(), g())时,foo、f()、g()这三个表达式哪个先求值哪个后求值是Unspecified,但是必须都求值完了才能做最后的函数调用,所以f()和g()的Side Effect按什么顺序发生不一定,但必定在这些Side Effect全部作用完之后才开始调用foo函数。
3) After copying of a returned value and before execution of any expressions outside the function 
函数即将返回时是Sequence Point,因为函数返回时必然会结束掉一个完整的表达式。
4) After evaluation of the first expression in a&&b,  a||b,  a?b:c,  or  a,b 
条件运算符?:、逗号运算符、逻辑与&&、逻辑或||的第一个操作数求值之后是Sequence Point。如条件运算符和逗号运算符,条件运算符要根据表达式1的值是否为真决定下一步求表达式2还是表达式3的值,如果决定求表达式2的值,表达式3就不会被求值了,反之也一样,逗号运算符也是这样,表达式1求值结束才继续求表达式2的值。
5) After the initialization of each base and member in the constructor initialization list
在一个完整的声明末尾是Sequence Point,所谓完整的声明是指这个声明不是另外一个声明的一部分。比如声明int a[10], b[20];,在a[10]末尾是Sequence Point,在b[20]末尾也是。

结合sequence point和下面的代码看,编译器只能保证statement1(是声名)和statement4(是非内联函数)的顺序,2和3的顺序无法保证
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. void Foo() {  
  2. int x=0,y=0; // Statement 1  
  3. x= 5; // Statement 2  
  4. y = 10; // Statement 3  
  5. printf( "%d,%d",x,y); // Statement 4  
  6. }  
编译器总是想着尽量随机化你的代码,让处理器可以在同一时间做更多的事情。
甚至,在C和C++中,编译器和链接器总是执行观察者行为,他们根据标准,编译器和链接器是只知道单线程的。那么系统库,比如libpthread是怎么实现多线程的呢?
libpthread不得不跳出C的框架,通过嵌入汇编代码,来保证在关键位置的顺序,不会被reorder。
而Double-Check-Locking是在C/C++的范畴内实现的,所以无法避免被reorder!

5、于是对于Double-Check-locking,我们有了这么几个鬼主意:
第一个鬼主意,用一个中间变量,让赋值给p,一定在new完之后:
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. mid = new T;  
  2. p = mid;  
我们可以分析一下,汇编中它会变成这样的执行顺序
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. tmp = operator new(sizeof(T)); (1)  
  2. new(tmp) T; (2)  
  3. mid = tmp (3)  
  4. p = mid     (4)  
乍一看,好像很聪明,其实不然。分析一下sequence point的概念,我们会发现(2)(3)(4)的顺序是无法保障的。甚至,编译器可能会把原先代码里面的tmp变量给优化掉了。

第二个鬼主意,把中间变量设置为static或者extern:
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. static T* mid = new T;  
  2. p = mid;  
不行的!编译器可以优化,只保证最后给你设置是tmp是对的。比如顺序如下
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. p = alloc(sizeof(T));   
  2. new(p) T;  
  3. tmp=p;  

7、第三个鬼主意:
关键代码写成非内联函数,这样就能成为一个sequence point。
不行的!很遗憾,有些编译器在编译的时候不内联,但是在链接的时候给你内联了,你就没辙了。

8、在解决Double-Check-Locking存在的问题中,我们还会想到用大名鼎鼎的volatile
在http://hedengcheng.com/?p=725这篇文章的测试样例,我们可以看到,两个volatile变量连续读写,是不会被reorder的。

于是我们就这么处理,让pInstance这个变量volatile了
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. class Singleton {  
  2.   public:  
  3.   static Singleton* instance();  
  4.   private :  
  5.   static Singleton* volatile pInstance; // volatile added  
  6.   int x;  
  7.   Singleton() : x(5) {}  
  8. };  
  9.   
  10. Singleton* Singleton::instance() {  
  11.   if (pInstance == 0) {  
  12.     Lock lock;  
  13.     if (pInstance == 0) {  
  14.       Singleton* volatile temp = new Singleton; // *  
  15.       pInstance = temp;  
  16.     }  
  17.   }  
  18.   return pInstance;  
  19. }  
上面的*行代码,等价于
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. Singleton* volatile temp =  
  2. static_cast <Singleton*>( operator newsizeof(Singleton)));  
  3. temp->x = 5; // inlined Singleton constructor  
  4. pInstance = temp;  
这样是不行的,因为即使 temp是指针是volatile的,但是temp指向的对象,也就是*temp却不是volatile的。也就是说,tmp->x=5;这行代码的执行,没有受到volatile的保护。那么这两行:temp->x = 5;和pInstance = temp;的顺序是不可知的,问题依然存在。

9、既然这样,那么把pInstance也volatile化吧,让pInstance指向的对象也volatile了
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. class Singleton {  
  2.   public:  
  3.   static volatile Singleton* volatile instance();  
  4.   private :  
  5.   static volatile Singleton* volatile pInstance;  
  6. };  
  7. volatile Singleton* volatile Singleton::instance() {  
  8.   if (pInstance == 0) {  
  9.     Lock lock;  
  10.     if (pInstance == 0) {  
  11. // one more volatile added  
  12.      volatile Singleton* volatile temp =  
  13.        new volatile Singleton;  
  14.       pInstance = temp;  
  15.     }  
  16.   }  
  17.   return pInstance;  
  18. }  
这也是有问题的,主要有两点:
1)根据标准, volatile保证对象不会乱序执行,也只是保证在本线程能不被乱序执行;
2) 一个volatile对象,只有在完全构造完成之后才会有volatile属性的

10、cache一致性也会导致问题
先复习一下cache一致性(在我的博文讲原子指针的时候也讲到过):每个cpu都有自己的cache,然后如果修改了cache还没有同步到内存,那么其他cpu就不能看到这个修改。而且每次同步到内存,都会按照地址顺序。假设y的地址在x的地址前面,当先修改x再修改y,那么y的值会比x先同步到内存。别的cpu上的线程就先看到y的修改,再看到x的修改。那么就又会出现,前面提到的问题:其他线程看到的现象是:申请好的内存的指针赋给pInstance,然后再构造。

所以,要解决cpu的cache一致性问题,我们只能使用内存屏障了(memory barriers)

内存屏障能保障什么?保障内存屏障之后的内存操作,比内存屏障之前的要后执行。位于内存屏障同一边的内存操作,不保证执行顺序(注意,内存屏障分割的只是内存操作,而不是什么赋值操作)(根据这个保障,分析为什么下面的代码是这么加内存屏障的)
[cpp] view plaincopy在CODE上查看代码片派生到我的代码片
  1. Singleton* Singleton::instance () {  
  2.   Singleton* tmp = pInstance;  
  3.   ... // insert memory barrier  
  4.   if (tmp == 0) {  
  5.     Lock lock;  
  6.     tmp = pInstance;  
  7.     if (tmp == 0) {  
  8.       tmp = new Singleton;  
  9.       ... // insert memory barrier  
  10.       pInstance = tmp;  
  11.     }  
  12.   }  
  13.   return tmp;  
  14. }  
第一个插入内存屏障,只需要使用“读内存屏障”(读屏障包含数据依赖屏障的功能, 并且保证所有出现在屏障之前的LOAD操作都将先于所有出现在屏障之后的LOAD操作被系统中的其他组件所感知)
而第二个插入的内存屏障,只需要是“写内存屏障”(在写屏障之前的STORE操作将先于所有在写屏障之后的STORE操作)
而不是都必须使用“通用内存屏障”(保证所有出现在屏障之前的LOAD和STORE操作都将先于所有出现在屏障之后的LOAD和STORE操作被系统中的其他组件所感知)
如果都使用“通用内存屏障”,那么开销太大咯。

Ok,就这么结束了,这个双锁检测是安全的了!^_^

11、总结(我觉得总结的很好,有些问题不用技术解决,而是策略就可以了,有时反而更好!!!赞!!!)
1)写线程安全的单例模型,还是要尽量不使用DCLP。如果你无法忍受每次都要加锁的开销,那么建议在客户端缓存单例的指针
2)在每个线程一开始就请求一次单例,然后缓存起来,以后用,就OK啦。
3)另外,我们怕的不是锁,而是锁竞争。如果竞争不激烈,其实也不怕。
4)我们可以不一定是到第一次有代码需要单例的时候才创造这个单例对象,而是主动的去创造好。那么也可以避免问题!
0 0
原创粉丝点击