条件变量(Condition Variables)

1、是什么

条件变量是并发编程中的一种同步机制。条件变量使得线程能够阻塞到等待某个条件发生后,再继续执行。条件变量能够实现强大并且高效的同步机制,但是要用好条件变量,也需要我们做出不少努力。

2、为什么需要条件变量

设想一下这样子的场景:在生产者消费者模型中,我们希望在生产者制造出 100 个产品后,庆祝一下。
如果我们直接用 mutex 互斥锁来实现的话,那么我们需要在某个线程上不断地轮询:现在是不是做出 100 个产品了?伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Thread Producer
produce () {
mutex_lock()
count++
mutex_unlock()
}
// Thread A
celebrateAfter100 () {
while (1) {
mutex_lock()
if (count >= 100) {
mutex_unlock()
break
}
mutex_unlock()
sleep(100)
}
// Celebrate!
}

相当于我们要进行大量无效的询问,才能知道条件已经满足,并且每次询问都是需要加锁的,这无疑是一种资源的浪费。

而条件变量则高效地解决了这个问题。使用条件变量的情况下,我们可以直接等待某个条件的发生,而不需要主动轮询。有了条件变量,上述伪代码就可以很方便地改写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Thread Producer
produce () {
mutex_lock()
count++
mutex_unlock()
if (count >= 100)
cond_signal(condition) // 条件满足啦,通知一个等待的线程
}
// Thread A
celebrateAfter100 () {
mutex_lock()
while(count < 100)
cond_wait(condition) // 等到条件满足再继续执行
mutex_unlock()
// Celebrate!
}

现在相当于是在条件满足的时候,由生产者通知 Thread A,而不是让 Thread A 傻傻地去不断轮询,变得高效了很多。

3、如何正确使用条件变量

来看一个简单的栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <pthread.h>
static int value = 0;
static pthread_mutex_t mutex;
static pthread_cond_t condition;
void setup()
{
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&condition, NULL);
}
void destroy()
{
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&condition);
}
void waitCondition()
{
pthread_mutex_lock(&mutex);
while (value == 0) {
pthread_cond_wait(&condition, &mutex); // 开始等待,并立即解锁 mutex
}
pthread_mutex_unlock(&mutex);
}
void triggerCondition()
{
pthread_mutex_lock(&mutex);
value = 1;
pthread_mutex_unlock(&mutex);
pthread_cond_broadcast(&condition); // 广播
}

3.1、创建和销毁条件变量

首先,条件变量在使用前必须初始化,pthread_cond_init 和 pthread_cond_destroy 方法可以用来动态创建和销毁条件变量。

同时,条件变量和互斥锁一样,也有静态创建方式,静态方式使用 PTHREAD_COND_INITIALIZER 常量,如下:

1
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;

此外,因为条件变量必须配合互斥锁使用,所以也要创建一个互斥锁。

3.2、与互斥锁配合使用

为了防止发生竞争条件,条件变量必须与互斥锁搭配使用。pthread_cond_wait 函数的调用临界区 都需要受到互斥锁的保护。

3.3、等待条件的发生

当条件不满足时,使用 pthread_cond_wait 或者 pthread_cond_timedwait 函数,来让线程进入休眠。当函数正常返回时,返回值为 0。

这两个函数的区别在于,pthread_cond_timedwait 函数提供了超时返回的能力,我们可以设定一个超时时间,来避免永久的等待。当到达超时时间后,条件变量仍未满足的话,函数会返回 ETIMEOUT。其中 abstime 以绝对时间的形式出现,0 表示格林尼治时间1970年1月1日0时0分0秒,这里常常有人误解为相对时间。

这两个 wait 函数的调用,都要在获取 mutex 锁后进行。

看到这里可能有的人会觉得疑惑:如果在 wait 之前锁住了 mutex,那其他线程在试图进入临界区时(上文 value = 1 的那行代码),不就永远获取不到 mutex 了吗?

这确实是让许多初学者觉得困惑的地方。其实函数 pthread_cond_wait 会在线程即将休眠之前,释放 mutex。因此,在线程休眠之后,其他线程就能正常锁住 mutex 了。

而后,等到其他线程触发了条件,并且 unlock 了 mutex 之后,休眠的线程在 wait 函数中会再次锁住 mutex,然后继续执行代码。

1
2
3
4
5
6
7
8
pthread_mutex_lock(&mutex);
while (value == 0) {
/* 解锁 mutex,线程开始休眠,等待条件变量触发...
* 等到条件变量被触发,线程被唤醒,
* 在 pthread_cond_wait 返回之前,会再次锁住 mutex */
pthread_cond_wait(&condition, &mutex);
}
pthread_mutex_unlock(&mutex);

3.4、条件触发

其他线程可以在条件满足后,通过调用 pthread_cond_signal 或者 pthread_cond_broadcast 来触发条件变量。

1
2
3
4
5
6
7
8
9
void triggerCondition()
{
pthread_mutex_lock(&mutex);
value = 1;
pthread_mutex_unlock(&mutex);
pthread_cond_broadcast(&condition); // 唤醒所有等待中的线程,不需要加锁
}

pthread_cond_signal 函数可以唤醒一个处于等待中的线程,当有多个线程等待时,它会自动根据线程的优先级选择一个线程唤醒。但是某些特殊情况下,该函数可能会唤醒不止一个线程。

pthread_cond_broadcast 函数则是用“广播”的方式唤醒所有等待中的线程。例如读写锁的实现中,在写入完毕后,可以用它来唤醒所有等待中的读取操作。

值得注意的是,无论是 pthread_cond_signal 还是 pthread_cond_broadcast 都不保证唤醒的正确性。也就是说,休眠中的线程有可能在被唤醒后,发现条件依旧不满足。这是由于在函数的实现中,为了追求高性能,而放弃了一定的准确性。这通常被称为“虚假唤醒”。

此外,pthread_cond_signalpthread_cond_broadcast 函数都不需要在 mutex 锁中调用。

4、注意

尽管条件变量的使用是较为简单的,但是其中也有不少的“坑”需要大家注意。下面介绍几个比较值得注意的问题。

4.1、要考虑解锁和唤醒的顺序

由于 pthread_cond_signalpthread_cond_broadcast 函数的调用都不需要加锁,所以它们放到 pthread_mutex_unlock 之前或者之后执行都是可以的。但在实际使用中,需要根据具体情况考虑它们的顺序,来使得程序高效运行。

当 signal 操作发生在 unlock 之前时,其他等待的线程被唤醒,但 mutex 锁可能仍然被 signal 的线程持有着,导致被唤醒的线程无法获取到 mutex 锁,从而再次进入休眠。通常情况下,这种调用顺序就会对代码的执行效率产生不良的影响。但是在 Java 下,必须采用这种顺序进行调用,否则会发生异常。

4.2、要使用 while 而不是 if,避免虚假唤醒

细心观察可以发现,我们在等待的线程中,使用的是 while (条件不成立) 的方式来调用 wait 函数,而不是使用 if 语句。

这是由于 wait 函数被唤醒时,存在虚假唤醒等情况,导致唤醒后发现,条件依旧不成立。因此需要使用 while 语句来循环地进行等待,直到条件成立为止。

4.3、timewait 是 absolute time

pthread_cond_timedwait 函数的 abstime 指的是超时的绝对时间,而不是相对现在的时间间隔。这点经常会有人误会。

4.4、pthread_cond_timedwait 不一定会准时返回

如果 pthread_cond_timedwait 超时到了,但是这个时候 mutex 锁被其他线程持有,导致本线程不能锁定 mutex,无法进入临界区,那么 pthread_cond_timedwait 就无法立即返回。

5、NSCondition

NSCondition 是 Objective-C 中对条件变量的封装,它的底层也是基于上文所述的 POSIX 的条件变量。用法也和上文的结构相似。
它的独特之处在于,它同时封装了一个互斥锁和一个条件变量,所有的加锁和条件的操作都可以直接通过 NSCondition 对象完成。
官方示例如下:

等待条件:

1
2
3
4
5
6
7
8
9
[cocoaCondition lock];
while (timeToDoWork <= 0)
[cocoaCondition wait];
timeToDoWork--;
// Do real work here.
[cocoaCondition unlock];

发送信号:

1
2
3
4
[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];

6、参考文献