原子操作
共享数据(全局变量或堆变量)的自增(++)操作在多线程环境下会出现错误是因为这个操作(一条c语句)被编译为汇编代码后不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,去执行别的代码。
我们把单指令的操作称为原子的(Atomic),因为无论如何,单条指令的执行是不会被打断的。为了避免出错,很多体系结构都提供了一些常用操作的原子指令,例如i386就有一条inc指令可以直接增加一个内存单元值。
在Windows里,有一套API专门进行一些原子操作(见下表),这些API称为InterlockedAPI。
Windows API 作用
InterlockedExchange 原子地交换两个值
InterlockedDecrement 原子地减少一个值
InterlockedIncrement 原子地增加一个值
InterlockedXor 原子地进行异或操作
使用这些函数时,Windows将保证是原子操作的,因此可以不用担心出现问题。遗憾的是,尽管原子操作指令非常方便,但是它们仅仅适用于比较简单的特定场合。在复杂的场合下,比如我们要保证一个复杂的数据结构更改的原子性,原子操作就力不从心了。这里我们就需要更加通用的手段:锁。
同步与锁
为了避免多个线程同时读写同一个数据(全局变量或堆变量)而产生不可预料的后果,我们需要将各个线程对同一个数据的访问同步(Synchronization)。所谓同步,即是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。
同步的最常见方法是使用锁(Lock)。即每个线程在访问数据或资源之前首先试图获取(Acquire)锁,并在访问结束后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。
二元信号量
二元信号量(Binary Semaphore)是最简单的一种锁,它只用两种状态:占用与非占用。它适合只能被唯一一个线程访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,知道该锁被释放。
信号量
对于允许多个线程并发访问的资源,多元信号量简称为信号量(Semaphore),它是一个很好的选择。一个初始值为N的信号量允许N个线程并发访问。
线程访问资源的时候首先获取信号量,进行如下操作:
■将信号量的值减1。
■如果信号量的值小于0,则进入等待状态,否则继续执行。
访问资源之后,线程释放信号量,进行如下操作:
■将信号量的值加1。
■如果信号量的值小于1,唤醒一个等待中的线程。
互斥量(Mutex)
和二元信号量很类似,即资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。
临界区(Critical Section)
是比互斥量更加严格的同步手段。在术语中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统中任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁时合法的。然而,临界区的作用范围仅限于本进程中,其他的进程无法获取该锁(类似于静态全局变量对全局变量)。除此之外,临界区具有和互斥量相同的性质。
读写锁(Read-Write Lock)
致力于一种更加特定的场合的同步。对于一段数据,多个线程同时读取总是没问题的,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一种来进行同步,尽管可以保证程序争取,但对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个锁,读写锁由两种获取方式,共享的(Shared)或独占的(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它必须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。读写锁的行为可以总结为如下表:
读写锁状态 以共享方式获取 以独占方式获取
自由 成功 成功
共享 成功 等待
独占 等待 等待
条件变量(Condition Variable)
作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。