程序员的自我修养-线程安全

原子操作

自增(++i)操作在多线程环境下出现错误是因为这个操作被编译成的代码不止一条指令,因此在执行的时候可能执行到一半被系统调度打乱,去执行别的代码。我们把单指令的操作称为原子的(atomic),因为无论如何,单条指令的执行时不会被打乱的,为了避免出错,很多体系结构都提供了一些常用操作的原子指令。

同步与锁

为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们要将多个线程对同一个数据的访问同步。所谓同步,指一个线程正在访问数据的时候,其他线程不能对同一个数据访问。

同步的最常用方法是使用锁。==锁是一种非强制的机制==,每个线程在访问之前试图去获取锁,并在访问之后释放锁,如果锁在被访问的时候已经被占用,线程等待,直到锁重新可用。

二元信号量是最简单的一种锁,她只有两种状态,占用和非占用,它适合只能被唯一线程访问的资源;对于允许多个线程并发访问的资源,多元信号量简称信号量(semaphore),她是一个很好的选择,多元信号量维护一个计数。

互斥量 (mutex)

和二元信号量类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统中可以被任意线程捕获,也就是说同一个信号量可以被系统的一个线程获取后由另一个线程释放,而互斥量则要求哪个线程获取了互斥量,哪个线程就要释放整个锁,其他线程越俎代庖释放信号量是无效的。

临界区 (Critical section)

临界区是比互斥量更严格的同步手段,在术语中,把临界区的锁的获取称为进入临界区,把锁的释放称为离开临界区。临界区和互斥量的区别在于,互斥量和信号量是在系统的任何进程都是可见的,然而临界区的作用范围是仅限于本进程,其他的进程无法获取。除此之外,临界区和互斥量有相同的性质。

读写锁

致力于一种更加特定场合的同步,对于一段数据,多个线程同时读总是没问题的,但假设操作不是原子的,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段避免出错。假如我们使用上述信号量,互斥量、临界区的方式进行修改,尽管正确,但是比较麻烦,对于读频繁但是写入比较少的情况,也比较低效。

读写锁可以解决这个问题,对于同一个锁,读写锁有两种获取方式,共享的或者是独占的,当锁处于自由的状态时,试图以任意一种方式获取锁都能成功;如果锁处于共享的状态,其他线程以共享的方式获取锁仍然可以成功,此时这个锁分配给了多个线程,然而,如果其他线程试图以独占的方式获取锁已经处于共享状态的锁,它必须等待锁被所有的线程释放;相应的,处于独占状态的锁将阻止任何其他线程获取该锁。

过度优化

线程安全是一个非常烫手的山芋,因为即使合理地使用锁,也不一定能保证线程安全,这是落后的编译器技术已经无法满足日益增长的并发需求。

1
2
3
4
5
x=0;
Thread1 Thread2
lock(); lock();
x++; x++;
unlock(); unlock();

如果编译器为了提高x的访问速度,把x放到某个寄存器里面,由于CPU的动态调度和编译器为了优化而交换毫不相干的相邻指令,此时锁失效。

我们可以使用 volatile关键字阻止过度优化,其基本可以做2件事,一是阻止编译器为了提高速度将一个变量缓存到寄存器里面而不写会,而是阻止编译器调整操作volatile变量的指令顺序。

cpu 乱序执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define barrier() __asm__ volatile ("lwsync")
volatile T* pInst = 0;

T* GetInstance()
{
if(!pInst)
{
Lock();
if()
{
T* temp = new T;
barrier();
pInst = temp;
}
unLock();
}
return pInst;
}

上面的代码加了一个barrier,是为了解决CPU的乱序执行。C++里面的new实际包含两个步骤,1、分配内存;2.调用构造函数。pInst = new T;包含三个步骤,1.分配内存,2,在内存的位置调用构造函数,3.将内存的值付给pInst, 其中2 3 是可能乱序执行的。

通常情况下,调用CPU提供的一条指令可以阻止CPU乱序,这条指令通常被称为barrier,一条barrier指令会阻止CPU将指令之前的指令交换到barrier之后。在powerPC中,这条指令是 lwsync.

-->