一. 概述
条件变量(condition variable)是利用共享的变量进行线程之间同步的一种机制。典型的场景包括生产者-消费者模型,线程池实现等。
与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。
条件变量使我们可以睡眠等待某种条件出现。条件变量是利用线程间共享的全局变量进行同步的一种机制,由两个部分组成,wait端:一个线程等待"条件变量的条件成立"而挂起;signal/broadcast端:另一个线程使"条件成立"(发出条件成立信号)。
条件变量通常和互斥锁一起使用。条件变量之所以要和互斥锁一起使用,主要是因为互斥量是防止多线程同时访问共享的互斥变量来保护临界区,只有两种状态:锁定和非锁定;而条件变量可以通过允许线程阻塞和等待另一个线程发送信号来弥补互斥锁的不足,所以互斥锁和条件变量通常一起使用。
为什么要与pthread_mutex 一起使用呢? 这是为了应对 线程1在调用pthread_cond_wait()但线程1还没有进入wait cond的状态的时候,此时线程2调用了 cond_singal 的情况。 如果不用mutex锁的话,这个cond_singal就丢失了。加了锁的情况是,线程2必须等到 mutex 被释放(也就是 pthread_cod_wait() 释放锁并进入wait_cond状态 ,此时线程2上锁) 的时候才能调用cond_singal。
假如线程A加锁进入临界区,这时发现条件不满足,就不能执行某一操作,然后解锁,这样线程A就不再有机会执行这一操作;而条件变量可以允许线程阻塞等待。因此线程A先加锁,当条件不满足时调用 pthread_cond_wait(cond, mutex),这样线程A被挂起,等待其它线程改变条件,当其他线程改变了条件,并发信号给关联的条件变量,唤醒一个或多个等待它的线程,这时线程A被唤醒,重新加锁,重新判断条件,如条件满足则继续向下执行。
条件变量只有一种正确使用的方式,几乎不可能用错,对于wait端:
1、必须与mutex一起使用,该布尔表达式的读写需受此mutex保护
2、在mutex已上锁的时候才能调用wait()
3、把判断布尔条件和wait()放到while循环中
对于signal/broadcast端
1、不一定要在mutext已上锁的情况下调用signal(理论上)
2、在signal之前一般要修改布尔表达式
3、修改布尔表达式通常要用mutex保护(至少用作full memory barrier)
4、注意区分signal与broadcast:broadcast通常用于表明状态变化,signal通常用于表示资源可用
它的使用方式如下图所示:
二.条件变量的使用,有几个地方要弄明白
1、解锁
2、等待 当收到一个解除等待的信号(pthread_cond_signal或者pthread_cond_broad_cast)之后,pthread_cond_wait马 上需要做的动作是:
3、加锁
(1) 1号线程从队列中获取了一个元素,此时队列变为空。
(2) 2号线程也想从队列中获取一个元素,但此时队列为空,2号线程便只能进入阻塞(cond.wait()),等待队列非空。
(3) 这时,3号线程将一个元素入队,并调用cond.notify()唤醒条件变量。
(4) 处于等待状态的2号线程接收到3号线程的唤醒信号,便准备解除阻塞状态,执行接下来的任务(获取队列中的元素)。
(5) 然而可能出现这样的情况:当2号线程准备获得队列的锁,去获取队列中的元素时,此时1号线程刚好执行完之前的元素操作,返回再去请求队列中的元素,1号线程便获得队列的锁,检查到队列非空,就获取到了3号线程刚刚入队的元素,然后释放队列锁。
(6) 等到2号线程获得队列锁,判断发现队列仍为空,1号线程“偷走了”这个元素,所以对于2号线程而言,这次唤醒就是“虚假”的,它需要再次等待队列非空。
虚假唤醒在linux的多处理器系统中/在程序接收到信号时可能回发生。在Windows系统和JAVA虚拟机上也存在。在系统设计时应该可以避免虚假唤醒,但是这会影响条件变量的执行效率,而既然通过while循环就能避免虚假唤醒造成的错误,因此程序的逻辑就变成了while循环的情况。
注意:即使是虚假唤醒的情况,线程也是在成功锁住mutex后才能从condition_wait()中返回。即使存在多个线程被虚假唤醒,但是也只能是一个线程一个线程的顺序执行,也即:lock(mutex) 检查/处理 condition_wai()或者unlock(mutex)来解锁.
这就是我们使用while去做判断而不是使用if的原因:因为等待在条件变量上的线程被唤醒有可能不是因为条件满足而是由于虚假唤醒。所以,我们需要对条件变量的状态进行不断检查直到其满足条件,不仅要在pthread_cond_wait前检查条件是否成立,在pthread_cond_wait之后也要检查。
虚假唤醒造成的后果:
需要对条件进行再判断以避免虚假唤醒:
2.2 条件变量signal与unlock的顺序
为了弥补这种缺陷,一些Pthreads的实现采用了一种叫做waitmorphing的优化措施,就不会有这个问题。因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列,当锁被持有时,直接将线程从cond_wait队列移到mutex_lock队列,而无需上下文切换,不用返回到用户空间,不会有性能的损耗。所以在Linux中推荐使用这种模式。或者说这些性能损耗并无大碍,可以使用该模式。
如果使用的Pthreads实现没有waitmorphing,我们可能需要在解锁之后在进行signal/broadcast。解锁操作并不会导致上下文切换到T2,因为T2是在条件变量上阻塞的。当T2被唤醒时,它发现锁已经解开了,从而可以对其加锁。
这种情形如何发生的呢?一个线程在锁上阻塞,是因为:
a:它要检查条件,并最终会在条件变量上wait;b:它要改变条件,并最终通知那些等待条件变量的线程;
1. 队列为空,消费者线程A阻塞并解锁,等待生产者线程B和C操作向队列中添加item。
2. 生产者线程B获取到mutex,向队列中添加item,但unlock之后,还未signal之前,发生上下文切换,切换到生产者线程C
3. 生产者线程C获取到mutex,向队列中添加item,解锁并且调用signal函数。
4. 此时消费者线程A获取到mutex,wait函数返回,处理了队列中的两个item,之后继续阻塞在条件变量上。
5. 此时如果生产者线程B得到CPU时间片,那么继续从2处运行,调用signal,唤醒消费者线程A
6. 消费者线程A被唤醒,但是因为之前的item已经被取出来,所以此时队列仍然为空,线程A被虚假唤醒,所以线程A再次进入阻塞状态。