C++条件变量(一):从轮询到唤醒 —— 条件变量的设计动机与基础用法

文章目录

    • 0.引言
    • [1.核心组件与基本 API](#1.核心组件与基本 API)
    • 2.生产者-消费者示例
    • [3.为什么 wait必须与互斥锁配合使用?](#3.为什么 wait必须与互斥锁配合使用?)
    • [4.notify_one 与 notify_all 的区别](#4.notify_one 与 notify_all 的区别)
    • [5.谓词版本的 wait 为什么更安全?](#5.谓词版本的 wait 为什么更安全?)
    • [6. 小结](#6. 小结)

0.引言

在多线程编程程序中,线程之间经常需要协同工作。常见的一种场景是:一个线程需要等待某个条件满足,再继续执行。例如:

  • 消费者线程等待队列非空,然后取出数据;

  • 工作线程等待某个标志位被设置,然后开始处理任务。

如果直接用最简单的轮询(busy waiting)来实现:

cpp 复制代码
// 消费者线程
while (queue.empty()) {
    // 什么都不做,继续循环
}
// 退出循环后,队列非空,取出数据

这种写法的问题很明显:CPU 会一直空转,浪费资源,如果系统负载高,这种空转可能导致其他线程得不到执行机会。

条件变量(std::condition_variable)正是为了解决这个问题而生的。它允许线程在条件不满足时休眠,将 CPU 让给其他线程,直到条件满足时被唤醒。这是一种高效的线程同步机制。

1.核心组件与基本 API

C++ 标准库提供了两个条件变量类:

  • std::condition_variable:只与 std::mutex 配合使用。

  • std::condition_variable_any:可与任何满足互斥体概念的对象配合,但开销更大。

在绝大多数情况下,我们使用 std::condition_variable 即可。

主要成员函数:

2.生产者-消费者示例

我们来实现一个最简单的生产者-消费者模型:

  • 生产者线程向队列中放入数据,并通知消费者。

  • 消费者线程等待队列非空,然后取出数据。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>

std::queue<int> g_queue;          // 共享数据队列
std::mutex g_mutex;               // 保护队列的互斥锁
std::condition_variable g_cv;     // 条件变量

void producer() {
    for (int i = 0; i < 10; ++i) {
        {
            std::lock_guard<std::mutex> lock(g_mutex);
            g_queue.push(i);
            std::cout << "Produced: " << i << std::endl;
        } // 离开作用域,自动解锁

        g_cv.notify_one();          // 通知一个消费者线程

        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(g_mutex);
        // 等待,直到队列非空(条件满足)
        g_cv.wait(lock, [] { return !g_queue.empty(); });

        // 条件满足,取出数据
        int value = g_queue.front();
        g_queue.pop();
        std::cout << "Consumed: " << value << std::endl;

        lock.unlock(); // 可选,提前解锁

        // 模拟消费耗时
        std::this_thread::sleep_for(std::chrono::milliseconds(150));
    }
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    // 为了让消费者看到结束,此处简单等待(实际项目可使用哨兵值)
    std::this_thread::sleep_for(std::chrono::seconds(1));
    // 注意:这里没有优雅退出,只是示例,实际应该设置停止标志
    return 0;
}

关键点解释:

  • 生产者使用 std::lock_guard 来锁定 g_mutex,在修改队列后立即释放锁,然后调用 notify_one()。这样唤醒时,消费者线程可以立刻获取锁。

  • 消费者使用 std::unique_lock(而不是 lock_guard),因为 wait 需要在线程阻塞期间解锁,唤醒后再重新锁定,unique_lock 支持这种操作。

  • wait 的谓词版本 g_cv.wait(lock, []{ return !g_queue.empty(); }) 等价于:

cpp 复制代码
while (!g_queue.empty()) {
    g_cv.wait(lock);
}

它会在条件不满足时持续等待,即使被虚假唤醒(spurious wakeup)也会重新检查条件。

3.为什么 wait必须与互斥锁配合使用?

这是一个常见的问题:为什么不能单独使用条件变量?为什么 wait 必须接受一个已经锁定的 unique_lock?

考虑一个没有锁的伪代码实现:

cpp 复制代码
// 错误示例:没有使用互斥锁
if (queue.empty()) {
    cv.wait();   // 假设有这样的 API
}

假设线程 A 执行到 if (queue.empty()) 判断为真,正准备进入 wait,但此时操作系统切换到了生产者线程 B。B 修改了队列(使其非空),并调用了 notify_one()。由于 A 还没有进入 wait,这个通知就丢失了。接着 A 才进入 wait,它将永远等下去,因为没有人再通知它了。

这就是经典的 "丢失唤醒" 问题。解决办法是:将条件检查和进入等待这两个步骤原子化。互斥锁实现了这一点:wait 内部会原子地完成"解锁 + 阻塞"两个动作,同时保证条件检查在锁的保护下完成。具体过程如下:

1)线程在调用 wait 之前已经持有锁。

2)wait 内部首先检查条件(谓词版本会先调用谓词,如果不满足,则执行下面的步骤)。

3)原子地:释放锁 + 阻塞线程。

4)当被唤醒后,wait 重新获取锁,然后返回(或再次检查谓词)。

这样,从条件检查到进入休眠之间没有空隙,通知不会丢失。

因此,wait 必须与互斥锁配合,并且锁必须由 std::unique_lock 持有,因为 lock_guard 不支持中途释放锁。

4.notify_one 与 notify_all 的区别

notify_one():唤醒一个等待的线程(如果有多个,由调度器决定唤醒哪一个)。适用于只需一个线程处理新任务的场景,可以减少"惊群效应"。

notify_all():唤醒所有等待的线程。适用于所有线程都需要响应某个状态变化(比如程序结束标志)。

在生产者-消费者模型中,如果只有一个消费者,notify_one 就足够了。如果有多个消费者,且每次生产一个数据,通常也使用 notify_one,因为只有一个消费者能获得数据,其他被唤醒的线程会再次进入等待,造成不必要的上下文切换。

5.谓词版本的 wait 为什么更安全?

上面的示例中,我们使用了 g_cv.wait(lock, []{ return !g_queue.empty(); }); 而不是直接调用 wait(lock) 再自己检查。

理由有两个:

1)自动处理虚假唤醒:即使线程被虚假唤醒,谓词会被重新检查,如果不满足,会继续等待,避免了错误执行。

2)代码更简洁:将条件检查与等待逻辑封装在一起,减少了出错可能。

在 C++ 标准中,wait(lock, pred) 等价于:

cpp 复制代码
while (!pred()) {
    wait(lock);
}

因此,它已经包含了必要的循环。

6. 小结

  • 条件变量解决了线程间高效等待的问题,避免了 CPU 空转。

  • 基本用法:互斥锁 + 条件变量,等待条件满足。

  • wait 必须与 std::unique_lock 配合,用于原子地释放锁并阻塞。

  • 使用 notify_one 或 notify_all 唤醒线程。

  • 始终使用谓词版本的 wait,以正确处理虚假唤醒。

下一篇我们将深入探讨条件变量的超时机制。

更多深入内容欢迎了解:C++/Linux/ 数据库内核 | 底层开发 + AI 实战圈------12 个月系统落地,从原理到工业级实战,搭建你的核心技术壁垒

相关推荐
是娇娇公主~2 小时前
线程池:缓存线程池CachedThreadPool
c++·线程池
小欣加油2 小时前
leetcode 128 最长连续序列
c++·算法·leetcode·职场和发展
玖釉-2 小时前
图形 API 的前沿试车场:Vulkan 扩展体系深度解析与引擎架构实践
c++·架构·图形渲染
许杰小刀2 小时前
SourceGenerator之partial范式及测试
c++·mfc
玖釉-2 小时前
告别 Shared Memory 瓶颈:Vulkan Subgroup 架构解析与硬核实战指南
开发语言·c++·windows·图形渲染
吴梓穆2 小时前
UE5 C++ 两种枚举
开发语言·c++·ue5
星辰徐哥2 小时前
C++测试与调试:确保代码质量与稳定性
开发语言·c++
jghhh012 小时前
VC++ 屏幕锁定、关机、托盘工具源代码
开发语言·c++
邪修king2 小时前
【UE4/UE5 萌新向】有C++基础如何快速入门虚幻引擎?超详细图文全揭秘!
c++·ue5·ue4