深入理解条件变量

原创内容,转载请注明出处

Posted by Weakyon Blog on March 14, 2019

本篇博客会深入分析条件变量实现来理解两个问题:

  • 为什么条件变量要配合锁来使用 — 信号丢失问题
  • 为什么条件变量需要用while循环来判断条件,而不是if — 虚假唤醒(spurious wakeup)问题

这两个问题不仅是C会遇到,所有语言的封装几乎都不可避免


先区分一下条件和条件变量,条件是指常用情况下,signal线程修改的值,使得cond_wait判断该值后不再阻塞

条件变量为什么要锁这个问题很久以前就想过。

当时觉得是条件变量判断的时候来保护条件,但今天遇到一段代码,想着如果是这样,那么单变量情况一个原子操作就能解决了,何必这么复杂。

首先说一下结论,条件变量配合锁的使用,从用不用原子操作的角度看,正确写法分别如下

// 经典写法,条件pass用锁
// 线程A,条件测试
pthread_mutex_lock(mtx);        // a1
while(pass == 0) {              // a2 
    pthread_cond_wait(cv, mtx); // a3
}
pthread_mutex_unlock(mtx);      // a4

// 线程B,条件发生修改,对应的signal代码
pthread_mutex_lock(mtx);   // b1
pass = 1;                  // b2
pthread_mutex_unlock(mtx); // b3
pthread_cond_signal(cv);   // b4
// atomic写法,条件pass用atomic
// 线程A,条件测试
pthread_mutex_lock(mtx);        // c1
while(pass == 0) {              // c2 
    pthread_cond_wait(cv, mtx); // c3
}
pthread_mutex_unlock(mtx);      // c4

// 线程B,条件发生修改,对应的signal代码
pass = 1;                  // c1
pthread_mutex_lock(mtx);   // c2
pthread_cond_signal(cv);   // c3
pthread_mutex_unlock(mtx); // c4

一 遇到的代码

void snda::CThread::ImpExecute(){
    bool iswaiting = false;
    boost::unique_lock<boost::mutex> lock(m_mut);
    while(true) {
        if (m_bTerminated)
        	break;
        if (iswaiting)
        	m_cond.wait(lock);
        this->ExecuteOne(iswaiting);
    }
}

void snda::Cthread::NoticeRun(){
    m_cond.notify_one();
}

这里的条件实际上只有cond_wait线程自己可以访问,因此signal不需要加锁。

乍一看是对的,因为经典写法的signal是也不需要锁的,仔细探究了才会发现问题。

看一下条件变量的源码来分析这个问题

二 条件变量实现源码分析

pthread_cond_signal

pthread_cond_wait

int __pthread_cond_signal (pthread_cond_t *cond) {
	++cond->__data.__futex;
}
int __pthread_cond_wait (pthread_cond_t *cond;
     pthread_mutex_t *mutex) {
   do {
     unsigned int futex_val = cond->__data.__futex;
     lll_unlock (cond->__data.__lock, pshared);
     lll_futex_wait (&cond->__data.__futex, futex_val, pshared);
   } while(val == seq || cond->__data.__woken_seq == val);
   return __pthread_mutex_cond_lock (mutex);
}

signal会改变futex的值,而cond_wait在解锁前记录下了futex的值futex_val,当wait时futex值和futex_val不同。

会使得休眠失败,直接执行下面的语句,因此只要进入了cond_wait函数后,即使还没执行futex_wait时就解锁了,依然可以唤醒这个准备休眠的线程

因此,条件变量的线程安全是强依赖互斥锁的

那么为什么经典写法的signal可以不需要锁呢?

三 经典写法分析

再来看下经典写法可能的执行顺序

pthread_mutex_lock(mtx);        // a1
while(pass == 0) {              // a2 
    pthread_cond_wait(cv, mtx); // a3
}
pthread_mutex_unlock(mtx);      // a4

pthread_mutex_lock(mtx);   // b1
pass = 1;                  // b2
pthread_mutex_unlock(mtx); // b3
pthread_cond_signal(cv);   // b4
  • 当a1先执行时,顺序为a1,a2,a3,b1,b2,b3,b4,a4。

    这个场景就是上一节分析的场景

  • 当b1先执行时,顺序为b1,b2,b3,而后a1无论什么时候执行,在a2执行后都会跳过a3,因此不会有问题

经典写法的signal的多线程安全不依赖锁,只是因为cond_wait根本不会执行罢了

当signal前没有条件进行加锁来跳过cond_wait时,就会因为cond_wait内部执行的非原子性,导致先cond_wait的线程无法被唤醒从而死锁

回到第一节的代码,很容易就能发现,由于cond_wait无法避免,因此signal必须加锁才能避免线程安全的问题。

四 虚假唤醒

虚假唤醒是因为第二节中源码那样的实现导致的另外一个问题

有两种情况:

  • 一次signal唤醒2个或以上线程
  • 没有发生signal但是唤醒了线程

这两种情况决定了条件变量返回后需要用while循环来判断条件,如果是虚假唤醒那么继续wait

第一种情况在man pthread_cond_signal中描述的很清楚,点这里看网页版本

其中的Multiple Awakenings by Condition Signal部分举了一个例子,当A线程即将wait,B线程已经wait,此时来了一个signal,由于第二节提到的futex值发生了变化,不仅B从cond_wait中返回,A也是一样。因此唤醒了两个线程

而第二个情况就比较简单了

futex这样的长等待系统调用会被信号打断,对其他系统调用来说,被打断会有一个EINTR错误,可以直接重试,并不影响本身的语义,例如read失败,或者互斥锁。但是对条件变量来说,可能就会错过已经发生的signal,因此一旦被打断就会直接返回。

这个解释是在为什么条件锁会产生虚假唤醒现象(spurious wakeup)?看到的,当然他只解释了第二个情况。而参考资料中陈硕的博客只解释了什么是虚假唤醒。

五 参考资料

条件变量 condition

87-深入条件变量

[并发系列-5] 从AQS到futex(三): glibc(NPTL)的mutex/cond实现

pthread_cond_wait 为什么需要传递 mutex 参数?

用条件变量实现事件等待器的正确与错误做法

14 Mar 2019