前面介绍互斥锁时,我们解决的是:

多个线程同时访问共享数据时,如何保证同一时刻只有一个线程进入临界区。

但是互斥锁还有一个解决不了的问题。

假设线程池中的工作线程需要不断检查任务队列:

while (true)
{
    std::lock_guard<std::mutex> lock(task_mutex);

    if (!tasks.empty())
    {
        // 取任务
    }
}

如果队列一直为空,线程就会不停循环:

检查队列
  ↓
没有任务
  ↓
继续检查
  ↓
还是没有
  ↓
继续检查……

这样会白白消耗 CPU。

我们真正希望的是:

没有任务
    ↓
线程睡眠
    ↓
其他线程放入任务
    ↓
唤醒工作线程
    ↓
继续处理任务

这就是 std::condition_variable 条件变量的作用。

一、condition_variable 到底解决什么问题

先定义:

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex mutex;
std::condition_variable condition;
std::queue<int> tasks;

工作线程可以这样等待任务:

void Worker()
{
    std::unique_lock<std::mutex> lock(mutex);

    condition.wait(lock, []() { return !tasks.empty(); });

    int task = tasks.front();
    tasks.pop();
}

生产者线程加入任务:

void AddTask(int task)
{
    {
        std::lock_guard<std::mutex> lock(mutex);
        tasks.push(task);
    }

    condition.notify_one();
}

整个过程是:

Worker线程
        ↓
检查tasks.empty()
        ↓
为空
        ↓
condition.wait()
        ↓
释放mutex并睡眠


AddTask线程
        ↓
获得mutex
        ↓
tasks.push()
        ↓
释放mutex
        ↓
notify_one()
        ↓
唤醒Worker

这里一定要注意:

条件变量本身并不保存“任务”。

真正的数据还是:

std::queue<int> tasks;

条件变量只是负责:

等待某个条件成立
+
通知等待线程重新检查条件

所以:

condition.notify_one();

并不是把任务传递给工作线程。

真正传递的数据仍然放在:

tasks

里面。


二、wait(lock, predicate) 到底是怎么执行的

线程池中最常见的一句代码就是:

condition.wait(lock, [this]() { return stop_ || !tasks_.empty(); });

这句代码第一次看确实很容易懵。

先把 Lambda 单独拿出来:

[this]() { return stop_ || !tasks_.empty(); }

它本质上就是一个函数。

可以简单理解成:

bool Check()
{
    return stop_ || !tasks_.empty();
}

所以:

condition.wait(lock, [this]() { return stop_ || !tasks_.empty(); });

逻辑上类似:

condition.wait(lock, Check);

而带谓词版本的 wait() 可以近似理解成:

while (!(stop_ || !tasks_.empty()))
{
    condition.wait(lock);
}

也就是说,它会不断检查:

stop_ || !tasks_.empty()

情况一:返回 true

例如:

stop_ = false
tasks_不为空

那么:

stop_ || !tasks_.empty()

结果就是:

false || true
= true

这时候:

condition.wait(lock, predicate);

不会进入等待,直接继续往下执行。

可以理解成:

condition.wait(lock, true);

虽然真正的函数并不是这样传参,但从理解执行结果的角度可以这么看。


情况二:返回 false

例如:

stop_ = false
tasks_为空

得到:

false || false
= false

这时候线程就需要等待。

wait() 做的事情不是简单的“停在这里”。

它实际上会完成:

当前线程持有mutex
        ↓
predicate返回false
        ↓
wait自动释放mutex
        ↓
当前线程进入睡眠
        ↓
其他线程notify
        ↓
当前线程被唤醒
        ↓
重新获取mutex
        ↓
再次检查predicate

这里最重要的一点是:

wait() 在睡眠期间会释放互斥锁。

否则就会出问题。

假设工作线程睡眠时还一直拿着:

mutex

那么生产者想执行:

tasks.push(task);

也需要这把锁:

std::lock_guard<std::mutex> lock(mutex);

结果就变成:

Worker:
拿着mutex睡觉

Producer:
想拿mutex加入任务
    ↓
拿不到

Worker:
等Producer添加任务
    ↓
Producer又因为拿不到mutex无法添加

程序就卡住了。

所以 wait() 必须:

睡眠前释放锁
唤醒后重新获得锁

三、为什么必须使用 unique_lock

因此我们就能理解一个非常经典的问题:

为什么条件变量要写:

std::unique_lock<std::mutex> lock(mutex);
condition.wait(lock);

而不能写:

std::lock_guard<std::mutex> lock(mutex);
condition.wait(lock);

原因就在于:

condition_variable::wait()

内部需要控制这把锁:

unlock
    ↓
睡眠
    ↓
lock

lock_guard 的设计非常简单:

构造 → 加锁
析构 → 解锁

它不允许中途主动:

lock.unlock();
lock.lock();

unique_lock 可以:

std::unique_lock<std::mutex> lock(mutex);

lock.unlock();
lock.lock();

所以条件变量需要 unique_lock

可以把它理解成:

lock_guard:
“我拿到锁以后一直拿到作用域结束。”

unique_lock:
“我负责管理这把锁,但中途允许释放和重新获取。”

wait() 恰好就需要第二种能力。

例如:

std::unique_lock<std::mutex> lock(mutex);

condition.wait(lock, []() { return !tasks.empty(); });

内部大致相当于:

检查tasks
    ↓
没有任务
    ↓
lock.unlock()
    ↓
线程睡眠
    ↓
被notify唤醒
    ↓
lock.lock()
    ↓
再次检查tasks

因此在线程池、阻塞队列、生产者消费者模型中,unique_lock + condition_variable 经常一起出现。


四、notify_one、notify_all 与虚假唤醒

当生产者添加新任务以后,需要通知正在等待的线程:

condition.notify_one();

或者:

condition.notify_all();

notify_one

condition.notify_one();

表示:

唤醒一个正在等待这个条件变量的线程。

假设有 4 个工作线程:

Worker1:等待
Worker2:等待
Worker3:等待
Worker4:等待

加入一个任务:

tasks.push(task);
condition.notify_one();

只需要唤醒其中一个线程处理任务即可。

因此线程池添加普通任务时,一般使用:

condition.notify_one();

notify_all

condition.notify_all();

表示:

唤醒所有正在等待的线程。

例如线程池准备关闭:

stop_ = true;
condition.notify_all();

此时:

Worker1
Worker2
Worker3
Worker4

都需要知道:

线程池准备退出

所以应该全部唤醒。

典型写法:

{
    std::lock_guard<std::mutex> lock(tasks_mutex_);
    stop_ = true;
}

condition_.notify_all();

所有线程醒来以后重新检查:

return stop_ || !tasks_.empty();

因为:

stop_ == true

所以谓词返回:

true

线程不再继续等待,而是进入后面的退出逻辑。


为什么唤醒后还要重新检查条件

因为条件变量存在一个重要现象:

Spurious Wakeup
虚假唤醒

也就是说:

即使没有调用 notify_one()notify_all(),等待线程也有可能被唤醒。

所以不能这样写:

condition.wait(lock);

// 醒了就默认一定有任务
int task = tasks.front();

因为线程醒来以后:

tasks

可能仍然为空。

传统写法应该是:

while (tasks.empty())
{
    condition.wait(lock);
}

也就是:

醒来
 ↓
重新检查条件
 ↓
条件不满足
 ↓
继续睡眠

而现代 C++ 更推荐:

condition.wait(lock, []() { return !tasks.empty(); });

因为带谓词的版本已经帮我们处理了:

while (!predicate())
{
    wait(lock);
}

所以线程池中的:

condition_.wait(lock, [this]() { return stop_ || !tasks_.empty(); });

写法实际上非常标准。


五、完整生产者—消费者示例

最后用一个简单任务队列,把整个流程串起来。

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>

std::queue<int> tasks;
std::mutex tasks_mutex;
std::condition_variable condition;
bool stop = false;

消费者线程:

void Worker()
{
    while (true)
    {
        std::unique_lock<std::mutex> lock(tasks_mutex);

        condition.wait(lock, []() { return stop || !tasks.empty(); });

        if (stop && tasks.empty())
        {
            return;
        }

        int task = tasks.front();
        tasks.pop();

        lock.unlock();

        std::cout << "处理任务:" << task << '\n';
    }
}

生产者:

void AddTask(int task)
{
    {
        std::lock_guard<std::mutex> lock(tasks_mutex);
        tasks.push(task);
    }

    condition.notify_one();
}

主函数:

int main()
{
    std::thread worker1(Worker);
    std::thread worker2(Worker);

    AddTask(100);
    AddTask(200);
    AddTask(300);

    {
        std::lock_guard<std::mutex> lock(tasks_mutex);
        stop = true;
    }

    condition.notify_all();

    worker1.join();
    worker2.join();

    return 0;
}

这里有一个值得注意的地方:

int task = tasks.front();
tasks.pop();

lock.unlock();

std::cout << "处理任务:" << task << '\n';

为什么取出任务后主动:

lock.unlock();

因为互斥锁只需要保护:

访问tasks队列

真正执行任务不应该一直占着锁。

否则:

Worker1拿到锁
    ↓
取出任务
    ↓
一直拿着锁执行任务
    ↓
Worker2无法访问任务队列

线程池就会失去并发执行的意义。

所以正确思路一般是:

加锁
 ↓
访问共享任务队列
 ↓
取出任务
 ↓
解锁
 ↓
真正执行任务

这也是 unique_lock 灵活性的一个典型体现。

整个生产者—消费者模型可以总结为:

生产者
    ↓
获得mutex
    ↓
加入任务
    ↓
释放mutex
    ↓
notify_one


消费者
    ↓
获得mutex
    ↓
检查条件
    ↓
没有任务
    ↓
wait释放mutex并睡眠
    ↓
被唤醒
    ↓
重新获得mutex
    ↓
再次检查条件
    ↓
取出任务
    ↓
释放mutex
    ↓
执行任务

这一篇最需要掌握的其实就是下面几句话:

condition_variable用于“等待某个条件成立”;

wait睡眠时会自动释放mutex,
醒来后会重新获得mutex;

正因为需要中途unlock和lock,
所以wait通常配合unique_lock;

predicate返回true时不等待,
返回false时进入等待;

线程醒来以后必须重新检查条件,
因此推荐使用带predicate的wait;

notify_one通常唤醒一个工作线程;

notify_all通常用于退出、状态变化等
需要所有等待线程都重新检查条件的场景。

如果把线程池中的这一句真正理解:

condition_.wait(lock, [this]() { return stop_ || !tasks_.empty(); });

那么 condition_variable 最核心的部分基本就掌握了。

0voice · GitHub

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐