笔记和练习博客总目录见:开始读TLPI。
在本章中,我们介绍了线程可以用来同步它们操作的两种工具:互斥锁和条件变量。互斥锁让线程可以同步使用共享资源,这样例如一个线程就不会在另一个线程修改共享变量时同时去访问它。条件变量则做一个互补的工作:它们允许线程互相通知某个共享变量(或其他共享资源)的状态已经发生变化。
30.1 Protecting Accesses to Shared Variables: Mute.„
线程的一个主要优点是它们可以通过全局变量共享信息。然而,这种轻松的共享是有代价的:我们必须小心确保多个线程不会同时尝试修改同一个变量,或者一个线程在另一个线程修改变量时尝试读取其值。术语“临界区”用来指访问共享资源的一段代码,这段代码的执行应该是原子的;也就是说,其执行不应该被同时访问相同共享资源的另一个线程打断。
清单30-1提供了一个简单的例子,说明当共享资源未被原子访问时可能出现的问题。该程序创建了两个线程,每个线程执行相同的函数。函数执行一个循环,重复将全局变量 glob 加 1 的操作:先将 glob 复制到局部变量 loc,然后将 loc 自增,最后再将 loc 复制回 glob。(由于 loc 是分配在每个线程栈上的自动变量,每个线程都有自己的 loc 副本。)循环的迭代次数由程序提供的命令行参数决定,如果没有提供参数,则使用默认值。
Listing 30-1: Incorrectly incrementing a global variable from two threads
代码略。
Figure 30-1: Two threads incrementing a global variable without synchronization
💡 上图的表示方式值得学习。
当我们运行清单 30-1 中的程序,并指定每个线程将变量递增 1000 次时,一切看起来都很正常:
$ ./thread_incr1000glob=2000然而,这里很可能发生的情况是,第一个线程完成了它的所有工作并在第二个线程甚至还没开始之前就终止了。当我们让两个线程做更多的工作时,我们会看到一个相当不同的结果:
$ ./thread_incr10000000glob=10939877在这个序列结束时,glob 的值应该是 2000 万。这里的问题出现在如下执行顺序中(也可参见上面的图 30-1):
- 线程 1 将当前 glob 的值取到它的本地变量 loc 中。假设当前 glob 的值是 2000。
- 线程 1 的时间片用完,线程 2 开始执行。
- 线程 2 执行多个循环,每次都把当前 glob 的值取到本地变量 loc,增加 loc 的值,然后将结果赋给 glob。在这些循环的第一次中,从 glob 获取的值将是 2000。假设在线程 2 的时间片用完之前,glob 已经被增加到 3000。
- 线程 1 再次获得一个时间片,从它中断的地方继续执行。之前(步骤 1)它已经把 glob 的值(2000)复制到 loc 中,现在它增加 loc 的值并将结果(2001)赋给 glob。此时,线程 2 执行的增加操作的效果就丢失了。
如果我们用相同的命令行参数多次运行清单 30-1 中的程序,会看到 glob 打印的值会大幅波动:
$ ./thread_incr10000000glob=11413748$ ./thread_incr10000000glob=12065910$ ./thread_incr10000000glob=11500623$ ./thread_incr10000000glob=11338848这种非确定性行为是内核 CPU 调度决策变化无常的结果。在复杂程序中,这种非确定性行为意味着这类错误可能很少发生、难以重现,因此也很难发现。
看起来我们似乎可以通过把 Listing 30-1 中 threadFunc() 函数的 for 循环里的三条语句替换为一条语句来消除这个问题:
glob++;/* or: ++glob; */然而,在许多硬件架构(例如 RISC 架构)上,编译器仍然需要将这条单独的语句转换为与 threadFunc() 循环中的三条语句等效的机器代码。换句话说,尽管它看起来很简单,即使是 C 语言中的自增操作符也可能不是原子操作,而且它可能表现出我们上面描述的行为。
💡 修改源代码为glob++后,结果确实如以上所述。glob的值普遍大些,说明冲突少些了,但不能避免冲突:
$ ./thread_incr10000000glob=15562912$ ./thread_incr10000000glob=12403784$ ./thread_incr10000000glob=12637472$ ./thread_incr10000000glob=15818286为了避免线程在尝试更新共享变量时可能出现的问题,我们必须使用互斥锁(mutex,互斥的缩写)来确保一次只有一个线程可以访问该变量。更广泛地说,互斥锁可以用来保证对任何共享资源的原子访问,但保护共享变量是最常见的用途。
互斥锁有两种状态:锁定和未锁定。在任何时候,最多只有一个线程可以持有互斥锁。尝试锁定已被锁定的互斥锁时,可能会阻塞或返回错误,这取决于用于加锁的方法。
当一个线程锁住一个互斥量时,它就成为这个互斥量的所有者。只有互斥量的所有者才能解锁它。这个特性改善了使用互斥量的代码结构,同时也允许在互斥量实现上进行一些优化。正因为有这个所有权特性,术语 acquire(获取)和 release(释放)有时会被作为 lock(锁定)和 unlock(解锁)的同义词使用。
通常,我们为每个共享资源(可能由多个相关变量组成)使用不同的互斥量,每个线程在访问资源时遵循以下协议:
- 锁住共享资源的互斥量;
- 访问共享资源;以及
- 解锁互斥量。
最后,需要注意的是,互斥锁的使用是建议性的,而不是强制性的。这意味着线程可以选择忽略互斥锁,直接访问相应的共享变量。为了安全地处理共享变量,所有线程都必须在使用互斥锁时相互配合,遵守它所执行的锁定规则。
Figure 30-2: Using a mutex to protect a critical section
30.1.1 Statically Allocated Mutexes
互斥锁可以作为静态变量分配,也可以在运行时动态创建(例如,在通过 malloc() 分配的内存块中)。动态创建互斥锁稍微复杂一些,我们会把这部分内容留到第 30.1.5 节再讨论。
互斥锁是 pthread_mutex_t 类型的变量。在使用之前,互斥锁必须先初始化。对于静态分配的互斥锁,我们可以通过将其赋值为 PTHREAD_MUTEX_INITIALIZER 来初始化,就像下面的示例一样:
pthread_mutex_tmtx=PTHREAD_MUTEX_INITIALIZER;根据 SUSv3,对本节剩余部分描述的操作应用到互斥锁的副本上会产生未定义的结果。互斥锁操作应该始终只在通过 PTHREAD_MUTEX_INITIALIZER 静态初始化或使用 pthread_mutex_init()(在第 30.1.5 节中描述)动态初始化的原始互斥锁上进行。
30.1.2 Locking and Unlocking a Mutex
初始化后,互斥锁是解锁的。要锁定和解锁互斥锁,我们使用 pthread_mutex_lock() 和 pthread_mutex_unlock() 函数。
#include<pthread.h>intpthread_mutex_lock(pthread_mutex_t*mutex);intpthread_mutex_unlock(pthread_mutex_t*mutex);Bothreturn0on success,or a positive error number on error要锁定一个互斥量,我们在调用 pthread_mutex_lock() 时指定这个互斥量。如果这个互斥量当前是未锁定状态,这个调用会立即锁定互斥量并返回。如果互斥量当前被其他线程锁定,那么 pthread_mutex_lock() 会阻塞,直到互斥量被解锁,此时它会锁定互斥量并返回。
如果调用线程本身已经锁定了传给 pthread_mutex_lock() 的互斥量,那么对于默认类型的互斥量,可能会出现两种实现定义的情况:线程会死锁,试图锁定它已经拥有的互斥量而被阻塞,或者调用失败,返回错误 EDEADLK。在 Linux 上,线程默认会死锁。(在 30.1.7 节讨论互斥量类型时,我们会描述其他可能的行为。)
pthread_mutex_unlock() 函数会解锁先前由调用线程锁定的互斥量。解锁一个当前未锁定的互斥量,或者解锁一个被其他线程锁定的互斥量,都是错误的。
如果有多个其他线程在等待获取通过 pthread_mutex_unlock() 解锁的互斥量,那么哪个线程能成功获取该互斥量是无法确定的。
Example program
清单30-2是清单30-1程序的一个修改版本。它使用互斥锁来保护对全局变量glob的访问。当我们用类似前面使用的命令行运行这个程序时,我们会看到glob总是可靠地被递增:
$ ./thread_incr_mutex10000000glob=20000000Listing 30-2: Using a mutex to protect access to a global variable
代码略。
pthread_mutex_trylock()andpthread_mutex_timedlock()
Pthreads API 提供了 pthread_mutex_lock() 函数的两个变体:pthread_mutex_trylock() 和 pthread_mutex_timedlock()。(可以查看手册页了解这些函数的原型。)
pthread_mutex_trylock() 函数和 pthread_mutex_lock() 一样,只不过如果互斥锁当前已经被锁定,pthread_mutex_trylock() 会失败并返回错误 EBUSY。
pthread_mutex_timedlock() 函数和 pthread_mutex_lock() 一样,只是调用者可以指定一个额外的参数 abstime,这会限制线程在等待获取互斥锁时可以休眠的时间。如果指定的 abstime 时间间隔到期而调用者仍未成为互斥锁的所有者,pthread_mutex_timedlock() 会返回错误 ETIMEDOUT。
pthread_mutex_trylock() 和 pthread_mutex_timedlock() 函数的使用频率远低于 pthread_mutex_lock()。在大多数设计良好的应用程序中,线程只应该持有互斥锁很短的时间,以便其他线程可以并行执行。这保证了那些被互斥锁阻塞的线程很快就能获得互斥锁。使用 pthread_mutex_trylock() 定期轮询互斥锁的线程,可能会在其他排队线程通过 pthread_mutex_lock() 依次获取互斥锁的情况下,被长期阻塞而无法访问互斥锁。
30.1.3 Performance of Mutexes
使用互斥锁的代价是多少?我们展示了两个不同版本的程序,用于增加一个共享变量:一个没有互斥锁(清单 30-1),另一个带有互斥锁(清单 30-2)。当我们在运行 Linux 2.6.31(带 NPTL)的 x86-32 系统上运行这两个程序时,我们发现没有互斥锁的版本每个线程执行 1000 万次循环总共只需 0.35 秒(但结果是错误的),而带互斥锁的版本则需要 3.1 秒。
起初,这看起来很昂贵。但考虑一下不使用互斥锁的版本(清单30-1)所执行的主循环。在那个版本中,threadFunc() 函数执行一个 for 循环,对循环控制变量进行递增,将该变量与另一个变量进行比较,进行两次赋值和另一次递增操作,然后跳回循环顶部。使用互斥锁的版本(清单30-2)执行相同的步骤,并且每次循环都会锁定和解锁互斥锁。换句话说,锁定和解锁互斥锁的成本大约是我们列出的第一个程序操作成本的十倍以下。这相对来说还是比较便宜的。此外,在典型情况下,线程会花更多时间去做其他工作,执行的互斥锁锁定和解锁操作相对较少,因此在大多数应用中,使用互斥锁对性能的影响并不大。
💡 虽然开销增长了10倍,但保证了正确性,这才是最重要的。
为了进一步说明这个情况,在同一个系统上运行一些简单的测试程序显示,使用 fcntl()(第 55.3 节)对文件区域进行 2000 万次加锁和解锁循环需要 44 秒,而对 System V 信号量(第 47 章)进行 2000 万次加一减一的循环需要 28 秒。文件锁和信号量的问题在于,它们每次加锁和解锁操作都需要系统调用,而每次系统调用都有一个小但显著的开销(第 3.1 节)。相比之下,互斥锁是使用原子机器语言操作来实现的(在所有线程可见的内存位置上进行),只有在锁争用的情况下才需要系统调用。
在 Linux 上,互斥锁是用 futex(意为快速用户态互斥锁的缩写)实现的,锁竞争则通过 futex() 系统调用来处理。我们在本书中没有描述 futex(它们并不打算直接在用户空间应用中使用),但可以在 [Drepper, 2004 (a)] 中找到相关细节,该文也说明了互斥锁是如何用 futex 实现的。[Franke 等, 2002] 是一篇(现在已经过时的)论文,由 futex 的开发者写的,描述了早期的 futex 实现,并分析了 futex 带来的性能提升。
30.1.4 Mutex Deadlocks
有时候,一个线程需要同时访问两个或更多不同的共享资源,而每个资源都有自己独立的互斥锁。当多个线程试图锁定同一组互斥锁时,就可能发生死锁。图30-3展示了一个死锁的例子:每个线程都成功锁定了一个互斥锁,然后尝试锁定另一个线程已经锁定的互斥锁。这两个线程就会一直被阻塞下去。
| 时间线 | Thread A | Thread B |
|---|---|---|
| 1 | pthread_mutex_lock(mutex1); | pthread_mutex_lock(mutex2); |
| 2 | pthread_mutex_lock(mutex2); | pthread_mutex_lock(mutex1); |
| 3 | blocks | blocks |
Figure 30-3: A deadlock when two threads lock two mutexes
避免这种死锁的最简单方法是定义一个互斥锁层次。当线程可能锁定同一组互斥锁时,它们应该总是按照相同的顺序锁定它们。例如,在图30-3的场景中,如果两个线程总是按照先锁mutex1再锁mutex2的顺序锁定互斥锁,就可以避免死锁。有时候,互斥锁之间存在一个逻辑上显而易见的层次。然而,即使没有,也可能制定一个任意的层次顺序,让所有线程都遵循这个顺序。
另一种不太常用的策略是“尝试,然后回退”。在这种策略中,线程先使用pthread_mutex_lock()锁定第一个互斥锁,然后使用pthread_mutex_trylock()锁定剩下的互斥锁。如果任何一个pthread_mutex_trylock()调用失败(返回EBUSY),线程就释放所有互斥锁,然后再尝试,可能会有一个延迟间隔。这种方法比锁层次的效率低,因为可能需要多次迭代。另一方面,它更灵活,因为不需要严格的互斥锁层次。这种策略的一个例子见[Butenhof, 1996]。
30.1.5 Dynamically Initializing a Mutex
静态初始化器值 PTHREAD_MUTEX_INITIALIZER 只能用于初始化具有默认属性的静态分配互斥锁。在其他情况下,我们必须使用 pthread_mutex_init() 动态初始化互斥锁。
#include<pthread.h>intpthread_mutex_init(pthread_mutex_t*mutex,constpthread_mutexattr_t*attr);Returns0on success,or a positive error number on errormutex 参数用来指定要初始化的互斥锁。attr 参数是一个指向已经初始化过的 pthread_mutexattr_t 对象的指针,用来定义互斥锁的属性。(接下来的部分我们会多说一些关于互斥锁属性的内容。)如果 attr 指定为 NULL,那么互斥锁会使用各种默认属性。
SUSv3 指出,初始化一个已经初始化过的互斥锁会导致未定义行为;我们不应该这样做。
有些情况下必须使用 pthread_mutex_init() 而不是静态初始化器,包括以下情况:
- 互斥锁是在堆上动态分配的。例如,假设我们创建了一个动态分配的结构体链表,并且链表中的每个结构体都包含一个 pthread_mutex_t 字段,用来保护对该结构体的访问。
- 互斥锁是分配在栈上的自动变量。
- 我们想用非默认属性来初始化一个静态分配的互斥锁。
当一个自动或动态分配的互斥锁不再需要时,应该使用 pthread_mutex_destroy() 来销毁它。(对于使用 PTHREAD_MUTEX_INITIALIZER 静态初始化的互斥锁,不需要调用 pthread_mutex_destroy()。)
#include<pthread.h>intpthread_mutex_destroy(pthread_mutex_t*mutex);Returns0on success,or a positive error number on error只有当互斥锁是解锁状态,并且之后没有线程会尝试去锁它时,销毁互斥锁才是安全的。如果互斥锁位于动态分配的内存区域中,那么在释放那块内存之前应该先销毁它。自动分配的互斥锁应该在它所在的函数返回之前销毁。
用 pthread_mutex_destroy() 销毁的互斥锁之后可以用 pthread_mutex_init() 重新初始化。
30.1.6 Mutex Attributes
如前所述,pthread_mutex_init() 的 attr 参数可以用来指定一个 pthread_mutexattr_t 对象,该对象定义了互斥锁的属性。可以使用各种 Pthreads 函数来初始化和获取 pthread_mutexattr_t 对象中的属性。我们不会详细介绍互斥锁属性的所有细节,也不会展示用于初始化 pthread_mutexattr_t 对象属性的各种函数原型。不过,我们会描述可以为互斥锁设置的其中一个属性:它的类型。
30.1.7 Mutex Types
在前面的几页中,我们对互斥锁的行为做了一些说明:
- 单个线程不能对同一个互斥锁进行两次加锁。
- 线程不能解锁自己当前没有拥有的互斥锁(也就是说,没有加锁的互斥锁)。
- 线程不能解锁当前没有被锁定的互斥锁。
在每种情况下具体发生什么取决于互斥锁的类型。SUSv3 定义了以下几种互斥锁类型:
PTHREAD_MUTEX_NORMAL
这种类型的互斥锁不提供(自我)死锁检测。如果一个线程尝试锁定已经被自己锁定的互斥锁,就会导致死锁。解锁一个未锁定或者被其他线程锁定的互斥锁会产生未定义结果。(在 Linux 上,这两种操作都会成功。)PTHREAD_MUTEX_ERRORCHECK
对所有操作都进行错误检查。上述三种情况都会导致相关的 Pthreads 函数返回错误。这种类型的互斥锁通常比普通互斥锁慢,但作为调试工具很有用,可以帮助发现应用程序违反互斥锁使用规则的地方。PTHREAD_MUTEX_RECURSIVE
递归互斥锁维持一个锁计数概念。当线程第一次获得互斥锁时,锁计数被设置为 1。同一线程的每一次后续锁定操作会增加锁计数,每一次解锁操作会减少计数。只有当锁计数降到 0 时,互斥锁才会被释放(即其他线程可以获取)。
解锁一个未锁定的互斥锁会失败,解锁一个当前被其他线程锁定的互斥锁同样会失败。
Linux 的线程实现为上述每种互斥锁类型提供了非标准的静态初始化器(例如,PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP),因此在静态分配的互斥锁上不必使用 pthread_mutex_init() 来初始化这些互斥锁类型。不过,可移植的应用程序应该尽量避免使用这些初始化器。
除了上述互斥锁类型外,SUSv3 定义了 PTHREAD_MUTEX_DEFAULT 类型,如果我们在使用 PTHREAD_MUTEX_INITIALIZER 或在调用 pthread_mutex_init() 时将 attr 指定为 NULL 时,它就是默认的互斥锁类型。在本节开头描述的三种场景中,这种互斥锁类型的行为都是刻意未定义的,这样可以为高效实现互斥锁提供最大的灵活性。在 Linux 上,PTHREAD_MUTEX_DEFAULT 互斥锁的行为类似于 PTHREAD_MUTEX_NORMAL 互斥锁。
清单 30-3 中的代码演示了如何设置互斥锁的类型,这里示例是创建一个错误检查互斥锁。
Listing 30-3: Setting the mutex type
代码略。
30.2 Signaling Changes of State: Condition Variables
互斥锁可以防止多个线程同时访问共享变量。条件变量允许一个线程通知其他线程共享变量(或其他共享资源)状态的变化,并且允许其他线程等待(阻塞)这种通知。
一个不使用条件变量的简单例子用来演示为什么它们很有用。假设我们有一些线程产生一些“结果单元”,这些单元会被主线程消费,我们使用一个受互斥锁保护的变量 avail 来表示等待被消费的已产生单元的数量:
staticpthread_mutex_tmtx=PTHREAD_MUTEX_INITIALIZER;staticintavail=0;本节中显示的代码段可以在本书源码分发包中的文件 threads/prod_no_condvar.c 中找到。
在生产者线程中,我们可能会有如下代码:
for(;;){ints=pthread_mutex_lock(&mtx);if(s!=0)errExitEN(s,"pthread_mutex_lock");while(avail>0){/* Consume all available units *//* Do something with produced unit */numConsumed++;avail--;printf("T=%ld: numConsumed=%d\n",(long)(time(NULL)-t),numConsumed);done=numConsumed>=totRequired;}s=pthread_mutex_unlock(&mtx);if(s!=0)errExitEN(s,"pthread_mutex_unlock");if(done)break;/* Perhaps do other work here that does not require mutex lock */}上面的代码可以运行,但它浪费了 CPU 时间,因为主线程不断循环,检查变量 avail 的状态。条件变量可以解决这个问题。它允许一个线程休眠(等待),直到另一个线程通知(发送信号)它必须做某事(也就是说,某种“条件”已经出现,等待的线程现在必须做出反应)。
运行如下,其中T表示相当于程序启动的时间,并非线程号:
$ ./prod_no_condvar432T=1:numConsumed=1T=1:numConsumed=2T=1:numConsumed=3T=2:numConsumed=4T=2:numConsumed=5T=2:numConsumed=6T=3:numConsumed=7T=3:numConsumed=8T=4:numConsumed=9条件变量总是与互斥锁一起使用。互斥锁提供对共享变量的互斥访问,而条件变量用来通知(signal)变量状态的改变。(这里的“signal”一词与第 20 到 22 章中描述的信号无关;它的意思是表示/指示。)
30.2.1 Statically Allocated Condition Variables
像互斥锁一样,条件变量可以静态分配或动态分配。我们会把动态分配的条件变量留到第30.2.5节再讨论,这里先看看静态分配的条件变量。
条件变量的类型是 pthread_cond_t。和互斥锁一样,条件变量在使用前必须先初始化。对于静态分配的条件变量,可以通过将其赋值为 PTHREAD_COND_INITIALIZER 来完成初始化,示例如下:
pthread_cond_tcond=PTHREAD_COND_INITIALIZER;根据 SUSv3,对条件变量的副本执行我们在本节余下部分描述的操作会产生未定义的结果。操作应始终只在使用 PTHREAD_COND_INITIALIZER 静态初始化或使用 pthread_cond_init() 动态初始化(见第 30.2.5 节)的原始条件变量上进行。
主要的条件变量操作是 signal 和 wait。signal 操作是通知一个或多个等待线程共享变量的状态已经发生变化。wait 操作是阻塞线程,直到收到这样的通知。
pthread_cond_signal() 和 pthread_cond_broadcast() 函数都会通知指定的条件变量 cond。pthread_cond_wait() 函数会阻塞线程,直到条件变量 cond 被 signal。
30.2.2 Signaling and Waiting on Condition Variables
主要的条件变量操作是 signal 和 wait。signal 操作是通知一个或多个等待线程共享变量的状态已经发生变化。wait 操作是阻塞线程,直到收到这样的通知。
pthread_cond_signal() 和 pthread_cond_broadcast() 函数都会通知指定的条件变量 cond。pthread_cond_wait() 函数会阻塞线程,直到条件变量 cond 被 signal。
#include<pthread.h>intpthread_cond_signal(pthread_cond_t*cond);intpthread_cond_broadcast(pthread_cond_t*cond);intpthread_cond_wait(pthread_cond_t*cond,pthread_mutex_t*mutex);Allreturn0on success,or a positive error number on errorpthread_cond_signal() 和 pthread_cond_broadcast() 的区别在于,如果有多个线程被 pthread_cond_wait() 阻塞,会发生什么。使用 pthread_cond_signal() 时,我们只保证至少有一个被阻塞的线程会被唤醒;而使用 pthread_cond_broadcast() 时,所有被阻塞的线程都会被唤醒。
使用 pthread_cond_broadcast() 总是能得到正确的结果(因为所有线程都应该被编程成能够处理多余和伪唤醒),但是 pthread_cond_signal() 可能更高效。不过,pthread_cond_signal() 只应该在只需要唤醒一个等待的线程来处理共享变量状态变化时使用,并且唤醒哪一个线程无关紧要。这种情况通常适用于所有等待的线程都被设计来执行完全相同的任务。基于这些假设,pthread_cond_signal() 可以比 pthread_cond_broadcast() 更高效,因为它避免了如下可能性:
- 所有等待的线程都被唤醒了。
- 一个线程先被调度执行。这个线程在相关互斥锁的保护下检查共享变量的状态,发现还有工作要做。线程完成必要的工作,改变共享变量的状态以表示工作已完成,然后解锁相关的互斥锁。
- 剩下的每个线程依次锁住互斥锁并检查共享变量的状态。然而,由于第一个线程已经做了修改,这些线程发现没有工作要做,于是解锁互斥锁并回去休眠(也就是再次调用 pthread_cond_wait())。
相比之下,pthread_cond_broadcast() 处理的是等待线程被设计为执行不同任务的情况(在这种情况下,它们可能有与条件变量相关的不同谓词)。
条件变量本身不存储任何状态信息。它只是一个用于传递应用程序状态信息的机制。如果在条件变量被发信号时没有线程在等待,那么信号就会丢失。之后等待该条件变量的线程只有在变量再次被发信号时才会被唤醒。
pthread_cond_timedwait() 函数和 pthread_cond_wait() 一样,只不过它的 abstime 参数指定了线程在等待条件变量发信号时最多可以休眠的时间。
#include<pthread.h>intpthread_cond_timedwait(pthread_cond_t*cond,pthread_mutex_t*mutex,conststructtimespec*abstime);Returns0on success,or a positive error number on errorabstime 参数是一个 timespec 结构(第 23.4.2 节),指定了一个绝对时间,以自纪元(第 10.1 节)起的秒和纳秒表示。如果 abstime 指定的时间间隔到期而条件变量没有被唤醒,那么 pthread_cond_timedwait() 会返回错误 ETIMEDOUT。
Using a condition variable in the producer-consumer example
此略。详见随书代码 threads/prod_condvar.c 。并注意与prod_no_condvar.c的差异。
在考虑消费者的代码之前,我们需要更详细地解释一下 pthread_cond_wait()。我们之前提到过,条件变量总是有一个关联的互斥锁。这两个对象都会作为参数传递给 pthread_cond_wait(),它会执行以下步骤:
- 解锁 mutex 指定的互斥锁;
- 阻塞调用线程,直到另一个线程发出条件变量 cond 的信号;
- 重新加锁 mutex。
pthread_cond_wait() 函数之所以设计成执行这些步骤,是因为我们通常以如下方式访问共享变量:
s=pthread_mutex_lock(&mtx);if(s!=0)errExitEN(s,"pthread_mutex_lock");while(/* Check that shared variable is not in state we want */)pthread_cond_wait(&cond,&mtx);/* Now shared variable is in desired state; do some work */s=pthread_mutex_unlock(&mtx);if(s!=0)errExitEN(s,"pthread_mutex_unlock");(我们将在下一节解释为什么 pthread_cond_wait() 调用被放在 while 循环中而不是 if 语句中。)
在上面的代码中,对共享变量的访问都必须用互斥锁保护,原因如前所述。换句话说,互斥锁和条件变量之间有一种自然的关联:
- 线程在准备检查共享变量状态时会先锁住互斥锁。
- 检查共享变量的状态。
- 如果共享变量不在期望的状态,线程必须先解锁互斥锁(这样其他线程才能访问共享变量),然后才会在条件变量上进入休眠。
- 当线程因为条件变量被触发而重新唤醒时,必须再次锁住互斥锁,因为通常线程会立刻去访问共享变量。
pthread_cond_wait() 函数会自动完成这几个步骤中最后两个所需的互斥锁解锁和加锁。在第三步中,释放互斥锁和在条件变量上阻塞是原子操作。换句话说,在调用 pthread_cond_wait() 的线程在条件变量上阻塞之前,其他线程不可能先获取互斥锁并发送条件信号。
有一个相关的结论是:条件变量和互斥量之间有天然的关系——所有同时等待某个条件变量的线程在调用 pthread_cond_wait()(或 pthread_cond_timedwait())时,都必须指定同一个互斥量。实际上,pthread_cond_wait() 调用在执行期间会动态地将条件变量绑定到唯一的互斥量。SUSv3 指出,如果在同一个条件变量上对并发的 pthread_cond_wait() 调用使用多个互斥量,其结果是未定义的。
我们最后来谈一下关于使用 pthread_cond_signal()(以及 pthread_cond_broadcast())的一个观察。在前面展示的生产者代码中,我们先调用了 pthread_mutex_unlock(),然后再调用 pthread_cond_signal();也就是说,我们先释放了与共享变量关联的互斥锁,然后再通知对应的条件变量。我们也可以把这两个步骤颠倒过来;SUSv3 允许它们按任意顺序执行。
[Butenhof, 1996] 指出,在某些实现中,先解锁互斥锁再发出条件变量信号,可能比按相反顺序执行性能更好。如果在发出条件变量信号后才解锁互斥锁,那么正在执行 pthread_cond_wait() 的线程可能会在互斥锁仍然被锁住的情况下被唤醒,然后发现锁被占用又立即回去睡觉,这会造成两次多余的上下文切换。一些实现通过使用所谓的“等待变形(wait morphing)”技术来解决这个问题,该技术会在互斥锁被锁住时将被唤醒的线程从条件变量等待队列直接移动到互斥锁等待队列,而不用进行上下文切换。
💡 其实我有一个疑问,为何pthread_mutex_lock后面还要加上pthread_cond_wait? pthread_mutex_lock成功不久表示有任务可做了吗?
为了解除这个疑问,我修改了代码,增加了一行,来看pthread_cond_wait是否有机会被执行:
for(;;){ints=pthread_mutex_lock(&mtx);if(s!=0)errExitEN(s,"pthread_mutex_lock");while(avail==0){/* Wait for something to consume */// 我增加了下面这行printf("T=%ld: pthread_cond_wait is executed!\n",(long)(time(NULL)-t));s=pthread_cond_wait(&cond,&mtx);if(s!=0)errExitEN(s,"pthread_cond_wait");}运行如下,pthread_cond_wait确实被执行了:
$ ./prod_condvar234T=0: pthread_cond_wait is executed!T=1:numConsumed=1T=1:numConsumed=2T=1: pthread_cond_wait is executed!T=1:numConsumed=3T=1: pthread_cond_wait is executed!T=2:numConsumed=4T=2:numConsumed=5T=2: pthread_cond_wait is executed!T=2:numConsumed=6T=2: pthread_cond_wait is executed!T=3:numConsumed=7T=3:numConsumed=8T=3: pthread_cond_wait is executed!T=4:numConsumed=930.2.3 Testing a Condition Variable’s Predicate
每个条件变量都有一个与之相关的谓词,涉及一个或多个共享变量。例如,在前一节的代码片段中,与 cond 相关的谓词是 (avail == 0)。这个代码片段展示了一个通用的设计原则:pthread_cond_wait() 调用必须由 while 循环控制,而不是 if 语句。这是因为,从 pthread_cond_wait() 返回时,无法保证谓词的状态;因此,如果谓词不在期望状态,我们应该立即重新检查谓词并继续等待。
从 pthread_cond_wait() 返回时,我们不能对谓词的状态做任何假设,原因如下:
- 其他线程可能会先被唤醒。也许有几个线程在等待获取与条件变量相关的互斥锁。即使发出信号的线程已经将条件谓词设置为期望的状态,仍有可能其他线程先获取互斥锁并改变相关共享变量的状态,从而改变谓词的状态。
- 为“宽松”谓词进行设计可能更简单。有时候,基于条件变量设计应用程序会更容易,特别是当条件变量表示可能性而非确定性的时候。换句话说,给条件变量发信号意味着“可能有事情”需要被唤醒的线程去处理,而不是“肯定有事情”。使用这种方法,条件变量可以根据谓词状态的近似值发送信号,被唤醒的线程可以通过重新检查谓词来确认是否确实有事情需要做。
- 可能会出现虚假唤醒。在某些实现中,一个线程在等待条件变量时,即便没有其他线程实际发出信号,也可能被唤醒。这类虚假唤醒是某些多处理器系统为了高效实现所需技术的(罕见)副作用,并且被 SUSv3 明确允许。
💡 其实也没有完全理解,当作固定套路照做就好
30.2.4 Example Program: Joining Any Terminated Thread
我们之前提到过,pthread_join() 只能用来等待特定的线程结束。它没有机制去等待任何已经终止的线程。现在我们展示如何使用条件变量来绕过这个限制。
列表 30-4 中的程序为它的每个命令行参数创建了一个线程。每个线程会休眠与对应命令行参数指定的秒数相同的时间,然后结束。休眠时间是我们模拟线程执行一段时间工作的方式。
该程序维护了一组全局变量,用来记录所有已创建线程的信息。对于每个线程,全局线程数组中的一个元素记录该线程的 ID(tid 字段)以及它的当前状态(state 字段)。状态字段有以下几种值:TS_ALIVE,表示线程仍在运行;TS_TERMINATED,表示线程已结束但尚未被 join;或者 TS_JOINED,表示线程已结束并且被 join 了。
每当一个线程结束时,它会将其在线程数组中的元素的 state 字段设置为 TS_TERMINATED,同时增加一个全局计数器 numUnjoined(记录已终止但尚未 join 的线程数量),并唤醒条件变量 threadDied。
主线程通过一个循环不断等待条件变量 threadDied。一旦 threadDied 被唤醒,并且有已终止但尚未 join 的线程,主线程就会扫描线程数组,寻找 state 字段为 TS_TERMINATED 的元素。对于每个处于该状态的线程,使用线程数组中的对应 tid 字段调用 pthread_join(),然后将其状态设置为 TS_JOINED。当主线程创建的所有线程都结束,即全局变量 numLive 为 0 时,主循环终止。
下面的 shell 会话日志演示了程序在清单 30-4 中的使用:
$ ./thread_multijoin21313Thread1terminating Reaped thread1(numLive=4)Thread3terminating Reaped thread3(numLive=3)Thread0terminating Reaped thread0(numLive=2)Thread4terminating Reaped thread4(numLive=1)Thread2terminating Reaped thread2(numLive=0)最后,请注意,虽然示例程序中的线程是以可连接(joinable)方式创建的,并且在线程终止时立即通过 pthread_join() 回收,但我们不一定需要这种方式来了解线程的终止情况。我们也可以将线程设为分离(detached)状态,不使用 pthread_join(),然后仅使用线程数组(以及相关的全局变量)来记录每个线程的终止。
Listing 30-4: A main thread that can join with any terminated thread
// threads/thread_multijoin.c// 代码略。30.2.5 Dynamically Allocated Condition Variables
pthread_cond_init() 函数用于动态初始化条件变量。需要使用 pthread_cond_init() 的情况类似于需要 pthread_mutex_init() 动态初始化互斥锁的情况(见第 30.1.5 节);也就是说,我们必须使用 pthread_cond_init() 来初始化自动和动态分配的条件变量,以及使用非默认属性来初始化静态分配的条件变量。
#include<pthread.h>intpthread_cond_init(pthread_cond_t*cond,constpthread_condattr_t*attr);Returns0on success,or a positive error number on errorSUSv3 指出,初始化一个已经初始化的条件变量会导致未定义行为;我们不应该这样做。
当一个自动分配或动态分配的条件变量不再需要时,应该使用 pthread_cond_destroy() 来销毁它。对于使用 PTHREAD_COND_INITIALIZER 静态初始化的条件变量,则不需要调用 pthread_cond_destroy()。
#include<pthread.h>intpthread_cond_destroy(pthread_cond_t*cond);Returns0on success,or a positive error number on error只有当没有线程在等待它时,销毁条件变量才是安全的。如果条件变量位于动态分配的内存区域,那么在释放该内存区域之前应该先销毁它。自动分配的条件变量应该在它所在的函数返回之前销毁。
用 pthread_cond_destroy() 销毁的条件变量之后可以通过 pthread_cond_init() 重新初始化。
30.3 Summary
线程提供的更大共享是有代价的。多线程应用程序必须使用同步原语,比如互斥锁和条件变量,以协调对共享变量的访问。互斥锁提供对共享变量的独占访问。条件变量允许一个或多个线程等待通知,以得知其他线程已经改变了共享变量的状态。
Further information
请参考第29.10节列出的更多信息来源。