为什么先讲这两个
最近开始读《C++并发编程实战》。前四章讲了不少东西,但真正绕不开的基础模型就两个:线程创建之后怎么收尾(join/detach),以及线程的结果怎么拿回来(future/promise)。书里的完整例子偏复杂,我把概念剥到最简,再从随书源码里挑了几个简洁的实例补上,用最小代码把这两个模型讲清楚。
一、join 和 detach:线程的两种归宿
一个 std::thread 对象创建出来后,只有两条路:join(等他干完) 或 detach(放他单飞)。
而且必须二选一——两条路都不走,std::thread 析构时直接调用 std::terminate(),程序当场崩溃。这不是警告,是硬性规定。
join:阻塞等待
1 |
|
join() 让主线程挂起,直到目标线程执行完毕。输出顺序是确定的:worker 打印在前,主线程收尾在后。
适用场景:要用线程的计算结果、退出前必须确认线程收拾干净。
detach:脱离托管
1 | int main() { |
detach() 把线程交给系统托管,std::thread 对象变成空壳。适用场景:日志线程、后台监控这种”发了就不用管”的任务。
书里的一个例子把 detach 的正确姿势演示得很清楚——文档编辑器每”新建文档”就开一个线程(listing_2.4,简化后):
1 | void edit_document(std::string const& filename) { |
两个细节值得注意:文件名是按值传进去的(新线程拿的是拷贝,不是引用);主线程不需要知道文档什么时候关——这正是 detach 的典型画像:任务独立、结果不需要回收。
两个必须知道的坑
坑 1:detach 后访问局部变量 = 悬垂引用。
1 | void bad() { |
函数一返回 x 就没了,后台线程手里捏着的是一块已释放的栈内存。所以 detach 的线程只能按值捕获,或者访问全局/堆上的对象。
坑 2:detach 之后不能再 join。
1 | std::thread t(worker); |
joinable() 返回 false 的情况:空线程、已 join 过、已 detach 过、被 move 走过。
“忘 join 就崩”的解药:RAII 包一层
既然”既不 join 也不 detach”等于自杀,书里给了一个经典方案:把线程包进 RAII 类,析构时自动 join(listing_2.6):
1 | class scoped_thread { |
用法和普通 std::thread 一样,但函数无论如何退出(正常返回也好、抛异常也好),析构函数都会把 join 补上。这个思路后来被标准收编,就是 C++20 的 std::jthread——能直接用的项目就别自己造轮子了。
一张表对比
| join | detach | |
|---|---|---|
| 主线程行为 | 阻塞等待 | 立即继续 |
| 能否感知线程结束 | 能 | 不能 |
| 典型场景 | 要结果、要顺序 | 后台任务、守护线程 |
| 最大风险 | 忘 join,析构即崩 | 悬垂引用、main 退出杀线程 |
最后一个细节值得单独说:main 返回时进程退出,detached 线程会被直接杀掉,可能日志写到一半就没了。所以实际工程里的”后台线程”通常也不是真撒手——退出前用条件变量之类的机制通知它收尾,只是不用 join 干等。
二、future 和 promise:线程结果的传送带
std::thread 本身不能返回值。传统做法是:共享变量 + 互斥锁 + join,三件套配下来很啰嗦。future/promise 是标准库给的一条”传送带”,专门解决线程把结果送回来的问题。
最省事的用法:std::async + std::future
1 |
|
把 future 理解成取货单:std::async 下单发货,f.get() 取货。货没好就等着,货好了直接拿走。线程创建、结果传递、异常传递全部自动处理。注意 get() 只能调一次——取货单撕掉就没了。
手动版:std::promise + std::future
如果想自己控制”什么时候、在哪个线程里放结果”,就用 promise:
1 |
|
记住三个要点就够了:
promise是写端(放结果),future是读端(取结果),一对孪生,通过get_future()建立连接。set_value()一放,get()就放行——天然带同步,不用自己写互斥锁和条件变量。这比”共享变量 + mutex + join”干净得多。- 异常也能传:工作线程里
set_exception(),主线程get()时异常被重新抛出,错误处理的路径和正常返回值统一了。
promise 的真实用武之地:数据自己上门时才填结果
什么时候 promise 比 async 合适?书里的网络连接处理循环是个标准答案(listing_4.10,简化后):
1 | void process_connections(connection_set& connections) { |
这里的模式是:发起请求时先拿到一个 promise(和对应的 future),结果要等网络数据回来那一刻才产生。产生结果的线程和时机由事件决定,而不是由”某个函数跑完”决定——async 表达不了这个,promise 可以。消息队列、异步 IO 回调、RPC 框架里全是这个模式的影子。
怎么选
| 场景 | 选择 |
|---|---|
| 跑个任务拿返回值 | std::async,别犹豫 |
| 结果的产生时机/线程要自己控制(比如回调里填结果) | std::promise |
| 任务和线程解耦(先打包任务,后决定在哪条线程跑) | std::packaged_task |
| 多个线程等同一个结果 | std::shared_future(可拷贝、可多次 get) |
三、这本书还讲了什么:一张延伸地图
两个基础模型够日常用了,但这本书的覆盖面远不止这些。我把其余章节的核心内容扫了一遍,列出这张地图——知道这些主题存在、各自解决什么问题,等真遇到时再来查,比现在就硬记细节划算得多。
异步任务三件套对比:async / packaged_task / promise
三者都能产生 future,区别在于”谁来跑、谁来填”:
std::async |
std::packaged_task |
std::promise |
|
|---|---|---|---|
| 本质 | 一行启动异步任务 | 把函数打包成”可搬运的任务” | 手工填结果的写端 |
| 谁创建线程 | 标准库 | 自己 | 自己 |
| 结果何时产生 | 函数返回时 | 任务被调用时 | 任意时机(如事件回调) |
| 典型场景 | 默认选择 | 任务队列、线程池 | 异步 IO、消息循环 |
三者是同一个”传送带”机制的三种封装层级:async 最省心,packaged_task 把任务和线程解耦,promise 最底层也最自由。
其余章节一句话索引
| 主题 | 解决什么 | 代表案例 |
|---|---|---|
| 互斥与锁(第3章) | 共享数据不被写花:lock_guard 守作用域,std::lock 一次锁多把防死锁,锁分层(hierarchical_mutex)从制度上杜绝环路 |
listing_3.1、3.7 |
| 条件变量(第4章) | 生产者-消费者:消费者 wait 挂起不占 CPU,生产者 notify_one 唤醒 |
listing_4.1 |
| 读写锁 | 读多写少场景:shared_mutex 允许多个读者并发 |
第3章后段 |
| future 带超时 | wait_for / wait_until:等结果但不死等,服务器必备 |
第4章后段 |
| 原子操作与内存模型(第5章) | 不加锁的共享:std::atomic + memory_order 控制指令重排可见性,全书最烧脑的一章 |
listing_5.4 |
| 并发容器(第6、7章) | 线程安全队列(锁版容易,无锁版极难),无锁数据结构是专家领域 | listing_6.2 |
| 并行算法设计(第8章) | 把排序、累加这类算法切开分给多线程,核心是划分与负载均衡 | 并行快排 |
| 线程池(第9章) | 固定数量线程 + 任务队列,hardware_concurrency() 定线程数,避免线程爆炸 |
listing_9.1 |
| 并发测试(第11章) | 并发 bug 怎么复现:压力测试、时序注入 | — |
我的取舍:第 3、4 章的互斥和条件变量是日常刚需,值得单独练;第 5 章内存模型和第 7 章无锁数据结构属于”知道存在即可”,真要用到现场翻书或问 LLM;线程池不必自己写,知道原理、用成熟实现就好。
小结
1 | 线程的两个基础模型 |
文中标注 listing 编号的例子来自《C++并发编程实战》随书源码,我做了简化和中文化注释,语义不变。
这两个模型是后面所有并发设施的底座:线程池、并行算法、std::packaged_task,拆开看都是这两套机制的组合。先把最小例子跑熟,再看书里复杂的例子就顺了。