0%

C++ 并发编程笔记:join/detach 与 future/promise 两个最基础的模型

为什么先讲这两个

最近开始读《C++并发编程实战》。前四章讲了不少东西,但真正绕不开的基础模型就两个:线程创建之后怎么收尾(join/detach),以及线程的结果怎么拿回来(future/promise)。书里的完整例子偏复杂,我把概念剥到最简,再从随书源码里挑了几个简洁的实例补上,用最小代码把这两个模型讲清楚。

一、join 和 detach:线程的两种归宿

一个 std::thread 对象创建出来后,只有两条路:join(等他干完) 或 detach(放他单飞)。

而且必须二选一——两条路都不走,std::thread 析构时直接调用 std::terminate(),程序当场崩溃。这不是警告,是硬性规定。

join:阻塞等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <thread>
#include <iostream>

void worker() {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "worker 干完了\n";
}

int main() {
std::thread t(worker);
std::cout << "主线程等待中...\n";
t.join(); // 卡在这里, 直到 worker 返回.
std::cout << "确认干完, 继续\n"; // 这行一定在 worker 之后打印.
}

join() 让主线程挂起,直到目标线程执行完毕。输出顺序是确定的:worker 打印在前,主线程收尾在后。

适用场景:要用线程的计算结果、退出前必须确认线程收拾干净。

detach:脱离托管

1
2
3
4
5
int main() {
std::thread t(worker);
t.detach(); // 从此 t 和线程再无关系, 无法 join、无法拿结果.
// main 立刻往下走, 线程在后台自生自灭.
}

detach() 把线程交给系统托管,std::thread 对象变成空壳。适用场景:日志线程、后台监控这种”发了就不用管”的任务。

书里的一个例子把 detach 的正确姿势演示得很清楚——文档编辑器每”新建文档”就开一个线程(listing_2.4,简化后):

1
2
3
4
5
6
7
8
9
10
11
12
13
void edit_document(std::string const& filename) {
open_document_and_display_gui(filename);
while (!done_editing()) {
user_command cmd = get_user_input();
if (cmd.type == open_new_document) {
std::string new_name = get_filename_from_user();
std::thread t(edit_document, new_name); // 按值传参, 避开悬垂引用.
t.detach(); // 新文档线程独立运行, 不用管.
} else {
process_user_input(cmd);
}
}
}

两个细节值得注意:文件名是按值传进去的(新线程拿的是拷贝,不是引用);主线程不需要知道文档什么时候关——这正是 detach 的典型画像:任务独立、结果不需要回收。

两个必须知道的坑

坑 1:detach 后访问局部变量 = 悬垂引用。

1
2
3
4
5
void bad() {
int x = 42;
std::thread t([&x] { std::cout << x; }); // 按引用捕获局部变量.
t.detach();
} // 函数返回, x 销毁, 后台线程还在读 -> 未定义行为.

函数一返回 x 就没了,后台线程手里捏着的是一块已释放的栈内存。所以 detach 的线程只能按值捕获,或者访问全局/堆上的对象。

坑 2:detach 之后不能再 join。

1
2
3
4
5
6
7
8
std::thread t(worker);
t.detach();
t.join(); // 崩溃! 线程已脱离, 不可 join.

// 正确姿势: 先检查.
if (t.joinable()) {
t.join();
}

joinable() 返回 false 的情况:空线程、已 join 过、已 detach 过、被 move 走过。

“忘 join 就崩”的解药:RAII 包一层

既然”既不 join 也不 detach”等于自杀,书里给了一个经典方案:把线程包进 RAII 类,析构时自动 join(listing_2.6):

1
2
3
4
5
6
7
8
9
10
11
class scoped_thread {
std::thread t;
public:
explicit scoped_thread(std::thread t_) : t(std::move(t_)) {
if (!t.joinable())
throw std::logic_error("No thread");
}
~scoped_thread() { t.join(); } // 出作用域自动 join.
scoped_thread(scoped_thread const&) = delete;
scoped_thread& operator=(scoped_thread const&) = delete;
};

用法和普通 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <future>
#include <iostream>

int slow_compute() {
std::this_thread::sleep_for(std::chrono::seconds(2));
return 42;
}

int main() {
// 后台开跑, 立刻拿回一张"取货单".
std::future<int> f = std::async(std::launch::async, slow_compute);

std::cout << "主线程继续干别的...\n";

// 要结果时取货, 没算完就在这里等.
std::cout << "结果: " << f.get() << "\n";
}

把 future 理解成取货单:std::async 下单发货,f.get() 取货。货没好就等着,货好了直接拿走。线程创建、结果传递、异常传递全部自动处理。注意 get() 只能调一次——取货单撕掉就没了。

手动版:std::promise + std::future

如果想自己控制”什么时候、在哪个线程里放结果”,就用 promise:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <future>
#include <iostream>
#include <thread>

void worker(std::promise<int> p) {
try {
std::this_thread::sleep_for(std::chrono::seconds(1));
p.set_value(42); // 把结果放上传送带.
} catch (...) {
p.set_exception(std::current_exception()); // 异常也能传.
}
}

int main() {
std::promise<int> p;
std::future<int> f = p.get_future(); // 先拿取货单, 再开工.

std::thread t(worker, std::move(p)); // promise 只能 move, 不能拷贝.

std::cout << "结果: " << f.get() << "\n"; // 阻塞直到 set_value.
t.join();
}

记住三个要点就够了:

  1. promise 是写端(放结果),future 是读端(取结果),一对孪生,通过 get_future() 建立连接。
  2. set_value() 一放,get() 就放行——天然带同步,不用自己写互斥锁和条件变量。这比”共享变量 + mutex + join”干净得多。
  3. 异常也能传:工作线程里 set_exception(),主线程 get() 时异常被重新抛出,错误处理的路径和正常返回值统一了。

promise 的真实用武之地:数据自己上门时才填结果

什么时候 promise 比 async 合适?书里的网络连接处理循环是个标准答案(listing_4.10,简化后):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void process_connections(connection_set& connections) {
while (!done(connections)) {
for (auto connection = connections.begin(); connection != connections.end(); ++connection) {
if (connection->has_incoming_data()) {
data_packet data = connection->incoming();
std::promise<payload_type>& p = connection->get_promise(data.id);
p.set_value(data.payload); // 数据到了才填结果, 等待方立刻被唤醒.
}
if (connection->has_outgoing_data()) {
outgoing_packet data = connection->top_of_outgoing_queue();
connection->send(data.payload);
data.promise.set_value(true); // 通知发送方: 发完了.
}
}
}
}

这里的模式是:发起请求时先拿到一个 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
2
3
4
5
6
7
线程的两个基础模型
├── 收尾: join / detach 二选一, 不选就 terminate
│ ├── join -> 等结果、保顺序, 风险是忘 join 即崩(RAII/std::jthread 解药)
│ └── detach -> 后台任务, 风险是悬垂引用 + main 退出强杀(按值传参避险)
└── 传值: future / promise 传送带
├── std::async -> 一行代码跑任务拿结果, 默认选它
└── std::promise -> 事件驱动填结果(网络包到了才 set_value), 异常也能传

文中标注 listing 编号的例子来自《C++并发编程实战》随书源码,我做了简化和中文化注释,语义不变。

这两个模型是后面所有并发设施的底座:线程池、并行算法、std::packaged_task,拆开看都是这两套机制的组合。先把最小例子跑熟,再看书里复杂的例子就顺了。