40、C++20 核心特性:std::stop_token,优雅停止线程
多线程编程里,怎么「优雅」地让一个线程停下来?
这个问题我琢磨了好多年。早期用 pthread_cancel,那叫一个粗暴——线程可能正在持有锁,突然就被干掉了,资源都没来得及释放。后来大家用原子变量做标志位,比如 std::atomic<bool> stop_flag,线程里轮询检查。这法子能用,但不够「体面」。
C++20 终于给出了官方答案:std::stop_token。说白了,它就是一套标准化的「停止令牌」机制。你不需要自己造轮子,也不用担心跨平台兼容性。
为什么需要 stop_token?
先说说我自己的经历。几年前我维护一个后台服务,里面有个工作线程池。每个线程都在循环里检查一个全局的 std::atomic<bool>。看起来没问题对吧?但后来需求变了:线程里调用了第三方库的阻塞操作,那个库根本不认你的原子变量。线程卡死在阻塞调用里,停不下来。
你想想看,这时候你怎么办?强行终止线程?数据可能就坏了。不终止?程序退不出。
std::stop_token 解决的就是这个痛点。它提供了一套协作式的停止机制:
- 线程主动检查是否收到停止请求
- 支持回调注册,阻塞操作可以监听停止信号
- 同一个停止源可以控制多个线程
核心思想:不是「命令」线程停止,而是「请求」线程停止。线程自己决定何时、何地响应这个请求。
三个关键组件
这套机制由三个类组成,我习惯把它们记成「一源、一牌、一信」:
| 组件 | 作用 |
|---|---|
std::stop_source |
停止信号的「源头」。你调用它的 request_stop() 方法,就发出了停止请求。 |
std::stop_token |
停止信号的「令牌」。每个线程持有一个 token,用来查询是否收到了停止请求。 |
std::stop_callback |
停止信号的「监听器」。注册一个回调函数,当停止请求发出时自动执行。 |
嗯,这里要注意:stop_source 和 stop_token 是关联的。同一个 stop_source 可以生成多个 stop_token,发给不同的线程。这样你一个信号就能停掉整个线程池。
基础用法:轮询检查
最简单的场景,就是线程里循环干活,时不时看一眼 token:
#include <thread>
#include <stop_token>
#include <iostream>
void worker(std::stop_token st) {
while (!st.stop_requested()) {
// 干点活...
std::cout << "工作中..." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::cout << "收到停止信号,优雅退出" << std::endl;
}
int main() {
std::stop_source source;
std::thread t(worker, source.get_token());
std::this_thread::sleep_for(std::chrono::seconds(1));
source.request_stop(); // 发出停止请求
t.join();
return 0;
}
你看,代码很直白。stop_requested() 返回 true 时,线程就知道该收工了。比起自己维护一个原子变量,这套方案的好处是:
- 标准库保证线程安全
- 可以配合
std::jthread自动处理 - 支持回调机制,更灵活
进阶用法:stop_callback 回调
轮询检查有个问题:如果线程正在执行一个长时间操作,比如等待网络数据,它没法频繁检查 token。这时候 stop_callback 就派上用场了。
我曾经写过一个网络库,里面有个线程在 epoll_wait 上阻塞。为了让它能响应停止信号,我注册了一个回调,在回调里给 epoll fd 写一个字节,让它从阻塞中醒来。嗯,这招挺巧妙的。
#include <stop_token>
#include <thread>
#include <iostream>
void worker(std::stop_token st) {
// 注册回调:当收到停止信号时,打印一条消息
std::stop_callback cb(st, [] {
std::cout << "回调触发:准备清理资源" << std::endl;
});
// 模拟长时间阻塞操作
while (!st.stop_requested()) {
std::cout << "阻塞等待中..." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
std::cout << "退出循环" << std::endl;
}
int main() {
std::stop_source source;
std::thread t(worker, source.get_token());
std::this_thread::sleep_for(std::chrono::seconds(1));
source.request_stop();
t.join();
return 0;
}
小提示:stop_callback 的回调会在 request_stop() 调用时同步执行。如果你的回调里做了耗时操作,注意别阻塞了调用 request_stop() 的线程。
与 std::jthread 的配合
C++20 还引入了 std::jthread,它和 stop_token 是天生一对。jthread 在析构时会自动调用 request_stop() 并 join(),你连手动管理生命周期都省了。
#include <thread>
#include <iostream>
void worker(std::stop_token st) {
while (!st.stop_requested()) {
std::cout << "jthread 工作中..." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main() {
std::jthread t(worker); // 自动获取 stop_token
std::this_thread::sleep_for(std::chrono::seconds(1));
// t 析构时自动请求停止并 join
return 0;
}
我个人习惯是:只要 C++20 可用,新代码一律用 jthread 替代 thread。少写很多样板代码,也少犯忘记 join 的错误。
知识结构图
下面这张图帮你理清三个组件之间的关系:
避坑指南
我曾经踩过一个坑,分享给你:
注意:stop_token 的 stop_requested() 检查是「一次性」的吗?不是。它可以多次检查,但一旦 request_stop() 被调用,所有 token 的 stop_requested() 都会永久返回 true。你不能「撤销」停止请求。
还有一点:stop_callback 的构造和析构有线程安全要求。如果你在回调里访问了其他线程的资源,记得加锁。我见过有人直接在回调里 delete 对象,结果引发了 double free——回调执行时,对象可能已经被别的线程释放了。
什么时候用?
不是所有场景都需要 stop_token。我个人总结了几条经验:
- 线程池管理:强烈推荐。一个 stop_source 控制所有工作线程。
- 长时间阻塞操作:配合 stop_callback 使用,比如网络 I/O、等待条件变量。
- 简单的一次性任务:用
std::future取消可能更合适。 - 性能敏感的热循环:原子变量的开销可能比 token 小,但差别微乎其微,我建议统一用 token。
说白了,std::stop_token 是 C++20 给多线程编程补上的一块重要拼图。它让线程停止这件事变得规范、可预测。你想想看,以前我们为了「优雅停止」要写多少辅助代码?现在标准库帮你搞定了。
我的建议:如果你的项目已经切换到 C++20,尽快用 std::jthread + std::stop_token 替换旧的线程管理代码。代码量减少,安全性提升,何乐而不为?