场景:因为多线程程序处于一个多变的环境中,可访问的全局变量和堆数据随时均可能被其余线程改变。缓存
一个经典实例来阐述多个线程同时访问一个共享数据所形成的后果。安全
线程1多线程 |
线程2并发 |
i=1;函数 ++i;优化 |
--i;ui |
首先要明白++i的实现步骤以下:spa
(1) 读取i到某个寄存器X;线程
(2) X++;code
(3) 将X的内容存储回i。
若是线程1和线程2并发执行,则执行顺序以下:
执行序号 |
执行指令 |
语句执行后变量值 |
线程 |
1 |
i=1 |
i=1,X[1]=未知 |
1 |
2 |
X[1]=i |
i=1,X[1]=1 |
1 |
3 |
X[2]=i |
i=1,X[2]=1 |
2 |
4 |
X[1]++ |
i=1,X[1]=2 |
1 |
5 |
X[2]-- |
i=1,X[2]=0 |
2 |
6 |
i=X[1] |
i=2,X[1]=2 |
1 |
7 |
i=X[2] |
i=0,X[2]=0 |
2 |
为了不上述问题,有以下几种方式:
注解:锁是一种非强制机制,每个线程在访问数据或资源以前首先试图获取(Acquire)锁,并在访问结束以后释放(Release)锁。当锁已经被占用的时候试图获取锁时,线程会等待,直到锁从新可用。
锁的分类:
1) 二元信号量(只有两种状态:占用和非占用,适合只能被惟一线程访问的资源)
2) 多元信号量,也叫信号量(一个初始值为N的信号量容许N个线程并发访问)
获取过程:
将信号量的值减1;
若是信号量的值小于0,则进入等待状态,不然继续执行。
释放过程:
将信号量的值加1;
若是信号量的值小于1,唤醒一个等待中的线程。
3) 互斥量(与二元信号量类似,资源仅容许被一个线程访问)
二元信号量与互斥量的区别:二元信号量:在整个系统能够被人以线程获取并释放,即,同一个信号量能够被系统中的一个线程获取后以后,能够由另外一个线程释放;互斥量则要求哪一个线程获取了互斥量,哪一个线程就要负责释放这个锁。
4) 临界区(其做用范围仅限于本进程,其余进程没法获取该锁)
5) 读写锁(适用于读多,写少的场合)
读写锁有两种获取方式(共享的和独占的)
6) 条件变量(可让许多线程一块儿等待某个事件的发生,当事件发生时,全部的线程能够一块儿恢复执行。多元信号量,只能让一个线程恢复执行。)
编译器的过分优化也可能形成线程安全的问题,看以下几个例子。
例1:
x = 0; Thread1 Thread2 lock(); lock(); x++; x++; unlock(); unlock();
因为有lock和unlock的保护,x++的行为不会被并发所破坏,那么x的值彷佛必然是2了。其实否则,若是编译器为了提升x的访问速度,把x放到了某个寄存器里(不一样线程的寄存器是各自独立的),所以若是Thread1先得到锁,则程序的执行多是以下状况:
可见,在这样的状况下,即便正确的加锁,也不能保证多线程安全。
例2:
x = y = 0; Thread1 Thread2 x = 1; y = 1; r1 = y; r2 = x;
r1和r2至少有一个为1,逻辑上不可能同时为0.然而,事实上r1 = r2 = 0的状况确实可能发生。编译器在进行优化的时候,可能为了效率而交换绝不相干的两条相邻指令(如x=1和r1=y)的执行顺序。
则变为:
x = y = 0; Thread1 Thread2 r1 = y; r2 = x; x = 1; y = 1;
解决方法:
可使用volatile来阻止过分优化,volatile主要作两件事
1)阻止编译器为了提升速度将一个变量缓存到寄存器内而不写回
2)阻止编译器调整操做volatile变量的指令顺序。
这个方法能够解决第一个问题,但不能彻底解决第二个问题,由于volatile可以阻止编译器调整顺序,也没法阻止CPU动态调度换序。
例3:
volatile T* pInst = 0; T* GetInstance() { if (pInst == NULL) { lock(); if(pInst == NULL) pInst = new T; unlock(); } return pInst; }
当函数返回时,pInst老是指向一个有效的对象。而lock和unclock防止了多线程竞争致使的麻烦。
然而,实际上这样的代码是有问题的,问题来源于CPU的乱序执行。
C++里的new操做包含两个步骤:
(1) 分配内存
(2) 调用构造函数
因此pInst = new T包含了三个步骤:
(1) 分配内存
(2) 在内存的位置上调用构造函数
(3) 将内存的地址赋给pInst
在这3步中,(2)(3)的顺序是能够颠倒的,也就是说可能出现这种状况:pInst的值已经不是NULL,但对象仍然没有构造完毕。这时候若是出现另一个对GetInstance的并发调用,此时第一个 if内的表达式pInst == NULL为false,因此这个调用会直接返回还没有构造彻底的对象的地址(pInst)以提供给用户使用。
从上面的例子中能够看出,阻止CPU换序是必需的。但目前并不存在可移植的阻止换序的方法。一般状况下是调用CPU提供的一条指令(barrier)。
barrier 指令用于阻止CPU将该指令以前的指令交换到barrier以后,反之亦然。
所以,例3能够修改成以下:
#define barrier() _asm_ volatile("lwsync") volatile T* pInst = 0; T* GetInstance() { if(!pInst) { lock(); if(!pInst) { T* temp = new T; barrier(); pInst = temp; } unlock(); } return pInst; }
因为barrier的存在,对象的构造必定在barrier执行以前完成,所以,当pInst被赋值时,对象老是无缺的。