一次性事件通信

  • 方向:检测任务 → 反应任务(单方向);
  • 内容:仅传递 “事件已发生” 的信号,无需传递具体数据;
  • 次数:仅一次(事件触发后无需重复通知);
  • 要求:反应任务需真正阻塞(不占用 CPU)、无通知丢失、无虚假唤醒。

方案 1:条件变量(condvar)—— 设计别扭的 “标配”

1. 多余的互斥锁(代码异味)

条件变量必须配合互斥锁使用(C++11 API 强制要求),但一次性事件通信中,检测 / 反应任务往往无共享数据需要保护(比如检测任务初始化完数据结构后不再访问,反应任务仅在事件后访问)—— 互斥锁成了 “无意义的强制依赖”。

2. 通知丢失风险

若检测任务在反应任务调用 cv.wait() 前执行 cv.notify_one(),反应任务会永久挂起(因为 wait 时通知已丢失)。

3. 虚假唤醒问题

条件变量存在 “虚假唤醒”(即使未被通知,wait 也可能返回),因此必须配合 “条件判断” 使用:

1
2
3
4
5
6
7
std::condition_variable cv;
std::mutex m;
bool flag = false;

// 反应任务
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{ return flag; }); // 必须加lambda判断flag

但问题在于:反应任务本就是 “等待事件发生”,若它能自行判断 flag,就无需依赖条件变量 —— 逻辑上形成矛盾。

结论:条件变量适合 “重复事件 + 共享数据保护” 场景,不适合一次性无数据的事件通信。

方案 2:共享布尔 flag —— 无锁但耗资源的轮询

为规避条件变量的问题,很多开发者会用 std::atomic<bool> 实现 “flag 轮询”:

1
2
3
4
5
6
7
std::atomic<bool> eventOccurred(false);

// 检测任务
eventOccurred = true;

// 反应任务
while (!eventOccurred); // 轮询等待

优点

  • 无需互斥锁,设计简单;
  • 无通知丢失(无论检测任务何时置位 flag,反应任务轮询总能读到);
  • 无虚假唤醒(仅依赖 flag 值判断)。

缺点

反应任务的 while (!eventOccurred) 是忙等(busy waiting) —— 线程看似 “阻塞”,实则一直在占用 CPU 核心运行,会导致:

  • 浪费硬件线程资源(本可给其他任务使用);
  • 增加上下文切换开销;
  • 持续占用核心,无法进入低功耗状态(嵌入式 / 移动端尤其明显)。

结论:flag 轮询仅适合 “事件极短时间内触发” 的场景,不适合长时间等待。

方案 3:条件变量 + flag —— 能工作但不优雅的组合

将前两种方案结合,可解决各自的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::condition_variable cv;
std::mutex m;
bool flag = false;

// 检测任务
{
std::lock_guard<std::mutex> g(m);
flag = true; // 置位flag(事件真发生)
}
cv.notify_one(); // 唤醒反应任务

// 反应任务
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{ return flag; }); // 避免虚假唤醒+通知丢失

优点

  • 解决了条件变量的通知丢失、虚假唤醒问题;
  • 解决了 flag 轮询的 CPU 占用问题(反应任务真正阻塞)。

问题

设计逻辑割裂:检测任务需 “先置位 flag + 再通知 cv”,反应任务需 “等 cv 唤醒 + 再查 flag”—— 本质是 “用两个操作传递一个事件信号”,代码不自然、可读性差,且仍需维护多余的互斥锁。

结论:能工作,但不是最优解。

方案 4:void promise/future —— 一次性通信的最优解

std::promise 和 std::future 原本用于 “异步任务返回结果”,但可无缝适配 “仅传递事件信号” 的场景 —— 思路是:用 void 类型表示 “仅传递事件发生的信号,无数据”。

实现(单反应任务)

1
2
3
4
5
6
7
std::promise<void> eventPromise; // 检测任务持有:通信信道的写入端

// 检测任务:事件发生时触发
eventPromise.set_value(); // 写入“void信号”,通知反应任务

// 反应任务:等待事件
eventPromise.get_future().wait(); // 阻塞至信号触发

优势

  1. 无多余依赖:无需互斥锁、无需 flag,代码极简;
  2. 无通知丢失:无论检测任务何时调用 set_value(),反应任务后续调用 wait() 都会立即返回;
  3. 无虚假唤醒:future 的 wait 仅在 set_value() 触发后返回,无任何无意义唤醒;
  4. 真正阻塞:反应任务 wait 时完全释放 CPU,无轮询开销,低功耗、低上下文切换;

缺点

  • 堆内存开销:promise/future 的 “共享状态”是堆上动态分配的,存在少量内存分配 / 释放开销;
  • 一次性限制:一个 promise 只能调用一次 set_value(),无法重复用于多次事件通信(这也是 “一次性事件通信” 的场景匹配项)。

扩展:多反应任务的一次性通知

若需一个检测任务通知多个反应任务,只需将 std::future<void> 转为 std::shared_future<void>(共享状态可被多个线程引用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
std::promise<void> eventPromise;
void react(); // 反应任务逻辑

void detect() {
// 生成可共享的future
auto sharedFuture = eventPromise.get_future().share();

// 创建多个反应线程
std::vector<std::thread> reactThreads;
for (int i = 0; i < 5; ++i) {
// 按值捕获sharedFuture(每个线程持有独立副本)
reactThreads.emplace_back([sharedFuture] {
sharedFuture.wait(); // 等待事件
react();
});
}

// 触发事件:所有反应线程同时解除阻塞
eventPromise.set_value();

// 等待所有线程完成
for (auto& t : reactThreads) {
t.join();
}
}

潜在风险:异常导致的线程永久挂起

若用 RAII(如 ThreadRAII)管理反应线程,需注意:若检测任务在调用 set_value() 前抛出异常,promise 未被触发,反应线程的 wait() 会永久阻塞—— 最终导致 RAII 对象析构时调用 join() 也永久挂起。

解决思路(参考ThreadRAII + Thread Suspension = Trouble?):

  1. 给 promise 增加 “取消机制”(比如结合 std::exception_ptr 传递异常);
  2. 用超时 wait 替代无限 wait(future.wait_for());
  3. 捕获检测任务的异常,主动触发 promise 的 set_exception()。

总结

  1. 条件变量适合 “重复事件 + 共享数据保护”,但一次性通信中存在多余互斥锁、通知丢失、虚假唤醒问题;
  2. 共享 flag 无锁但轮询耗资源,仅适合极短时间等待的场景;
  3. 条件变量 + flag 能工作,但设计不优雅、逻辑割裂;
  4. void promise/future 是一次性事件通信的最优解:无锁、无虚假唤醒、真正阻塞、代码极简,仅需接受 “堆内存开销 + 一次性通信” 的限制。