第10章 线程
多线程主题可是个大块头,它本身就值得写好几本书[Bute1997, Rich1997]。简单起见,我将多线程编程(www.cppentry.com)的挑战全部归于对资源的同步访问。这里的资源可以是指单个变量、一个类,甚至是由某个线程生产另一个线程消费的东西。关键在于,如果两个或多个线程需要访问同样的资源的话,它们的访问必须是安全的。唉,C和C++(www.cppentry.com)面世时多线程还不像现在这般流行。因此:
Imperfection:C和C++(www.cppentry.com)对线程只字未提。
这意味着什么呢?嗯,在C++(www.cppentry.com)标准中你找不到任何地方有关于线程(threading)的任何描述。1那么,这是不是意味着你无法使用C++(www.cppentry.com)来写多线程的程序呢?当然不是。但这确实意味着C++(www.cppentry.com)对多线程编程(www.cppentry.com)没有提供任何(语言级别)的支持。这一点所导致的实际后果是相当可观的。
在编写多任务系统时,人们必须知道的两个经典概念是[Bute1997, Rich1997]竞争条件和死锁。竞争条件是在两个不同的执行中的线程同时访问相同的资源时产生的。注意,这里我使用了"执行中的线程"这个术语,实际上包括同一个系统中的进程,以及同一个宿主系统中相同或不同进程中的线程。
为了避免竞争条件,多任务系统使用了同步机制,例如互斥体、条件变量、信号量[Bute1997, Rich1997]等,来防止对共享资源的并发访问。当某个执行中的线程获取了一个资源时(也被称为锁定(lock)某个资源),其他需要该资源的线程都被锁在外面,并进入等待状态直到该资源被释放(即解锁(unlock))。
当然,两个或多个执行中的独立线程之间的交互通常具有潜在的高度复杂性,并且可能出现这样的情况:两个资源分别被两个线程所获取,而这两个线程都需要同时拥有这两个资源才能继续执行,但目前的状态却是各持其一,并等待对方释放另一个。这种情况就是所谓的死锁(deadlock)。另一个不那么常见、但也是致命的情况,是活锁(livelock),即一组进程中的每一个都不断地根据该集合中的其他进程的状态的改变而不断改变自身的状态,从而可能导致每个进程都停滞不前的情况。
竞争条件和死锁是难于预见或测试的,这是多线程编程(www.cppentry.com)的实际挑战之一。虽然死锁很容易发现:你的可执行文件停止执行了,但是它们很难诊断,因为其时你的进程(或其中的某个线程)已经挂起了。
10.1 对整型值的同步访问
由于每次当进程遇到线程切换时,处理器寄存器的内容都会被保存到线程上下文当中,因此同步的最基本形式就是确保对单个内存位置的访问是被序列化的。如果被读取的内存位置的大小是一个字节,那么处理器就能够原子地访问它。实际上,依照给定架构的规则,同样的逻辑也可以被运用于对更大数值的读取。例如,一个32位的处理器能够确保对32位值的访问是序列化的,前提是这个32位的值必须对齐到32位边界上。1序列化对未对齐的数据的访问可能也可能不被某个处理器支持。很难想象一个可用的架构会不支持这种原子操作。
如果你希望原子地读写平台相关大小的整型值,处理器的这种保证已经够好,但除此之外你可能还想让实际上由几个操作构成的一个整体的操作也成为原子的。然而由于这类操作本身是由多个操作构成的,所以并非原子的。最经典的一个例子是递增或递减变量。下面的这条语句:
- ++i;
其实只是以下语句的简写形式:- ii = i + 1;
对i递增会导致如下的几个步骤:从内存中获取其值,2将该值加1,最后再将新值保存到i的内存位置中。这个过程被称为"读-改-写(RMW,Read-Modify-Write)"[Gerb2002]。由于这个过程分为三步,所以如果任何其他线程试图并发操纵i的话,就可能导致这两个线程之一或全部被破坏。如果两个线程都试图递增i,那么就可能产生步骤"脱节"的情况: - 线程1 线程2
- 从内存获取i
- 从内存获取i
- 值增1
- 值增1
- 将新值存储至i中
- 将新值存储至i中
两个线程都从内存中获取同样的值,当线程1保存递增后的值时,会覆盖线程2刚保存的结果,从而导致两个递增操作之一被作废了。
在实践中,不同的处理器对这些步骤会生成不同的操作指令,例如,在Intel处理器上的实现可能像这样:
- mov eax,dword ptr [i]
- inc eax
- mov dword ptr [i], eax
或更简洁,如下:- add dword ptr [i],1
即使是对于只含单个指令的后一种形式,对于多处理器的系统来说它也不能保证原子性,因为从逻辑上它跟第一种是等价的,另一个处理器上的线程照样可以按照前面描述的方式介入该操作。