最近在看《程序员的自我修养》,做一下笔记。
原子操作
典型的例子就是++i这种,看着像是一条语句,其实编译器会把它翻译成多条执行命令,让操作系统执行。i++的汇编语句执行过程:
1) 读取i到某个寄存器X
2) X++
3) 将X的内容存储回i。
所以两个线程同时操作i的时候,会出现交叉赋值的情况,使执行结果变得未知。
我们把汇编语句层面的单条指令的操作成为原子。
在Windows中,有一套API专门进行一些原子操作,这些API成为Interlocked API。
编写可重入函数,保证线程安全
一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
1)多个线程同时执行这个函数
2) 函数自身调用自身
一个函数被称为可重入,表明该函数被重用之后不会产生任何不良后果。举例:
int sqr(int i) { return i*i; }
可重入函数的特点:
1) 不使用任何(局部)静态或全局的非const变量。
2) 不返回任何(局部)静态或全局的非const变量的指针。
3) 仅依赖于调用方提供的参数,
4) 不依赖任何单个的资源的锁
5) 不调用任何不可重入的函数
可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用
过度优化
过度优化不是指我们自己优化过度。而是指编译器或者操作系统帮我们进行偷偷的优化,导致我们的程序在多线程下出现一些怪异的情况。
x = 0 Thread1 Thread2 lock() lock() x++; x++; unlock() unlock()
上面提到过的,现在用锁给保护了,X++的行为不会被并发所破坏。那么X的值必然可以预测。
然而,如果编译器为了提高X的访问速度,把X放到某个寄存器里,由于不同线程的寄存器是各自独立的。因此如果Thread1先获得锁,则程序的执行可能会出现如下的情况:
可见这样的情况下即使正确的加锁,也不能保证多线程安全。
例子2
x=y=0 Thread1 Thread2 x=1; y=1; r1=y; r2=x;
很显然,r1和r2至少有一个为1,逻辑上不可能同时为0.
然而,事实上r1=r2=0的情况确实可能发生。原因在于十几年前,CPU就发展出了动态调度,在执行程序的时候为了提高效率可能交换指令的顺序。同样,编译器在进行优化的时候,也可能为了效率而交换毫不相干的两条相邻执行(如x=1和r1=y)的执行顺序。以上代码执行的时候可能是这样的:
x=y=0; Thread1 Thread2 r1=y; y=1; x=1; r2=x;
那么r1=r2=0就完全可能了。我们可以使用volatile关键字试图阻止过度优化,volatile基本可以做到两件事情:
1)阻止编译器为了提高速度将一个变量缓存到寄存器内而不回写。
2) 阻止编译器调整操作volatile变量的指令顺序。
可见volatile可以解决编译器层面的顺序调整问题,但是无法阻止CPU动态调度换序
例3
单例模式的实现 https://www.cnblogs.com/myd620/p/6133420.html
volatile T *pInst = 0; T *getInstance() { if(pInst == NULL) { lock(); if(pInst == NULL) { pInst = new T; } unlock(); } return pInst; }
上面代码双重if在这里另有妙用,可以让lock的调用开销降低到最小。
问题剖析
问题来源仍然是CPU的乱序执行。
C++里的new其实包含两个步骤:
1) 分配内存
2)调用构造函数
所以pInst = new T包含了三个步骤:
1) 分配内存
2)在内存的位置上调用构造函数
3) 将内存的地址赋值给pInst
在这三步中,2)和3)是可以颠倒的。也就是说,完全可以出现pInst的值已经不是NULL, 但对象仍然没有构造完毕,这时候如果出现另外一个对getInstance的并发调用,此时第一个if内的表达式pInst==NULL为false,会返回一个为构造对象的地址给用户,当然了,程序就崩溃了。
如何解决这个问题呢?
volatile T *pInst = 0; T *getInstance() { if(pInst == NULL) { lock(); if(pInst == NULL) { T* temp = new T; pInst = temp; } unlock(); } return pInst; }