c++核心:小心条件变量的陷阱

原文:http://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables

条件变量支持一个很简单的功能,一个线程准备好数据,然后向另外一个线程发送通知,通知另一个线程处理这些数据.为什么有可能会很危险呢?

不要无条件等待

有这样的一个规则:一个没有条件(condition)的等待有可能错失线程唤醒,或者线程唤醒后没有工作可做.这句话是什么意思呢?条件变量有可能存在两个问题:丢失线程唤醒和虚假唤醒(spurious wakeup),条件变量的一个问题就是他们不占内存.

首先我们先来看这样一段程序:

// conditionVariables.cpp

#include 
#include 
#include 

std::mutex mutex_;
std::condition_variable condVar; 

bool dataReady{false};

void waitingForWork(){
    std::cout << "Waiting " << std::endl;
    std::unique_lock lck(mutex_);
    condVar.wait(lck, []{ return dataReady; });   // (4)
    std::cout << "Running " << std::endl;
}

void setDataReady(){
    {
        std::lock_guard lck(mutex_);
        dataReady = true;
    }
    std::cout << "Data prepared" << std::endl;
    condVar.notify_one();                        // (3)
}

int main(){
    
  std::cout << std::endl;

  std::thread t1(waitingForWork);               // (1)
  std::thread t2(setDataReady);                 // (2)

  t1.join();
  t2.join();
  
  std::cout << std::endl;
  
}

这段程序是如何实现线程间同步的呢?程序有两个线程t1,t2,分别执行waitingForWork和setDataRead.

setDataReady进行通知:使用条件变量condVar,通过condVar.notify()(line 3)进行通知.线程t1拿到锁之后,等待通知condVar.wait(lck, []{ return dataReady; })( line 4).发送者和接受者都需要一个锁,对于发送者来说,使用lock_guard就足够了,因为发送方只需要lock和unlock一次.对于接收者来说,需要使用unique_lock,因为接受者需要经常进行lock和unlock操作.

也许你会奇怪,wati方法为什么需要一个predicate?因为从理论上分析wait方法不需要predicate也可以实现这个功能.下面我们再看一下线程的唤醒丢失和虚假唤醒

线程唤醒丢失和虚假唤醒

唤醒丢失:这个现象是指发送方在接受方进入wait之前就发送了通知,结果就是发送方发送的这个通知丢失,c++标准中把条件变量描述成一个同时的同步机制(simultaneous synchronisation mechanism):条件变量是一种原始的同步机制,可以同时阻塞一个或多个线程.如果不使用predicate,当发送方的通知丢失的时候,接收方会永远等待下去.

虚假唤醒:在接收方未收到通知的时候,接收方线程也有可能会唤醒.

由于存在这两个问题,你必须使用额外的predicate.如果你不相信,我们再看一下条件变量wait方法的工作流程:

wait工作流程

在调用wait之前,线程需要拿到锁并加锁.

执行wait,进程首先判断predicate,如果predicate为true,线程继续运行.如果predicate为false,线程unlock锁,进入等待(阻塞)状态.

如果条件变量处于等待状态的时候收到了通知或虚假唤醒,线程会执行一下步骤:

线程解除阻塞,重新lock住锁,判断predicate,如果predicate为true,线程继续工作,如果为false,则unlock锁,线程重新进入等待阻塞态.

没有predicate

如果没有predicate,会发生什么呢?

// conditionVariableWithoutPredicate.cpp

#include 
#include 
#include 

std::mutex mutex_;
std::condition_variable condVar;

void waitingForWork(){
    std::cout << "Waiting " << std::endl;
    std::unique_lock lck(mutex_);
    condVar.wait(lck);                       // (1)
    std::cout << "Running " << std::endl;
}

void setDataReady(){
    std::cout << "Data prepared" << std::endl;
    condVar.notify_one();                   // (2)
}

int main(){
    
  std::cout << std::endl;

  std::thread t1(waitingForWork);
  std::thread t2(setDataReady);

  t1.join();
  t2.join();
  
  std::cout << std::endl;
  
}

现在,对于wait的调用不再使用predicate,这种同步机制看起来很简单.但是不幸的是,这个程序会存在一个 race condition,会引发死锁.

发送方在line(1)发送通知,发送方在接受方wait之前就发送了通知,接收方就永久阻塞了.

原子操作的predicate

也许你已经注意到了,dataReady变量只是一个布尔变量,我们可不可以把它变成一个原子的布尔变量,这样就可以不加锁了呢?

// conditionVariableAtomic.cpp

#include 
#include 
#include 
#include 

std::mutex mutex_;
std::condition_variable condVar;

std::atomic dataReady{false};

void waitingForWork(){
    std::cout << "Waiting " << std::endl;
    std::unique_lock lck(mutex_);
    condVar.wait(lck, []{ return dataReady.load(); });   // (1)
    std::cout << "Running " << std::endl;
}

void setDataReady(){
    dataReady = true;
    std::cout << "Data prepared" << std::endl;
    condVar.notify_one();
}

int main(){
    
  std::cout << std::endl;

  std::thread t1(waitingForWork);
  std::thread t2(setDataReady);

  t1.join();
  t2.join();
  
  std::cout << std::endl;
  
}

这个程序很直观,但是这个程序,线程间依然有race condition并且会导致死锁.因为wait操作比看起来的还要复杂一些.wait操作等价于:

std::unique_lock lck(mutex_);
while ( ![]{ return dataReady.load(); }() {
    // time window (1)
    condVar.wait(lck);
}

即使你把dataReady设置成原子变量,但是也必须加锁操作,如果不加锁的话可能无法实现线程同步.我们假设所有变量的操作是原子的并且没有锁保护.

我们假设接收方在执行wait但是线程没有阻塞,即接收方执行line 1的代码段,这样的话发送方发送的通知就丢失了,接下来接受方会永久进入阻塞状态.但是如果加锁进行保护的话,这种情况就不会再发生,当加锁保护的时候,只有接收方处于等待态的时候,发送方才能发送通知.

这是不是一个鬼故事?那么有没有其他的可能来简化我们的程序呢?有的,但不是使用条件变量,你可以使用一对promise和future(a promise and future pair the make the job done).

你可能感兴趣的:(C++)