《C++-Concurrency-in-Action》第四章
碎碎念:接下来的一两周就是考试面试考试面试了,前段时间好不容易说服自己专心搞量化,wlb才是该追求的东西,但是京东的一个面试邀约直接打乱了计划。还是想去互联网刷一段实习,去去魅,毕竟如果秋招去量化的话,社招大概率也去不了互联网了。
努努力准备一下吧,算法,八股,项目,今天下午再看一下dp是怎么回事,重点刷一下,我还是太摆烂了,自己定下的目标很少能完成。
————————————————————————————————————
本书的第四章是同步条件,重点讲的是condition_varible条件变量和future,这里重点讲一下future,并以线程池为例子,第四章后半部分的东西过于小众,我们就略过不讲。
cv这个我们常用的条件变量,会存在一个伪唤醒的情况,即cv.wait()的条件明明没有真正准备好,但就被唤醒了(这让我想到了cas_weak)
伪唤醒 指的是:一个正在条件变量上等待的线程,在没有其他线程显式地通知(signal 或 broadcast)它的情况下,自己从等待状态(如 pthread_cond_wait 或 std::condition_variable::wait)中恢复执行了。
换句话说,线程被“莫名其妙”地唤醒了,但线程等待的那个“条件”实际上并未变为真。
为什么会出现伪唤醒?
伪唤醒听起来像是一个 Bug,但实际上,在某些操作系统和线程库的实现中,这是被允许甚至是有意为之的行为。主要原因有:
- 性能和实现的简化:在支持多处理器(SMP)的系统上,在条件变量的实现中,要完全避免竞争条件和保证绝对的“不唤醒”,可能需要非常复杂的、低效的代码。为了性能和代码简洁性,标准允许在某些边缘情况下发生伪唤醒。
- 信号中断:在某些系统上,如果一个线程在等待时收到了一个信号(例如 UNIX 信号),等待函数可能会提前返回。虽然这通常会被归类为错误返回,但在某些实现中也可能被视为一种唤醒。
关键点:POSIX 线程标准和 C++ 标准都明确允许伪唤醒的发生。因此,编写代码时绝不能假设“线程被唤醒”就等于“条件已满足”。
带来的问题
如果代码没有正确处理伪唤醒,会导致严重的并发 Bug:
- 数据竞争:线程在条件不满足的情况下访问了受保护的数据,可能导致数据损坏或读取到无效状态。
- 逻辑错误:程序基于一个错误的前提(认为条件已满足)继续执行,产生不可预知的结果。
- 程序崩溃:例如,线程认为队列非空,结果去取元素时发现是空的,导致未定义行为。
如何避免伪唤醒?—— 正确的使用模式
解决方案非常简单和固定:永远不要在条件变量上使用简单的 if 语句来检查条件,而必须使用 while 循环。
这是因为 while 循环可以在线程被唤醒后,再次检查条件是否真正满足。如果发生了伪唤醒,条件依然不成立,线程会自动再次进入等待状态。
错误示例(使用 if,易受伪唤醒影响)
1 | // 错误的写法! |
正确示例(使用 while,防止伪唤醒)
这是多线程编程中的标准范式。
cpp
1 | std::unique_lock<std::mutex> lock(mutex); |
流程解释:
- 线程获取互斥锁,检查
queue.empty()是否为true。 - 如果是,则调用
cond_var.wait(lock)。这个调用会原子地释放互斥锁,并将线程挂起。 - 当有其他线程向条件变量发出通知,或者发生伪唤醒时,线程会从
wait中返回。在返回之前,它会重新获取互斥锁。 - 由于我们使用
while循环,线程会立刻再次检查queue.empty()。- 如果是真通知:另一个线程已经将数据放入队列,
queue.empty()为false,循环结束,线程安全地处理数据。 - 如果是伪唤醒:
queue.empty()仍然为true,循环继续,线程再次调用wait进入等待状态。
- 如果是真通知:另一个线程已经将数据放入队列,
C++ 的进一步简化:带谓词的 wait
为了简化代码,C++ 标准库提供了可以接受一个“谓词”(一个可调用对象,返回布尔值)的 wait 重载版本。这个版本在内部自动为我们实现了 while 循环。
上面的正确示例可以简写为:
1 | std::unique_lock<std::mutex> lock(mutex); |
这个 wait(lock, predicate) 的内部逻辑等价于:
1 | while (!predicate()) { |
总结
- 伪唤醒是什么:没有收到通知就自行唤醒的线程。
- 为什么存在:出于性能和实现复杂度的考虑,标准允许其发生。
- 核心对策:永远使用
while循环来检查条件,而不是if语句。 - 最佳实践:在 C++ 中,优先使用
condition_variable::wait带谓词的版本,它更简洁、更安全。
记住这个黄金法则:条件变量的等待必须在一个循环中。 这是编写正确、健壮的多线程代码的基石之一
接下来是future和async
std::future<>这里的模板函数签名表示异步进行的任务的返回值类型
1 | 所以其实我们可以用auto自动推导(太好用了家人们) |
std::future、std::shared_future 和 std::async ——
是 C++11 引入的异步任务与结果通信机制的核心。
🧩 一、std::future 是什么?
🌱 定义
std::future<T> 是一个未来结果的占位符,
用来在异步任务和主线程之间传递结果。
可以理解为:我现在没有结果,但我将来能拿到。
🧠 使用场景
- 在一个线程中启动任务;
- 返回一个
future; - 主线程稍后通过
future.get()获取任务结果。
✅ 基本用法示例
1 |
|
解释:
std::async启动了一个异步任务(可能是新线程);- 返回一个
std::future<int>; - 调用
fut.get()会阻塞,直到compute()执行完毕; get()只能调用一次!
⚠️ 注意事项
future.get()调用一次后,结果被取走,不能再用;- 如果不调用
get()就销毁了 future,程序在某些实现下可能阻塞(等待任务完成)。
🌿 二、std::shared_future —— 可共享的 future
std::future 的结果只能取一次,这在某些情况下不方便。
例如多个线程都想读取同一个异步结果。
为了解决这个问题,C++11 提供了 std::shared_future。
✅ 基本用法
1 |
|
解释:
std::future<int>::share()→ 转换为std::shared_future<int>;- 多个线程都可以调用
sfut.get(); shared_future内部有引用计数;- 结果缓存下来,不会被“取走”。
⚙️ 区别总结
| 特性 | std::future |
std::shared_future |
|---|---|---|
| 获取结果方式 | get() 一次性取走 |
get() 可多次读取 |
| 拷贝 | 不可拷贝 | 可拷贝(引用计数) |
| 线程安全 | 只能单线程取结果 | 多线程可同时取结果 |
| 典型用途 | 单消费者 | 多消费者(广播结果) |
🚀 三、std::async —— 异步任务启动器
std::async 是最常用的异步执行函数。
它返回一个 std::future<T>,代表任务的最终结果。
✅ 基本语法
1 | std::future<T> std::async(std::launch policy, Callable&& f, Args&&... args); |
f:要执行的函数;args...:传递给函数的参数;policy:执行策略。
🔧 启动策略(policy)
| 策略 | 含义 |
|---|---|
std::launch::async |
立即启动一个新线程执行任务 |
std::launch::deferred |
延迟执行,直到调用 .get() 或 .wait() 时才执行 |
| `std::launch::async | std::launch::deferred` |
🌊 示例 1:结合 shared_future
1 | std::shared_future<int> sfut = std::async(std::launch::async, add, 10, 20).share(); |
多个线程都可以安全地获取结果。
🧠 四、其它实用操作
future.wait()
只等待结果可用,不取结果。
1 | fut.wait(); |
⚠️ 五、常见坑点
future.get()只能调用一次- 调用后结果被“取走”,future 失效;
- 如果多线程都需要结果,请使用
shared_future。
async默认策略可能不一定开线程- 如果没有明确指定
std::launch::async,可能会延迟执行; - 要强制开线程,请指定策略。
- 如果没有明确指定
异常会自动传播
- 如果异步任务抛出异常,调用
.get()时会重新抛出; - 要在
.get()外层捕获。
- 如果异步任务抛出异常,调用
1 | auto fut = std::async([]() { throw std::runtime_error("Error"); }); |
🧭 六、总结对比表
| 名称 | 功能 | 可共享 | 多线程安全 | 常用场景 |
|---|---|---|---|---|
std::future<T> |
异步结果占位符 | ❌ | ❌(单线程取结果) | 单一消费者异步任务 |
std::shared_future<T> |
可共享结果 | ✅ | ✅ | 多线程共享结果 |
std::async() |
启动异步任务并返回 future |
- | - | 快速异步执行任务 |
✅ 一句话记忆:
std::async启动异步任务,std::future等待并取一次结果,std::shared_future可以让多个线程共享这个结果。
1 | #include <type_traits> |
1 | #include <iostream> |
在这里我们认识了invoke,function,packaged_task这三个相似但完全不同的东西
三者的关系和区别
层次关系
1 | std::invoke (调用工具) |
详细对比
| 特性 | std::function |
std::packaged_task |
std::invoke |
|---|---|---|---|
| 用途 | 通用函数包装 | 异步任务包装 | 统一调用机制 |
| 存储 | ✅ 可以存储可调用对象 | ✅ 可以存储可调用对象 | ❌ 只是调用工具 |
| 异步 | ❌ 同步调用 | ✅ 支持异步执行 | ❌ 同步调用 |
| Future | ❌ 无future支持 | ✅ 关联future获取结果 | ❌ 无future支持 |
| 成员函数 | ❌ 不能直接包装 | ❌ 不能直接包装 | ✅ 支持成员函数 |
| 开销 | 中等 | 较大 | 零运行时开销 |
invoke的本质是将函数指针,成员函数等特殊类型的函数调用统一,提供一个统一的调用方法,但是是一次性的(可以在编译期去推断调用函数的返回值类型,invoke_result_t,就是上面线程池做的)
而function是一种可以多次调用的invoke,所以要起一个名字
packaged_task就是支持异步返回值的function。
packaged_task的内部本质是一个promise
std::promise:你手动提供结果
1 |
|
🔹特点:
promise不执行任何函数;- 你必须在某个地方**手动调用
set_value()**; - 适合线程之间传递结果或信号。
std::packaged_task:任务自动产生结果
1 |
|
显然promise更灵活,但packaged_task更方便。
1 | template<class F> |
对于packaged_task:
- 构造时 ❌ 没执行
- get_future() ❌ 没执行
- 调用 task() ✅ 开始执行
- get() 只是等待结果已经存在
这里我们引入一点线程池的原理,众所周知,线程池需要一个东西:类型擦除,即将无论什么参数,什么返回值的函数,都用统一的包装和统一的调用。
对于无返回值,有参数的函数,我们最先见到的往往是bind(函数名,(类的实例),参数1,参数2,Args…),如果参数暂时不可用,就用占位符。这样,函数被包装成为了function<void()>,无函数签名。
当然,lambda这个语法糖也可以做到,其值捕获可以代替函数参数
🧩 问题 1:
“正常情况下 packaged_task 的函数签名需要参数?
但是用 lambda 去初始化,捕获可以代替函数签名参数,是这个意思吗?”
✅ 正确
🧠 1️⃣ std::packaged_task 的签名
std::packaged_task<R(Args...)> 的模板参数 Args... 就是它要“接受的参数列表”。
例子:
1 | std::packaged_task<int(int,int)> task([](int a, int b){ |
这里:
task的类型是std::packaged_task<int(int,int)>- 它的
operator()(2,3)会把参数传递给 lambda。
🧩 2️⃣ 为什么在你的代码中写成了 std::packaged_task<return_type()>
你的代码里写的是:
1 | std::packaged_task<return_type()> task( |
👉 也就是说:
packaged_task的签名是 无参 的;- 但是这个 lambda 内部捕获了
f和所有的args...; - 当调用
(*task)()时,这个 lambda 内部执行:—— 实际上已经使用了原本的1
std::invoke(func, std::move(captured)...);
args...。
✅ 结论:
捕获确实“替代”了函数签名的参数。
因为参数值在创建任务时就被拷贝(或移动)进了 lambda 的闭包体。
所以:
- 函数签名可以是
(),因为调用时不再需要额外的参数; - 所有参数都在创建时绑定(类似于
std::bind的效果,但效率更高)。
📦 总结一句话:
在这个写法里,
lambda 捕获相当于“提前绑定参数”,
因此packaged_task的签名可以是()。
🧩 问题 2:
[task](){ (*task)(); }在按值捕获的时候拷贝了 shared_ptr,那他是被再次包装成了 function?
完全正确 ✅✅✅
这个过程其实就是“用一个小 lambda 包成 std::function<void()>”。
🧠 1️⃣ 捕获行为
[task] 是 按值捕获,
而 task 是一个 std::shared_ptr<std::packaged_task<return_type()>>。
按值捕获 shared_ptr,会发生:
拷贝 shared_ptr(引用计数 +1)
因此:
- 这个 lambda 内部有自己的 shared_ptr 副本;
- 即使外层的
task离开作用域销毁,队列里的 lambda 仍然持有一份 shared_ptr 引用,保证对象仍然存在。
🧱 2️⃣ lambda 被存进 std::function<void()>
tasks.emplace([task](){ (*task)(); });
这里假设 tasks 是:
1 | std::queue<std::function<void()>> tasks; |
那么:
[task](){ (*task)(); }是一个无参、无返回值的 lambda;- 它可以隐式转换成
std::function<void()>; - 队列里存放的是
std::function<void()>类型; - 所以这个 lambda 被复制一份存入
std::function对象中。
⚙️ 3️⃣ 调用链
当 worker 线程执行任务时:
1 | std::function<void()> job = std::move(tasks.front()); |
发生的调用链:
1 | job() --> 调用 std::function<void()>::operator() |
🎯 一整条调用链串起来,未来结果最终出现在 future.get()。
🧠 小结:两段的关系图
1 | submit(f, args...) |
✅ 一句话总结
| 概念 | 含义 |
|---|---|
packaged_task<return_type()> |
无参数任务,但内部 lambda 捕获了所有实参(提前绑定) |
lambda 捕获 |
替代了函数签名参数,把参数“记住”在闭包里 |
[task](){ (*task)(); } |
用 shared_ptr 拷贝持有任务对象,保证生命周期 |
| 存入队列 | lambda 被再包装成 std::function<void()>,方便统一调度 |
而对于有返回值,有参数的函数,bind和lambda之外,我们就要再用packaged_task去包装,提前得到future(相当于返回值),这样,我们就将有返回值有参数的函数,类型抹除成为了function<void()>这个无参无返回值的包装器(实际是转移了参数和返回值)
同时这里用shared_ptr执行异步任务的操作,以及用lambda拷贝(值捕获)shared_ptr使其引用计数+1,防止提前销毁的策略在协程库的fire-and-forget里面也有体现。(有时间把协程库的东西也写在博客)


