文章目录
一、为什么需要条件变量
条件变量是实现线程同步的一种重要机制,为了更好的理解条件变量,我们首先需要弄清楚,什么是线程同步。
通常在并发编程中有这两大需求,一个是互斥、一个是同步。互斥是为了解决竞争的问题,相信阅读到这篇文章的你,一定已经理解并掌握了如何使用 Linux 下的互斥量来保证线程对临界资源的安全读写,所以在本篇文章中也不在赘述。
而同步是两个或多个线程的协作行为,让多个线程能够按照某种特定的顺序访问临界资源。如何理解呢?以常见的饥饿问题为例,我们举一个夸张点的生活的例子来帮助大家理解。
食堂很小,又只有一个打菜窗口,因此为了避免拥挤,学校规定一次只允许一个人进入食堂打菜,其他人就在食堂门口等着。眼看着一个人打好了菜,一群人就蜂拥着挤进食堂。你本来就比较瘦小了,抢菜又抢不过别人,于是一直吃不上饭的你就被“饿死”了。
在计算机中,互斥就有可能会造成这样的饥饿问题,即一个执行流长期得不到资源。互斥的规则是没有错的,但是在这种情况下显然是不合理的。但如果我们能够让线程在食堂门口等待的时候,排好队,按照次序来,就能够避免弱小线程得不到资源的问题。基于这种想法,线程同步机制就被设计出来了。
但请注意同步和互斥不是对立的关系,而是相互补充的关系。
二、什么是条件变量
明白了什么是同步,再回过来看看什么是条件变量?所谓的条件变量本质上就是一个能将线程阻塞的变量,因为它通常与 if while 等条件判断语句一起使用,即在特定的条件下才将线程阻塞,因此才被称为条件变量。
条件变量的唤醒时机由用户决定,当用户调用系统接口唤醒条件变量时,操作系统会选择一个阻塞在该条件变量的线程唤醒。操作系统有自己的调度策略,从而确保各个线程可以被均匀的调度,从而解决了饥饿的问题。
明白了条件变量的基本原理,那么我们来举一个具体例子来说明如何使用条件变量实现线程同步。就以大家最熟悉的生产者消费者模型为例说明吧:
- 对于生产者来说,需要的是空间资源。因此当仓库满的时候,将生产者阻塞;
- 对于消费者来说,依赖的是生产者所生产的资料。因此当仓库为空时,将消费者阻塞
- 每当生产者生产出一个资源后,就可以唤醒一个消费者来消费
- 每当消费者消费一个资源后,就腾出了一个仓库空间,可以唤醒一个生产者来生产
因此我们可以看到,为了实现生产者线程和消费者线程的同步,我们只需要实现「阻塞」与「唤醒」,而这也恰好是条件变量的核心功能。因此我们可以暂且写下这样的伪代码。
现在再看同步想必大家就没有这么生涩了,其实同步就对应着一种依赖关系,当我们按照这种特定的依赖关系来协调多个线程的执行时,系统就可以井然有序的执行。
那我们再回过头来想想,如果没有条件变量,那么上面的生产者消费者模型是如何实现的呢?比如消费者要想得知仓库中是否有数据,就需要疯狂的轮训缓冲区,而轮训检查是非常浪费 CPU 资源的,其性能之差自然是不难理解的。而有了条件变量之后,缓冲区中什么时候有数据会自动通知消费者,本质上来说,这就是一种事件通知机制。
三、条件变量系统接口
经过上面的铺垫后,我们下面正式介绍 Linux 下条件变量对应的系统接口:
1. 初始化:
// 方法一:使用宏定义初始化
pthread_cond_t cond1 = PTHREAD_COND_INITIALIZER;
// 方法二:使用 pthread_cond_init 初始化
pthread_cond_t cond2;
pthread_cond_init(&cond, NULL);
2. 阻塞:
【作用】:让调用该函数的线程阻塞在条件变量cond上
【注意】:条件变量必须和锁配合使用 (为什么?后面解释)
3. 唤醒:
【作用】:让阻塞在条件变量处的线程取消阻塞
-
signal:解除一个线程的阻塞
-
broadcast:解除所有线程的阻塞
四、条件变量的使用
条件变量通常是与条件判断语句,例如 if where 等配合使用的,正如我们上面所提到的,消费者只在缓冲区为空时阻塞,而生产者只在缓冲区为满时阻塞,即我们是在特定的条件下阻塞。
如果无条件阻塞会怎么样,例如下面这个例子:
// 控制消费者的阻塞与唤醒
pthread_cond_t cond_c = PTHREAD_COND_INITIALIZER;
// 控制生产者的阻塞与唤醒
pthread_cond_t cond_p = PTHREAD_COND_INITIALIZER;
// 用同一把锁保证对缓冲区的互斥访问
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
vector<int> buff;
void consume() {
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond_c , &mutex);
// 消费略...
pthread_cond_signal(&cond_p );
pthread_mutex_unlock(&mutex);
}
void produce() {
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond_p , &mutex);
// 生产略...
pthread_cond_signal(&cond_c);
pthread_mutex_unlock(&mutex);
}
-
从功能层面上讲,我们每次消费前都要将消费者阻塞这合理吗?如果此时队列中是有数据的,那么我们并不需要阻塞消费者,直接消费即可。
-
从代码层面讲,上面的代码存在死锁的风险。如果生产者在消费者之前执行,完成生产之后通知生产者来消费。但是此时消费者线程如果还没有执行到wait语句,即线程没有等待此条件变量上,那通知的信号就丢失了,直到后面 consume() 中执行 wait 才会将消费者挂起。
但此时由于缓冲区满了,生产者不会生产,自然不会再唤醒消费者;消费者由于没有被唤醒,一直不消费,由此陷入死循环。
所以,合理的做法是,只有在队列不为空的情况下,才需要阻塞。由此也不难看出,条件变量是离不开条件判断的:
vector<int> buff;
void consume() {
pthread_mutex_lock(&mutex);
if(buff.empty())
pthread_cond_wait(&cond_c , &mutex);
// 消费略...
pthread_cond_signal(&cond_p );
pthread_mutex_unlock(&mutex);
}
void produce() {
pthread_mutex_lock(&mutex);
if(buff.size() >= max_num)
pthread_cond_wait(&cond_p , &mutex);
// 生产略...
pthread_cond_signal(&cond_c);
pthread_mutex_unlock(&mutex);
}
但是使用 if 存在一个虚假唤醒的问题,即线程可能因为其他的原因被操作系统错误的唤醒,但是此时条件还并不满足。为了解决这个问题,我们需要循环判断:
vector<int> buff;
void consume() {
pthread_mutex_lock(&mutex);
while(buff.empty())
pthread_cond_wait(&cond_c , &mutex);
// 消费略...
pthread_cond_signal(&cond_p);
pthread_mutex_unlock(&mutex);
}
void produce() {
pthread_mutex_lock(&mutex);
while(buff.size() >= max_num)
pthread_cond_wait(&cond_p , &mutex);
// 生产略...
pthread_cond_signal(&cond_c);
pthread_mutex_unlock(&mutex);
}
我们最终的代码如上所示,不知道你会不会有这样的疑惑,消费者线程被阻塞的时候,线程上下文发生切换,那么此时消费者线程会带着互斥量 mutex 阻塞吗?显然不会,否则生产者会因为获取不到 mutex 而阻塞,从而又落入了一个死循环。
⭐️实际上,当 pthread_cond_wait()
将线程阻塞时,会将 mutex 释放,供其他线程申请。而当 pthread_cond_wait()
返回时(即阻塞结束),它会确保当前线程获取到互斥量再返回,继续执行下面的代码时也是在互斥量的保护下进行的。
诶,这时候我们突发奇想,如果说 wait 提供的功能只是阻塞线程的功能,释放锁申请锁需要我们自己控制,会有什么问题?如下面这个例子
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
我们可以看到,释放锁之后阻塞线程之前存在着一段不持有互斥量的“空窗期”,因此在这个阶段锁可能就被另一方拿走了并通过 signal 通知 cond。但由于此时我们的线程并没有阻塞在 cond 上,就导致这样的一个通知丢失了
而操作系统在使用 pthread_cond_wait
释放锁和阻塞线程之间是原子的。
如果另一个线程在即将阻塞的线程释放了互斥锁之后成功获取了该互斥锁,那么在这个线程中随后调用的 pthread_cond_broadcast 或 pthread_cond_signal 将确保它们的行为就好像是在即将阻塞的线程已经完全阻塞之后才发出的,所以对阻塞的线程来说唤醒通知不会丢失
man 手册的具体描述如下:
五、条件变量为什么要配合 mutex 使用
总的来说,有三个角度可以考虑:
- 保护内部条件(如condition内部资源)
- 保护外部条件(如外部共享资源操作)
- 防止信号丢失(signal 先于 wait)
1.保护内部条件
条件变量不会无缘无故被唤醒,在内核代码中一定会对应着某些共享数据的变化。所以一定要使用互斥锁来安全的获取和修改共享数据。
这个说法是没什么问题,但是令人迷惑的地方在于,一个好的设计,是需要在内部隐藏细节的,为什么需要外部传入一个mutex来保护内部的实现细节呢。
但是看具体pthread的实现,这种说法有对的情况。因为pthread在不同OS有不同的实现,有的实现内部的确借用 mutex 来保护了共享资源
2.保护外部条件
考虑这样一个简单的条件变量使用案例,条件通过是一个全局变量可以被多个线程修改,因此对于临时区资源的读写访问,肯定是需要加锁保护的
pthread_mutex_lock(&mtx);
while () { //访问一个外部条件
pthread_cond_wait(&cond, &mtx);
// 修改外部条件
pthread_cond_signal(&cond);
}
pthread_mutex_unlock(&mtx);
3.防止信号丢失
考虑这样的代码执行顺序,思考会发生什么问题?
在上面这段代码中,因为没有给 Thread B 加锁,从而导致 Thread A 刚进行完条件判断还没有阻塞时,条件就触发了,这样就丢失了一次唤醒的机会。
问题的核心是没有保证阻塞和条件判断的原子性
所以两个线程都要加锁。只加一个锁和没加锁没有什么区别
// 等待条件的代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
# 修改条件
pthread_mutex_unlock(&mutex);
// 给条件发送信号代码
pthread_mutex_lock(&mutex);
# 设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
// 这样也可以
pthread_mutex_lock(&mutex);
# 设置条件为真
pthread_mutex_unlock(&mutex);
pthread_cond_signal(cond);
六、实战案例:三个线程交替打印 ABC
现在要求使用互斥量和条件变量让三个线程交替打印 ABC。
简单来说,使用条件变量只需要注意一点:在条件判断和 wait 之前,是始终要持有互斥量的,记住这一点再去编程就不会犯错。具体原因见上面的解释。
对于这份代码我先后的版本是这样的,大家兴许也可以从我的失败中获取经验。
最开始我写的 start_routine()
是这样的
void* start_routine(void* arg) {
struct data* d = (struct data*)arg;
for (int i = 0; i < 9; ++i) {
pthread_mutex_lock(&mutex);
while (flag != d->key) // 避免虚假唤醒
pthread_cond_wait(&cond, &mutex);
printf("%s: %c\n", d->id, d->msg);
flag = (flag + 1) % 3;
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&mutex);
}
return (void*)(0);
}
int main() {
pthread_t pid1;
pthread_t pid2;
pthread_t pid3;
struct data data1 = {"thread1", 0, 'A'};
struct data data2 = {"thread2", 1, 'B'};
struct data data3 = {"thread3", 2, 'C'};
pthread_create(&pid1, NULL, start_routine, &data1);
pthread_create(&pid2, NULL, start_routine, &data2);
pthread_create(&pid3, NULL, start_routine, &data3);
pthread_join(pid1, NULL);
pthread_join(pid2, NULL);
pthread_join(pid3, NULL);
return 0;
}
虽然没有问题,但是有很多没有必要的操作。我们在最后并不需要自己释放 mutex,等到循环继续的时候,线程执行到 wait 函数处条件不满足自然会将 mutex 释放。
所以我们可以这样写:
void* start_routine(void* arg) {
struct data* d = (struct data*)arg;
pthread_mutex_lock(&mutex);
for (int i = 0; i < 9; ++i) {
while (flag != d->key) // 避免虚假唤醒
pthread_cond_wait(&cond, &mutex);
printf("%s: %c\n", d->id, d->msg);
flag = (flag + 1) % 3;
pthread_cond_broadcast(&cond);
}
return (void*)(0);
}
仔细想想,是不是能保证条件判断和 wait 之间是始终持有锁呢?是的。
但是执行后发现程序发生死锁了!?仔细看看,原来是左后循环结束的时候,没有 wait 就不会自动将锁释放,所以最终可以改成这样:
void* start_routine(void* arg) {
struct data* d = (struct data*)arg;
pthread_mutex_lock(&mutex);
for (int i = 0; i < 2; ++i) {
while (flag != d->key) // 避免虚假唤醒
pthread_cond_wait(&cond, &mutex);
printf("%s: %c\n", d->id, d->msg);
flag = (flag + 1) % 3;
pthread_cond_broadcast(&cond);
}
pthread_mutex_unlock(&mutex);
return (void*)(0);
}
使用 C++ 的 unique_ptr 自动管理 mutex 的话,我们就不用担心忘记释放锁了
参考文章: