跳转到内容

C++/STL/ConditionVariable

维基教科书,自由的教学读本
< C++

condition_variable标准程式库中的一个头文件,定义了C++11标准中的一些用于并发编程时表示条件变量的类与方法等。从g++ 4.8.1、Visual C++ 2012都已经支持了C++11标准中定义的condition_variable头文件。

背景简介

[编辑]

条件变量是并发编程中用于线程间事件等待与唤醒的同步原语,专门解决一个互斥锁无法解决的问题:线程拿到锁了,但 “业务条件不满足”,需要主动释放锁、休眠等待,条件成立后再被唤醒继续执行。单纯的互斥锁仅能保证临界区访问互斥,只能解决 “抢不抢得到资源” 的问题;但无法解决“抢到资源但暂时不能干活、需要等待状态变化” 的问题。条件变量就是为“带条件的阻塞等待”而生的配套机制。

条件变量的完整运行模型依赖两套阻塞队列协同工作(核心本质):

  1. 条件变量等待队列:线程条件不满足时,主动进入此处休眠、释放互斥锁;
  2. 互斥锁等待队列:线程被唤醒后,转入此处重新竞争锁。

整套流程严格固定:

  • 线程持有互斥锁,独占读取 / 判断共享条件;
  • 若条件不成立:原子释放锁 + 进入条件变量队列阻塞;
  • 若条件成立:直接执行业务逻辑;
  • 其他线程修改共享状态后,通过 notify_one / notify_all 唤醒等待线程;
  • 被唤醒线程不会直接执行,必须转入互斥锁队列重新抢锁、再次循环校验条件,规避虚假唤醒与时序漏洞。

由于条件变量自身不存储条件、不保存状态、不记录数据,仅作为 “唤醒通知队列”,因此必须绑定唯一互斥锁:互斥锁用于原子保护条件判断,条件变量用于阻塞休眠与事件唤醒。若等待线程使用不同互斥锁,时序模型彻底崩坏,行为未定义。

标准编程范式的本质目的:

mutex.lock();//互斥锁加锁
whilepredict()!=true) // predict可以任意复杂,但必须互斥独占访问
    conditionVariable.wait();
//退出while循环执行至此时,既有predict()为真且获得了mutex加锁

while 对抗虚假唤醒;

  • 锁保证条件判断、等待入队全程无间隙、无丢失唤醒;
  • 退出循环时同时满足:持有锁 + 条件成立。

唤醒顺序的底层工程抉择(关键硬核知识点),notify线程不强制持有锁,业界长期存在两种顺序:

  • 先解锁、后 notify。被唤醒线程可直接抢锁,减少一次阻塞。但存在优先级倒置、线程饥饿、公平性破坏风险。发起notify的线程不需要拥有互斥锁。
  • 先notify、后解锁(标准推荐悲观策略)。现代操作系统不唤醒该线程恢复执行,仅仅把线程从条件队列迁移到锁队列;再释放锁。虽然会出现 hurry-up-and-wait(唤醒即阻塞) 的短暂空转,但保证队列 FIFO 顺序、保留锁调度器的优先级与公平性调度,规避优先级倒置。一种办法是调用了notify_all的线程保持互斥锁,直到所有从条件变量上解除阻塞的线程都已经挂起(suspend)到互斥锁上,然后发起了notify_all的线程再释放互斥锁。[1]互斥锁上一般都有比较完善的阻塞线程调度算法,一般会按照线程优先级调度,相同优先级按照FIFO调度。一般建议是先notify操作,后对互斥锁解锁。因为这既有利于上述的公平性,同时还避免了相反顺序时可能的w:优先级倒置。这种先notify后解锁的做法是悲观的(pessimization),因为被通知(notified)线程将立即被阻塞,等待通知(notifying)线程释放互斥锁。很多实现(特别是pthreads的很多实现)为了避免这种“匆忙与等待”(hurry up and wait)情形,把在条件变量的线程队列上处于等待的被通知线程直接移到互斥锁的线程队列上,而不唤醒这些线程。尽管该方式依然无法保证条件变量原生实现绝对公平,但可以规避一种典型饥饿场景:防止刚抵达临界区的新线程抢先获取锁、持续抢占资源,长期挤占条件变量上早已等待的线程。

主流 POSIX pthread、Windows Vista原生 CONDITION_VARIABLE均采用队列迁移优化:被唤醒线程不真正唤醒 CPU,直接从条件队列移入互斥锁队列,彻底解决 “匆忙等待” 的性能损耗。

C++11 std::condition_variable 完全对齐 POSIX 语义,底层无论 Linux (futex) / Windows (ConditionVariable),均遵循双队列、绑定互斥锁、循环判断、先通知后解锁的标准模型。

std::condition_variable类

[编辑]

std::condition_variable类表示w:条件变量。效果上相当于包装了w:pthread库中的pthread_cond_*()系列的函数。

  • 构造函数
    • condition_variable();缺省构造函数
    • condition_variable (const condition_variable&) = delete;禁止拷贝构造函数
  • 成员函数
    • void wait (unique_lock<mutex>& lck); 无条件被阻塞。调用该函数前,当前线程应该已经对unique_lock<mutex> lck完成了加锁。所有使用同一个条件变量的线程必须在wait函数中使用同一个unique_lock<mutex>。该wait函数内部会自动调用lck.unlock()对互斥锁解锁,使得其他被阻塞在互斥锁上的线程恢复执行。使用本函数被阻塞的当前线程在获得通知(notified,通过别的线程调用 notify_*系列的函数)而被唤醒后,wait()函数恢复执行并自动调用lck.lock()对互斥锁加锁。
    • template <class Predicate> void wait (unique_lock<mutex>& lck, Predicate pred);带条件的被阻塞。wait函数设置了谓词(Predicate),只有当pred条件为false时调用该wait函数才会阻塞当前线程,并且在收到其他线程的通知后只有当pred为true时才会被解除阻塞。因此,等效于while (!pred()) wait(lck);
    • template <class Rep, class Period> cv_status wait_for (unique_lock<mutex>& lck, const chrono::duration<Rep,Period>& rel_time);指定一个时间段,在当前线程收到通知(notify)或者超过指定时间段,wait_for 返回
    • template <class Rep, class Period, class Predicate> bool wait_for (unique_lock<mutex>& lck, const chrono::duration<Rep,Period>& rel_time, Predicate pred); 有条件阻塞且超时返回。
    • template <class Clock, class Duration> cv_status wait_until (unique_lock<mutex>& lck, const chrono::time_point<Clock,Duration>& abs_time);指定一个绝对时间点,超时wait_until返回。
    • template <class Clock, class Duration, class Predicate> bool wait_until (unique_lock<mutex>& lck, const chrono::time_point<Clock,Duration>& abs_time, Predicate pred);有条件阻塞且超时返回。
    • notify_one():唤醒某个等待线程,该线程是通过该条件变量的某个wait函数阻塞在该条件变量的线程队列上。如果当前没有等待线程,则该函数什么也不做
    • notify_all():唤醒所有的等待(wait)线程。如果当前没有等待线程,则该函数什么也不做。

std::condition_variable_any类

[编辑]

与std::condition_variable用法一样,区别仅在于std::condition_variable_any 的 wait 函数可以接受任何 lockable 参数,而 std::condition_variable 只能接受 std::unique_lock<std::mutex> 类型的参数。

std::cv_status枚举类型

[编辑]
  • std::cv_status::no_timeout: wait_for 或者 wait_until 没有超时即返回,即在规定的时间段内线程收到了通知。
  • std::cv_status::timeout: wait_for 或者 wait_until 超时后返回。

函数std::notify_all_at_thread_exit()

[编辑]

函数原型为:

void notify_all_at_thread_exit (condition_variable& cond, unique_lock<mutex> lck);

当调用该函数的线程退出时,所有在 cond 条件变量上等待的线程都会收到通知。Microsoft Visual C++ 2013已经支持了该函数;但GCC 4.9.3尚未支持该函数。

例子程序

[编辑]
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
 
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread()
{
    // 等待主线程设置好ready变量为真
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{return ready;}); //或者为 cv.wait(lk);
 
    // 现在拥有了互斥锁m,变量ready为真,已进入了临界区
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // Send data back to main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // 手工解锁,并通知阻塞在cv上的某个线程。  
    cv.notify_one();
    lk.unlock();
}
 
int main()
{
    std::thread worker(worker_thread); //启动工作线程
 
    data = "Example data";
    //把ready变量由false变为true,这使得工作线程进入临界区
    {
        std::lock_guard<std::mutex> lk(m); //由于工作线程不可能更早得到ready为真,所以主线程很快就会获得互斥锁m
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one(); //通知已经阻塞在cv上的某个线程;如果没有线程被阻塞,则什么也不做
 
    // 等待工作线程——主线程需要获得互斥锁m且processed变量变为真
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, []{return processed;});
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

示例:实现信号量

[编辑]
#include <mutex>
#include <condition_variable>

class Semaphore {
public:
    Semaphore (int count_ = 0)
        : count(count_) {}

    inline void notify()
    {
        std::unique_lock<std::mutex> lock(mtx);
        count++;
        cv.notify_one();
    }

    inline void wait()
    {
        std::unique_lock<std::mutex> lock(mtx);

        while(count == 0){
            cv.wait(lock);
        }
        count--;
    }

private:
    std::mutex mtx;
    std::condition_variable cv;
    int count;
};

参考文献

[编辑]
  1. Douglas C. Schmidt and Irfan Pyarali:《Strategies for Implementing POSIX Condition Variables on Win32》§3.4. The SignalObjectAndWait Solution