Linux线程同步——条件变量

时间:2024-04-06 14:37:12

一. 概述 
        条件变量(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通常用于表示资源可用

        它的使用方式如下图所示:

Linux线程同步——条件变量


二.条件变量的使用,有几个地方要弄明白

    2.1 在wait端,我们必须把判断布尔条件和wait()放到while循环中,而不能用if语句,原因是虚假唤醒(spurious wakeup)
            那么,究竟什么是虚假唤醒,导致虚假唤醒的原因又是什么呢?
            一般来说,在多线程竞争一个资源的时候,会用到pthread_cond_wait,pthread_cond_signal机制,典型的做法就是在一个使用这个资源的线程(消费者)里面,判断资源如果不可用的话,则pthread_cond_wait,在另外一个线程(生产者)中判断如果资源可用的话,则发一个pthread_cond_signal或者pthread_cond_broadcast通知wait的线程。
            但是有一个问题,就是在wait成功后,实际上此时的资源是否就一定可用呢?答案是否定的。
            先要知道pthread_cond_wait的实际过程。 当发起一个pthread_cond_wait之后,分解后,实际上是三个动作:    
                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号线程而言,这次唤醒就是“虚假”的,它需要再次等待队列非空。
            在多核处理器下,pthread_cond_signal可能会**多于一个线程(阻塞在条件变量上的线程)。结果就是,当一个线程调用pthread_cond_signal()后,多个调用pthread_cond_wait()或pthread_cond_timedwait()的线程返回。这种效应就称为“虚假唤醒”
            也就是说,即使没有线程调用condition_signal, 原先调用condition_wait的函数也可能会返回。此时线程被唤醒了,但是条件并不满足,这个时候如果不对条件进行检查而往下执行,就可能会导致后续的处理出现错误。 
            虚假唤醒在linux的多处理器系统中/在程序接收到信号时可能回发生。在Windows系统和JAVA虚拟机上也存在。在系统设计时应该可以避免虚假唤醒,但是这会影响条件变量的执行效率,而既然通过while循环就能避免虚假唤醒造成的错误,因此程序的逻辑就变成了while循环的情况。 
            注意:即使是虚假唤醒的情况,线程也是在成功锁住mutex后才能从condition_wait()中返回。即使存在多个线程被虚假唤醒,但是也只能是一个线程一个线程的顺序执行,也即:lock(mutex)  检查/处理  condition_wai()或者unlock(mutex)来解锁. 
            如果用if判断,多个等待线程在满足if条件时都会被唤醒(虚假的),但实际上条件并不满足,生产者生产出来的消费品已经被第一个线程消费了。 
            这就是我们使用while去做判断而不是使用if的原因:因为等待在条件变量上的线程被唤醒有可能不是因为条件满足而是由于虚假唤醒。所以,我们需要对条件变量的状态进行不断检查直到其满足条件,不仅要在pthread_cond_wait前检查条件是否成立,在pthread_cond_wait之后也要检查。
            Linux man page中也有提到: 
            虚假唤醒造成的后果: 
Linux线程同步——条件变量

          需要对条件进行再判断以避免虚假唤醒: 

Linux线程同步——条件变量
  2.2  条件变量signal与unlock的顺序

  pthread_cond_signal即可以放在pthread_mutex_lock和pthread_mutex_unlock之间,也可以放在pthread_mutex_lock和pthread_mutex_unlock之后。
         pthread_cond_signal和pthread_mutex_unlock的顺序,即唤醒和解锁的先后顺序,对程序的运行有何影响。
这里抓住一个关键点,并非signal端的线程调用pthread_cond_signal函数后,pthread_cond_wait() 就立即返回,而是在它获取到mutex并重新lock,才能返回继续向下执行。
    (1)signal在前,unlock在后
                某些平台上,在执行了signal/broadcast之后,为了减少延迟,操作系统会将上下文切换到被唤醒的线程。在单核系统上,如果在加锁的情况下调用signal/broadcast,这可能导致不必要的上下文切换,有性能损耗。 
   
     Linux线程同步——条件变量 
  考虑上图的场景:T2阻塞在条件变量上,T1在持有锁的情况下调用signal,接着上下文切换到T2,并且T2被唤醒,但是T2在从pthread_cond_wait返回时,需要重新加锁,然而此时锁还在T1手中。因此,T2只能继续阻塞(但是此时是阻塞在锁上),并且上下文又切换回T1。所以一来一回会有性能的问题。当T1解锁时,T2才得以继续运行。如果是调用broadcast唤醒等待条件变量的多个线程的话,那这种情形会变得更糟。
              为了弥补这种缺陷,一些Pthreads的实现采用了一种叫做waitmorphing的优化措施,就不会有这个问题。因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列,当锁被持有时,直接将线程从cond_wait队列移到mutex_lock队列,而无需上下文切换,不用返回到用户空间,不会有性能的损耗。所以在Linux中推荐使用这种模式。或者说这些性能损耗并无大碍,可以使用该模式。
              如果使用的Pthreads实现没有waitmorphing,我们可能需要在解锁之后在进行signal/broadcast。解锁操作并不会导致上下文切换到T2,因为T2是在条件变量上阻塞的。当T2被唤醒时,它发现锁已经解开了,从而可以对其加锁。
  (2)unlock在前,signal在后
                优点:不会出现之前说的那个潜在的性能损耗,因为在signal之前就已经释放锁了。
               解锁后调用signal有问题吗?首先,我们注意到,如果先进行signal/broadcast,则肯定会唤醒一个阻塞在条件变量上的线程;然而如果先解锁,则可能会唤醒一个阻塞在锁上的线程。
               这种情形如何发生的呢?一个线程在锁上阻塞,是因为:
         a:它要检查条件,并最终会在条件变量上wait;
         b:它要改变条件,并最终通知那些等待条件变量的线程;
        可能会产生虚假唤醒,假如有一个消费者线程A,两个生产者线程B和C,考虑如下场景
       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再次进入阻塞状态。
        另外,在线程进行signal/broadcast之前,也可能会发生优先级反转。继续上面的栗子。
                线程A、B、C的优先级分别为P2、P1、P3,    优先级之间的大小顺序为:P1 < P3 < P2
               线程B向队列中添加item,但unlock之后,还未signal之前,可能被更高优先级的P3的线程C抢占,从而无法唤醒线程A。因此低优先级的线程C阻碍了高优先级的线程A, 发生优先级反转。
            还有,如果先解锁,则可能会导致另一种问题:你必须保证解锁之后,用于signal/broadcast的条件变量依然有效。如果解锁后条件变量无效,会导致异常。

三、总结
       综上所述,推荐使用“signal在前,unlock在后”的这种模式。首先,这样做可以避免隐蔽的bug;然后,在使用了wait morphing优化的Pthreads实现中,这样做几乎没有性能损耗;其次,我认为只有在明确表明性能可以得到显著提升时,才有必要先unlock,后signal/broadcast,优化那些并非导致性能瓶颈的点,是没有必要的。