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_sourcestop_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 的错误。

知识结构图

下面这张图帮你理清三个组件之间的关系:

std::stop_source 停止信号源头 get_token() std::stop_token 停止令牌 注册回调 stop_callback 回调监听器 线程 1 线程 2 线程 3 request_stop() 一个 stop_source 可以生成多个 stop_token,分发给不同线程 调用 request_stop() 后,所有关联的 token 都会收到停止信号 stop_callback 在信号发出时自动执行

避坑指南

我曾经踩过一个坑,分享给你:

注意stop_tokenstop_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 替换旧的线程管理代码。代码量减少,安全性提升,何乐而不为?


专注资料整理