`std::jthread` 会自动 join,为什么程序仍可能关不掉?
我们把一个后台工作线程从 std::thread 改成 std::jthread,删除了手写的 join(),代码看起来立刻安全了:
1 | std::jthread worker([] { |
但程序退出时仍然卡住。原因并不神秘:std::jthread 的析构函数确实会请求停止并等待线程结束,可线程函数既没有观察停止请求,wait_for_task() 也无法被请求唤醒。RAII 管住了线程句柄的生命周期,却不会替业务代码发明退出路径。
C++20 增加的 std::jthread、停止令牌、latch、barrier、semaphore、原子等待和同步输出,价值不在于“语法更新”,而在于让取消、汇合、限流和通知这些并发意图拥有更直接的表达。不过每个工具都有明确边界,用错之后仍然会死锁、丢任务或产生数据竞争。
本文从一个可取消的后台任务队列出发,回答三个问题:线程拥有者怎样保证退出?阻塞等待怎样响应停止?一次性汇合、阶段同步、资源限流和状态通知又该分别选什么工具?
1. 我们遇到了什么问题?
1.1 std::thread 的生命周期容易漏掉异常路径
下面的函数看似会在末尾等待线程:
1 | // 存在隐患的写法 |
若栈展开时 thread 仍然 joinable,它的析构函数会调用 std::terminate。为覆盖所有出口,我们要么写额外的作用域守卫,要么使用把等待行为纳入析构的线程类型。
1.2 自动 join 仍可能无限等待
把类型替换为 std::jthread 只解决一半:
1 | // 仍然存在退出隐患 |
jthread 析构时会请求停止并 join,但这个线程函数没有接收 std::stop_token,而且 blocking_receive() 可能永远不返回。join 因此也永远无法完成。
这揭示了并发资源管理中的两层责任:
1 | 所有者负责:请求停止 + 等待线程结束 |
std::jthread 让第一层更可靠;第二层仍然必须由程序设计。
2. jthread 与 stop_token 的核心原理是什么?
2.1 停止请求是一种共享的一次性状态
停止协议由三个角色组成:
| 组件 | 角色 | 典型操作 |
|---|---|---|
std::stop_source |
发出停止请求 | request_stop() |
std::stop_token |
观察共享停止状态 | stop_requested() |
std::stop_callback |
在首次停止请求发生时响应 | 注册一个无参数回调 |
它们可以共享同一个停止状态:
1 | stop_source -- request_stop() --> shared stop state |
停止状态是单向的:一旦请求停止,就不能撤销或复位。后续再次调用 request_stop() 不会开启“第二轮停止”。需要新一轮生命周期时,应创建新的停止状态。
这叫协作式停止,而不是强制杀线程。它允许工作线程在一致性边界释放锁、提交部分结果或回滚操作,避免在任意指令处终止造成资源和不变量损坏。
2.2 jthread 析构做了什么?
当 std::jthread 仍然 joinable 时,它的析构逻辑可以直观理解为:
1 | request_stop(); |
实际行为由标准库定义,不能通过手动调用析构来使用。关键是先发请求、再等待。如果线程函数把 std::stop_token 作为第一个参数,jthread 构造时会把自己的 token 传给它:
1 | std::jthread thread([](std::stop_token token) { |
这里的 do_one_bounded_step() 必须能在可接受时间内返回。若它执行永久阻塞的系统调用,仅在循环开头检查 token 仍然不够。
2.3 停止请求本身不会唤醒任意阻塞 API
stop_token 不是操作系统级中断。普通 std::condition_variable::wait()、网络读取、文件读取和第三方库调用不会因为 token 变为“已停止”就自动返回。
解决办法取决于阻塞源:
- 使用支持
stop_token的等待 API; - 注册
stop_callback,在回调中触发该 API 支持的取消或唤醒操作; - 使用有界超时,周期性检查停止状态;
- 关闭套接字、管道或队列,让阻塞操作按其约定返回;
- 若第三方 API 完全不可取消,把它隔离到具有明确进程级生命周期的边界。
停止回调可能在发出请求的线程中同步执行,也可能在注册竞争的另一侧执行。回调应短小、线程安全,不能假定固定执行线程,也不应抛出异常。
3. 为什么可取消等待要使用 condition_variable_any?
C++20 为 std::condition_variable_any 增加了接受 std::stop_token 和谓词的等待重载:
1 | const bool predicate_satisfied = |
等待会在以下两类事件后重新评估:条件变量收到通知,或者停止状态收到请求。返回值表示谓词最终是否成立。
这仍然没有取消条件变量的基本规则:
- 共享条件必须由同一把锁保护;
- 等待必须使用谓词,处理虚假唤醒和通知竞争;
- 被唤醒后要根据业务语义决定处理任务还是退出;
- 不能在持锁状态执行耗时任务,否则生产者无法入队。
普通 std::condition_variable 没有对应的 stop-token 重载。若坚持使用它,就需要自己把“停止”纳入谓词并在请求停止时显式 notify_all(),还要仔细处理停止标志、通知和对象生命周期。
4. 如何写出一个能可靠退出的最小工作队列?
下面实现一个单工作线程队列。它采用明确的关闭语义:停止请求到达后,不再接受新任务,但会排空已经进入队列的任务,然后退出。
4.1 运行条件
- 语言标准:C++20;
- 所需组件:
std::jthread、std::stop_token、std::condition_variable_any; - 第三方依赖:无;
- 输出顺序:单工作线程按 FIFO 处理,因此示例输出确定;
- 实现支持:需要标准库真正实现相应 C++20 并发组件,仅指定
-std=c++20不足以补齐旧标准库。
将代码保存为 cancellable_worker.cpp:
1 |
|
在支持这些组件的标准库上编译:
1 | clang++ -std=c++20 -O2 -Wall -Wextra -pthread \ |
预期输出:
1 | 2 squared is 4 |
当前笔记环境中的 Apple Clang 所配套标准库缺少 std::jthread,因此这里不能声称已经在本机完成真实编译。应在目标编译器与标准库组合上运行上述命令;若失败,先检查标准库版本与特性支持,而不是只把 -std=c++20 改为 -std=c++23。
5. 这段代码如何完成停止、唤醒和收尾?
5.1 数据只在锁保护下进入和离开队列
tasks_ 被生产者和工作线程共享,所以 push_back、front 和 pop_front 都在 mutex_ 保护下执行。任务取出后立即释放锁,再执行计算;否则耗时任务会阻止生产者继续提交。
notify_one() 放在解锁后不是正确性的硬性要求,但能避免刚唤醒的线程立即在同一把锁上阻塞。真正防止“先通知、后等待”丢失条件的是带锁修改状态和谓词等待,而不是通知调用的相对运气。
5.2 收到停止请求后为什么还能处理剩余任务?
停止请求到达时,stop-aware wait 会被唤醒。若队列非空,谓词仍然为真,wait 返回 true,工作线程取出一个任务。它再次进入循环时,只要队列仍非空,就继续处理;当队列为空且停止已经请求,wait 返回 false,线程退出。
因此这里实现的是 drain(排空)语义,而不是立即丢弃。若业务要求立即停止,应在取任务前检查 token.stop_requested() 并决定如何退回、持久化或报告未处理任务。不能只改一行 return,却不定义任务所有权。
5.3 成员声明顺序为什么关系到线程安全?
C++ 按成员声明的逆序析构。thread_ 最后声明,所以它最先析构:请求停止并 join 时,condition_、mutex_ 和 tasks_ 仍然活着。
如果把 thread_ 声明在最前面,它会最后析构;队列和同步对象可能已经销毁,而工作线程还在访问它们,造成 use-after-destruction。线程类捕获 this 时,成员销毁顺序是生命周期设计的一部分,不是排版偏好。
5.4 析构为何能唤醒正在等待的线程?
jthread 的停止状态与 condition_variable_any 的 stop-token wait 相连。析构发出请求后,等待重载会响应停止并重新检查谓词,因此不需要额外手写 notify_all() 来唤醒纯停止路径。
但如果 std::cout 被替换为不可取消的永久阻塞调用,线程仍可能卡在那里。停止协议只能覆盖参与该协议的取消点。
6. 一次性汇合、阶段同步和资源限流该怎么选?
6.1 std::latch:只打开一次的倒计时门
std::latch 适合等待固定数量的工作完成:
1 | std::latch ready(worker_count); |
计数只能减少,归零后保持打开,不能复位。它适合启动、一次性初始化和测试中的统一起点。
若工作线程可能抛异常,必须确保异常被线程内部捕获并传播,同时不能让漏掉的 count_down() 把等待方永久挂住。并发示例中“异常直接逃出线程函数”会触发 std::terminate。
6.2 std::barrier:所有参与者反复进入下一阶段
迭代仿真常需要每轮同步:
1 | 各线程计算 step N |
std::barrier 可以重复使用,适合阶段式算法。参与线程若提前退出,必须按协议调用 arrive_and_drop() 或让其他参与者也停止;否则剩余线程会永远等待一个不会到达的参与者。
barrier 建立阶段间同步,不会自动消除同一阶段内对共享对象的冲突。两个线程在到达 barrier 前并发写同一非原子对象,数据竞争已经发生。
6.3 std::counting_semaphore:管理许可数量
当资源只允许最多三个并发使用者时,可以用信号量表达许可:
1 | std::counting_semaphore<3> slots(3); |
它适合连接数、并发 I/O 数和有界缓冲区槽位,不直接保护复杂对象不变量。示例用 try/catch 保证异常时归还许可;真实代码可以写一个很小的 RAII permit guard,避免每条出口重复释放,但没有必要为简单、无异常的局部路径引入复杂框架。
std::binary_semaphore 只有 0/1 许可,可表达一次通知,但它没有携带业务数据。数据的发布与生命周期仍需单独设计。
6.4 三者的关键差异
| 需求 | 工具 | 能否重复使用 | 谁改变状态 |
|---|---|---|---|
| 等 N 项工作全部完成一次 | latch |
否 | 完成者递减计数 |
| N 个参与者反复阶段同步 | barrier |
是 | 每个参与者到达 |
| 最多允许 K 个使用者进入 | counting_semaphore |
是 | 使用者获取/归还许可 |
如果需求是保护“map 更新必须同时维护索引和计数”这类不变量,仍应使用 mutex,而不是从表格里任选一个新组件。
7. atomic::wait/notify 解决了什么问题?
等待一个原子状态时,最直接的代码会持续占用 CPU:
1 | // 存在性能隐患:等待较久时持续忙等 |
C++20 允许线程在原子值仍等于旧值时阻塞:
1 | std::atomic<bool> ready{false}; |
notify_one() 本身不发布 payload;真正建立可见性的是 release store 与 acquire load。循环仍有意义,因为业务条件必须由值判断,而且值可能发生 ABA:从旧值变到新值又变回旧值时,等待者不一定观察到中间状态。
atomic::wait 适合单个状态字、序号和轻量事件。多个条件、复杂队列或需要在同一临界区检查多项共享状态时,mutex 加条件变量通常更清晰。
8. 其他现代并发组件有哪些边界?
8.1 std::atomic_ref:给现有对象建立原子视图
1 | alignas(std::atomic_ref<int>::required_alignment) int counter = 0; |
它适合无法把字段类型改成 std::atomic<T> 的既有布局、共享内存或 C 接口。被引用对象必须满足对齐和生命周期要求;在 atomic_ref 存活期间,并发访问应遵守它要求的原子访问规则。它不会让包含该字段的整个对象自动线程安全。
能直接设计为 std::atomic<T> 时,成员类型更能表达长期约束,通常更易维护。
8.2 std::shared_mutex:读锁不等于免费并发
std::shared_mutex 是 C++17 组件,适用于读操作明显多于写操作且读临界区足够长的场景。实现的公平策略、读写竞争和平台开销会影响结果;大量读者还可能延迟写者。
如果临界区只包含几条指令,普通 std::mutex 可能更快、更稳定。选择前应使用真实读写比例 Benchmark,而不是看到“读多写少”四个字就替换锁。
8.3 std::osyncstream:保证一段输出不互相穿插
1 | std::osyncstream(std::cout) |
临时 osyncstream 在发射缓冲内容时与同一底层流的其他同步输出协调,使一条逻辑消息不被拆开。它适合示例和调试输出,不等同于带队列、批量写入、丢弃策略和日志轮转的生产异步日志系统。
9. 版本和标准库支持应该怎样判断?
本篇文件名沿用“C++20/23”,但版本归属需要说准确:
| 组件 | 标准版本 |
|---|---|
shared_mutex |
C++17 |
jthread、stop token/callback |
C++20 |
latch、barrier、semaphore |
C++20 |
atomic::wait/notify、atomic_ref |
C++20 |
osyncstream |
C++20 |
这里讨论的同步工具主体是 C++20,并不是 C++23 才出现。C++23 可以与它们组合使用,但把语言模式切到 C++23 不会自动升级编译器配套的 libstdc++、libc++ 或平台线程运行时。
项目中可以检查标准库提供的特性测试宏,例如:
1 |
还应在 CI 中使用项目真实编译器与标准库组合编译最小示例。不同操作系统和发行版的支持进度不同,具体可用性需要结合目标版本验证。
10. 常见误区
10.1 误区:jthread 能强制杀死工作线程
它只发出协作式停止请求并 join。线程若不检查 token,或卡在不可取消操作中,析构仍可能永久等待。正确做法是为每个阻塞点设计唤醒或取消路径。
10.2 误区:看到停止请求就可以直接丢掉队列
退出策略必须定义任务所有权:排空、退回生产者、持久化、标记失败,还是允许丢弃。stop_token 只传递请求,不替业务选择语义。
10.3 误区:barrier 后访问数据就一定安全
barrier 只分隔阶段。阶段内部若有无同步的冲突访问,数据竞争不会等到 barrier 才发生。
10.4 误区:semaphore 是更快的 mutex
mutex 表示临界区所有权并保护不变量;semaphore 表示许可数量,释放者甚至不一定是获取者。语义不同,不能只按性能替换。
10.5 误区:atomic::wait 让内存序不再重要
等待和通知负责阻塞机制,数据发布仍依赖正确的原子修改顺序。没有 release/acquire 或其他有效同步关系,非原子 payload 的可见性不能凭 notify_one 推断。
10.6 误区:把普通字段包一层 atomic_ref,整个对象就线程安全
atomic_ref 只作用于被引用对象的原子操作。相关字段之间的不变量、对象生命周期以及其他访问路径仍需单独同步。
11. 什么时候应该使用这些工具?
少量长期线程、后台 worker、明确的阶段式计算和受限资源访问,很适合直接使用现代标准组件。它们能把生命周期和同步意图写进类型与 API,减少手写协议的状态组合。
如果程序包含大量短任务、递归分治、动态负载均衡或复杂 pipeline,线程池、TBB 等任务调度系统通常更合适。标准同步组件负责构建正确边界,任务框架负责复用线程、调度和 work stealing;二者不是互斥关系。
对于一次简单同步,也不要为了“现代 C++”同时叠加 stop token、semaphore、barrier 和多种原子。选择最直接表达当前不变量的一个工具,通常更安全。
12. 总结
std::jthread 会自动 join,程序却仍可能关不掉,是因为线程句柄的 RAII 与业务操作的可取消性是两件事。
jthread负责请求停止和等待结束,工作线程负责观察请求并从所有阻塞点退出。condition_variable_any的 stop-token wait 能把队列条件与停止请求放进同一个可靠等待协议。latch表示一次性完成,barrier表示可重复阶段,semaphore表示有限许可,三者不能按“都能阻塞”混用。atomic::wait/notify减少长时间忙等,但数据可见性仍由原子值及其内存序建立。- 大多数本文组件属于 C++20;语言选项和标准库实现版本必须同时满足要求。
最实用的建议是:每创建一个长期线程,就把它的所有权、停止发起者、每个阻塞点的唤醒方式,以及未完成任务的处置策略写在一起。如果这四项说不清楚,换成 jthread 也只是把卡死从 join() 调用处移动到了析构函数。