`std::jthread` 会自动 join,为什么程序仍可能关不掉?

我们把一个后台工作线程从 std::thread 改成 std::jthread,删除了手写的 join(),代码看起来立刻安全了:

1
2
3
4
5
6
std::jthread worker([] {
for (;;) {
wait_for_task();
process_one_task();
}
});

但程序退出时仍然卡住。原因并不神秘:std::jthread 的析构函数确实会请求停止并等待线程结束,可线程函数既没有观察停止请求,wait_for_task() 也无法被请求唤醒。RAII 管住了线程句柄的生命周期,却不会替业务代码发明退出路径。

C++20 增加的 std::jthread、停止令牌、latch、barrier、semaphore、原子等待和同步输出,价值不在于“语法更新”,而在于让取消、汇合、限流和通知这些并发意图拥有更直接的表达。不过每个工具都有明确边界,用错之后仍然会死锁、丢任务或产生数据竞争。

本文从一个可取消的后台任务队列出发,回答三个问题:线程拥有者怎样保证退出?阻塞等待怎样响应停止?一次性汇合、阶段同步、资源限流和状态通知又该分别选什么工具?

1. 我们遇到了什么问题?

1.1 std::thread 的生命周期容易漏掉异常路径

下面的函数看似会在末尾等待线程:

1
2
3
4
5
6
7
8
9
// 存在隐患的写法
void run() {
std::thread thread([] {
do_background_work();
});

do_foreground_work(); // 如果这里抛异常,下面的 join 不会执行
thread.join();
}

若栈展开时 thread 仍然 joinable,它的析构函数会调用 std::terminate。为覆盖所有出口,我们要么写额外的作用域守卫,要么使用把等待行为纳入析构的线程类型。

1.2 自动 join 仍可能无限等待

把类型替换为 std::jthread 只解决一半:

1
2
3
4
5
6
// 仍然存在退出隐患
std::jthread worker([] {
while (true) {
blocking_receive();
}
});

jthread 析构时会请求停止并 join,但这个线程函数没有接收 std::stop_token,而且 blocking_receive() 可能永远不返回。join 因此也永远无法完成。

这揭示了并发资源管理中的两层责任:

1
2
所有者负责:请求停止 + 等待线程结束
工作线程负责:观察请求 + 从阻塞点醒来 + 收尾后返回

std::jthread 让第一层更可靠;第二层仍然必须由程序设计。

2. jthreadstop_token 的核心原理是什么?

2.1 停止请求是一种共享的一次性状态

停止协议由三个角色组成:

组件 角色 典型操作
std::stop_source 发出停止请求 request_stop()
std::stop_token 观察共享停止状态 stop_requested()
std::stop_callback 在首次停止请求发生时响应 注册一个无参数回调

它们可以共享同一个停止状态:

1
2
3
4
stop_source -- request_stop() --> shared stop state
| |
v v
stop_token stop_callback

停止状态是单向的:一旦请求停止,就不能撤销或复位。后续再次调用 request_stop() 不会开启“第二轮停止”。需要新一轮生命周期时,应创建新的停止状态。

这叫协作式停止,而不是强制杀线程。它允许工作线程在一致性边界释放锁、提交部分结果或回滚操作,避免在任意指令处终止造成资源和不变量损坏。

2.2 jthread 析构做了什么?

std::jthread 仍然 joinable 时,它的析构逻辑可以直观理解为:

1
2
request_stop();
join();

实际行为由标准库定义,不能通过手动调用析构来使用。关键是先发请求、再等待。如果线程函数把 std::stop_token 作为第一个参数,jthread 构造时会把自己的 token 传给它:

1
2
3
4
5
std::jthread thread([](std::stop_token token) {
while (!token.stop_requested()) {
do_one_bounded_step();
}
});

这里的 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
2
const bool predicate_satisfied =
condition.wait(lock, token, predicate);

等待会在以下两类事件后重新评估:条件变量收到通知,或者停止状态收到请求。返回值表示谓词最终是否成立。

这仍然没有取消条件变量的基本规则:

  1. 共享条件必须由同一把锁保护;
  2. 等待必须使用谓词,处理虚假唤醒和通知竞争;
  3. 被唤醒后要根据业务语义决定处理任务还是退出;
  4. 不能在持锁状态执行耗时任务,否则生产者无法入队。

普通 std::condition_variable 没有对应的 stop-token 重载。若坚持使用它,就需要自己把“停止”纳入谓词并在请求停止时显式 notify_all(),还要仔细处理停止标志、通知和对象生命周期。

4. 如何写出一个能可靠退出的最小工作队列?

下面实现一个单工作线程队列。它采用明确的关闭语义:停止请求到达后,不再接受新任务,但会排空已经进入队列的任务,然后退出。

4.1 运行条件

  • 语言标准:C++20;
  • 所需组件:std::jthreadstd::stop_tokenstd::condition_variable_any
  • 第三方依赖:无;
  • 输出顺序:单工作线程按 FIFO 处理,因此示例输出确定;
  • 实现支持:需要标准库真正实现相应 C++20 并发组件,仅指定 -std=c++20 不足以补齐旧标准库。

将代码保存为 cancellable_worker.cpp

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <condition_variable>
#include <deque>
#include <iostream>
#include <mutex>
#include <stop_token>
#include <thread>

class Worker {
public:
Worker() : thread_([this](std::stop_token token) { run(token); }) {}

Worker(const Worker&) = delete;
Worker& operator=(const Worker&) = delete;

bool submit(int value) {
{
std::lock_guard lock(mutex_);
if (thread_.get_stop_token().stop_requested()) {
return false;
}
tasks_.push_back(value);
}

condition_.notify_one();
return true;
}

private:
void run(std::stop_token token) {
while (true) {
int task = 0;

{
std::unique_lock lock(mutex_);
const bool has_task = condition_.wait(
lock, token, [this] { return !tasks_.empty(); });

if (!has_task) {
return;
}

task = tasks_.front();
tasks_.pop_front();
}

std::cout << task << " squared is " << task * task << '\n';
}
}

std::mutex mutex_;
std::condition_variable_any condition_;
std::deque<int> tasks_;

// 必须最后声明,使其最先析构;join 时其他成员仍然存活。
std::jthread thread_;
};

int main() {
{
Worker worker;
if (!worker.submit(2) ||
!worker.submit(3) ||
!worker.submit(4)) {
std::cerr << "worker is already stopping\n";
return 1;
}
} // thread_ 请求停止;工作线程排空队列后返回;随后自动 join

std::cout << "worker stopped\n";
}

在支持这些组件的标准库上编译:

1
2
3
clang++ -std=c++20 -O2 -Wall -Wextra -pthread \
cancellable_worker.cpp -o cancellable_worker
./cancellable_worker

预期输出:

1
2
3
4
2 squared is 4
3 squared is 9
4 squared is 16
worker stopped

当前笔记环境中的 Apple Clang 所配套标准库缺少 std::jthread,因此这里不能声称已经在本机完成真实编译。应在目标编译器与标准库组合上运行上述命令;若失败,先检查标准库版本与特性支持,而不是只把 -std=c++20 改为 -std=c++23

5. 这段代码如何完成停止、唤醒和收尾?

5.1 数据只在锁保护下进入和离开队列

tasks_ 被生产者和工作线程共享,所以 push_backfrontpop_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
2
3
4
5
6
7
8
9
10
11
std::latch ready(worker_count);

for (int i = 0; i < worker_count; ++i) {
threads.emplace_back([&, i] {
initialize_worker(i);
ready.count_down();
});
}

ready.wait();
start_accepting_requests();

计数只能减少,归零后保持打开,不能复位。它适合启动、一次性初始化和测试中的统一起点。

若工作线程可能抛异常,必须确保异常被线程内部捕获并传播,同时不能让漏掉的 count_down() 把等待方永久挂住。并发示例中“异常直接逃出线程函数”会触发 std::terminate

6.2 std::barrier:所有参与者反复进入下一阶段

迭代仿真常需要每轮同步:

1
2
3
4
5
6
7
各线程计算 step N
|
v
barrier -- 所有人到齐后,可执行一次完成函数
|
v
各线程读取 step N 的完整结果,进入 step N+1

std::barrier 可以重复使用,适合阶段式算法。参与线程若提前退出,必须按协议调用 arrive_and_drop() 或让其他参与者也停止;否则剩余线程会永远等待一个不会到达的参与者。

barrier 建立阶段间同步,不会自动消除同一阶段内对共享对象的冲突。两个线程在到达 barrier 前并发写同一非原子对象,数据竞争已经发生。

6.3 std::counting_semaphore:管理许可数量

当资源只允许最多三个并发使用者时,可以用信号量表达许可:

1
2
3
4
5
6
7
8
9
10
11
12
std::counting_semaphore<3> slots(3);

void use_resource() {
slots.acquire();
try {
access_limited_resource();
} catch (...) {
slots.release();
throw;
}
slots.release();
}

它适合连接数、并发 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
2
3
// 存在性能隐患:等待较久时持续忙等
while (!ready.load(std::memory_order_acquire)) {
}

C++20 允许线程在原子值仍等于旧值时阻塞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::atomic<bool> ready{false};
Payload payload;

void producer() {
payload = make_payload();
ready.store(true, std::memory_order_release);
ready.notify_one();
}

void consumer() {
while (!ready.load(std::memory_order_acquire)) {
ready.wait(false, std::memory_order_relaxed);
}
use(payload);
}

notify_one() 本身不发布 payload;真正建立可见性的是 release store 与 acquire load。循环仍有意义,因为业务条件必须由值判断,而且值可能发生 ABA:从旧值变到新值又变回旧值时,等待者不一定观察到中间状态。

atomic::wait 适合单个状态字、序号和轻量事件。多个条件、复杂队列或需要在同一临界区检查多项共享状态时,mutex 加条件变量通常更清晰。

8. 其他现代并发组件有哪些边界?

8.1 std::atomic_ref:给现有对象建立原子视图

1
2
3
4
5
6
alignas(std::atomic_ref<int>::required_alignment) int counter = 0;

void increment() {
std::atomic_ref<int> ref(counter);
ref.fetch_add(1, std::memory_order_relaxed);
}

它适合无法把字段类型改成 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
2
std::osyncstream(std::cout)
<< "thread=" << id << ", result=" << result << '\n';

临时 osyncstream 在发射缓冲内容时与同一底层流的其他同步输出协调,使一条逻辑消息不被拆开。它适合示例和调试输出,不等同于带队列、批量写入、丢弃策略和日志轮转的生产异步日志系统。

9. 版本和标准库支持应该怎样判断?

本篇文件名沿用“C++20/23”,但版本归属需要说准确:

组件 标准版本
shared_mutex C++17
jthread、stop token/callback C++20
latchbarriersemaphore C++20
atomic::wait/notifyatomic_ref C++20
osyncstream C++20

这里讨论的同步工具主体是 C++20,并不是 C++23 才出现。C++23 可以与它们组合使用,但把语言模式切到 C++23 不会自动升级编译器配套的 libstdc++、libc++ 或平台线程运行时。

项目中可以检查标准库提供的特性测试宏,例如:

1
2
3
4
5
#include <version>

#ifndef __cpp_lib_jthread
#error "This standard library does not provide std::jthread"
#endif

还应在 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 与业务操作的可取消性是两件事。

  1. jthread 负责请求停止和等待结束,工作线程负责观察请求并从所有阻塞点退出。
  2. condition_variable_any 的 stop-token wait 能把队列条件与停止请求放进同一个可靠等待协议。
  3. latch 表示一次性完成,barrier 表示可重复阶段,semaphore 表示有限许可,三者不能按“都能阻塞”混用。
  4. atomic::wait/notify 减少长时间忙等,但数据可见性仍由原子值及其内存序建立。
  5. 大多数本文组件属于 C++20;语言选项和标准库实现版本必须同时满足要求。

最实用的建议是:每创建一个长期线程,就把它的所有权、停止发起者、每个阻塞点的唤醒方式,以及未完成任务的处置策略写在一起。如果这四项说不清楚,换成 jthread 也只是把卡死从 join() 调用处移动到了析构函数。

参考资料