源码解析C++20新特性如何简化线程超时取消

发布时间:2026/10/22 7:55:36
源码解析C++20新特性如何简化线程超时取消
前言在 C20 之前标准库没有提供任何「取消一个正在运行的线程」的机制。开发者想让一个阻塞等待的线程在超时后退出通常只有两条路一是把sleep_for切成小段反复轮询一个bool标志二是使用带超时的wait_for把「超时」当成「取消」来用。前者让线程空转、延迟不可控后者无法区分「确实超时」和「被要求停止」而且如果线程正卡在别的地方超时根本帮不上忙。C20 引入了协作式取消cooperative cancellation设施std::stop_token、std::stop_source、std::stop_callback以及会自动请求停止并 join 的std::jthread。同时std::condition_variable_any增加了接受stop_token的wait/wait_for/wait_until重载。这三者组合起来让「等待某件事最多等 N 毫秒期间随时可以被取消」变成几行标准代码。需要先说明一点这些设施是协作式的它们不能强行杀死一个线程。线程必须自己检查stop_token或者在可中断的等待点上被唤醒。任何声称能「强制终止线程」的 C 标准做法都不存在pthread_cancel之类的平台接口不在本文讨论范围其异步取消语义也很难安全使用。本文按「问题—原语—等待点—完整示例」的顺序讲解代码基准为 C20C20 的线程库在各编译器上的支持时间不一致使用前请以自己工具链的支持表为准。一、超时取消难在哪里先看一个典型错误写法。工作线程阻塞在一个条件变量上主线程想让它最多等 100 毫秒// 适用于 C17#include chrono#include condition_variable#include mutexstd::mutex m;std::condition_variable cv;bool ready false;void worker() {std::unique_lockstd::mutex lk(m);// 错误示范忽略返回值也忽略虚假唤醒cv.wait_for(lk, std::chrono::milliseconds(100));// 此处无法知道是超时、被通知还是根本没有数据}这段代码有三个问题。第一wait_for可能因为虚假唤醒spurious wakeup提前返回标准允许条件变量无缘无故醒来。第二返回值被丢弃调用方无法区分「超时」与「条件满足」。第三即使成功返回也缺少对共享条件的复查。正确的基础写法是带谓词的等待// 适用于 C17cv.wait_for(lk, std::chrono::milliseconds(100), [] { return ready; });带谓词的版本会在内部循环醒来后重新求值谓词谓词为假就继续等直到超时或谓词为真返回值就是谓词的最终结果。这解决了虚假唤醒但还没有解决取消如果线程阻塞在一个不设超时的wait上别的线程没有任何标准手段让它醒来。于是问题归结为两个需求需求C17 的处境C20 的答案等待有上限wait_for/wait_until相同且可与 stop_token 组合外部随时叫停自己加atomicbool并轮询stop_token 可中断等待停止后自动收尾手工 join容易漏std::jthread析构时 request_stop join区分超时与取消无法区分只能约定返回值 stop_requested()判断二、stop_token 三件套的职责划分std::stop_token、std::stop_source、std::stop_callback三者共享一个「停止状态」stop-state。可以把 stop-state 理解为一个带原子标志和回调链的引用计数对象。std::stop_source是请求端。它持有 stop-state调用request_stop()把标志置位并同步执行已注册的回调。request_stop()返回bool第一次成功请求返回true之后再调用返回false标准保证停止请求只会生效一次。std::stop_token是观察端。它是轻量句柄可拷贝提供stop_requested()查询是否已请求停止stop_possible()查询是否有可能收到停止请求。std::stop_callback是挂钩点。构造时把一个可调用对象注册到某个 token 对应的 stop-state 上一旦有人request_stop()回调就在调用 request_stop 的那个线程上执行。回调对象的析构函数会等待正在执行的回调结束。三者的关系可以这样记stop_source是开关stop_token是观察开关状态的凭证stop_callback是开关被按下时触发的动作。几个容易忽略的语义细节默认构造的stop_token不与任何 stop-state 关联stop_possible()返回falsestop_requested()也返回false。这种 token 传给可中断等待时等待行为退化成普通等待。stop_source可以拷贝拷贝之间共享同一个 stop-state也可以用stop_source的get_token()取出多个 token。回调在请求停止的线程上同步执行因此回调体必须短小不能做阻塞 I/O也不能长时间持有锁。在某个 stop-state 的回调里对同一个 stop-state 调用request_stop()会造成死锁标准明确禁止这种用法。std::jthread把「请求停止 join」打包进析构函数它的构造函数会把内部 stop_token 作为第一个参数传给可调用对象如果该对象接受的话析构时先request_stop()再join()。这让「忘记 join 导致std::terminate」这个经典坑在 jthread 上消失。// 需要 C20#include iostream#include stop_token#include threadint main() {std::jthread t([](std::stop_token st) {while (!st.stop_requested()) {std::this_thread::sleep_for(std::chrono::milliseconds(10));}std::cout 收到停止请求退出\n;});// 离开作用域时 jthread 自动 request_stop() 并 join()}这个例子里线程靠轮询stop_requested()退出延迟取决于 sleep 的粒度。更好的做法是把轮询换成第三节的可中断等待。三、condition_variable_any 的 stop_token 重载std::condition_variable只接受std::unique_lockstd::mutex而std::condition_variable_any接受任意满足 BasicLockable 的锁类型。C20 给后者增加了三个重载参数顺序要注意stop_token 在时间参数之前// 需要 C20签名示意非完整程序templateclass Lock, class Predicatebool wait(Lock lock, std::stop_token stoken, Predicate pred);templateclass Lock, class Rep, class Period, class Predicatebool wait_for(Lock lock, std::stop_token stoken,const std::chrono::durationRep, Period rel_time,Predicate pred);templateclass Lock, class Clock, class Duration, class Predicatebool wait_until(Lock lock, std::stop_token stoken,const std::chrono::time_pointClock, Duration abs_time,Predicate pred);语义要点等待期间如果收到停止请求函数应尽快返回返回值是谓词的当前值。因此「被取消」表现为「谓词仍为假返回 false」而不是抛异常。谓词在持有锁的状态下求值这一点和普通重载一致。如果谓词在停止请求到达时已经为真返回true调用方仍可正常取走数据。也就是说取消不保证丢弃已经就绪的工作是否继续处理由调用方决定。标准只规定「应尽快返回」实现方式通常是内部注册一个stop_callback去通知条件变量具体实现不属于可移植接口。因为condition_variable_any需要类型擦除来支持任意锁类型它的开销通常高于condition_variable。只有在确实需要 stop_token 重载、或者锁类型不是unique_lockmutex时才选它。四、完整示例可超时、可取消的阻塞队列下面把前面的部件组装成一个生产者—消费者示例。队列的pop_for同时处理三种结局取到数据、超时、收到停止请求。为了区分「超时」和「停止」函数返回一个枚举而不是只返回布尔值。// 需要 C20#include chrono#include condition_variable#include iostream#include mutex#include optional#include queue#include stop_token#include thread#include utilitytemplate class Tclass BlockingQueue {public:enum class PopResult { Ok, Timeout, Stopped, Closed };void push(T value) {{std::lock_guardstd::mutex lk(m_);q_.push(std::move(value));}cv_.notify_one();}void close() {{std::lock_guardstd::mutex lk(m_);closed_ true;}cv_.notify_all();}// 无超时版本停止请求到达且没有数据时返回 nulloptstd::optionalT pop(std::stop_token st) {std::unique_lockstd::mutex lk(m_);cv_.wait(lk, st, [this] { return closed_ || !q_.empty(); });if (q_.empty()) {return std::nullopt; // 已关闭且没有剩余元素}T value std::move(q_.front());q_.pop();return value;}// 带相对超时的版本PopResult pop_for(T out, std::chrono::milliseconds timeout,std::stop_token st) {std::unique_lockstd::mutex lk(m_);const bool ready cv_.wait_for(lk, st, timeout, [this] { return closed_ || !q_.empty(); });if (!ready) {// 谓词为假要么超时要么期间收到了停止请求return st.stop_requested() ? PopResult::Stopped: PopResult::Timeout;}if (q_.empty()) {return PopResult::Closed;}out std::move(q_.front());q_.pop();return PopResult::Ok;}private:std::mutex m_;std::condition_variable_any cv_;std::queueT q_;bool closed_ false;};使用方式如下。主线程在 250 毫秒后请求停止消费者即使正卡在等待中也会被 stop_token 重载唤醒并退出。这里用std::thread加外部stop_source是为了演示「由别人触发取消」如果不需要外部句柄用std::jthread更省事。// 需要 C20int main() {BlockingQueueint q;std::stop_source source;std::stop_token token source.get_token();std::thread consumer([q, token] {int value 0;while (!token.stop_requested()) {const auto result q.pop_for(value, std::chrono::milliseconds(100), token);if (result BlockingQueueint::PopResult::Ok) {std::cout 取到 value \n;} else if (result BlockingQueueint::PopResult::Timeout) {std::cout 等待超时做点别的\n;}}std::cout 消费者退出\n;});for (int i 0; i 3; i) {q.push(i * 10);}std::this_thread::sleep_for(std::chrono::milliseconds(250));source.request_stop();consumer.join();}逐行推演队列有元素时谓词为真wait_for立即返回true取出元素并置为Ok队列空时线程进入等待100 毫秒后超时、谓词仍为假返回false若无人请求停止就得到Timeout。request_stop()之后等待点应尽快返回谓词为假且stop_requested()为真得到Stopped循环结束。按标准语义程序不会卡在join()上但调度与输出行数依赖时序。pop版本有一个值得注意的行为停止请求到达时如果队列里还有元素谓词为真函数会照常返回数据。这是有意为之——取消不等于丢弃已经排队的工作。如果业务要求「一旦取消就立刻放弃」应当在取出数据后再检查一次stop_requested()或者改用pop_for并显式判断。让任意 sleep 也可被取消并非所有阻塞点都是条件变量。有时线程只是在一个sleep_for上等待此时 stop_token 不会自动生效。可以用stop_callback把「请求停止」转成「通知条件变量」从而提前唤醒// 需要 C20#include chrono#include condition_variable#include mutex#include stop_token// 返回 true 表示睡满了false 表示被停止请求提前唤醒bool interruptible_sleep(std::chrono::milliseconds duration,std::stop_token st) {std::mutex m;std::condition_variable cv;std::unique_lockstd::mutex lk(m);std::stop_callback callback(st, [cv] { cv.notify_one(); });const bool finished !cv.wait_for(lk, duration, [st] {return st.stop_requested();});return finished;}这个函数有几个必须守住的约束。回调里只调用notify_one()不获取互斥量否则可能与持有锁的等待线程互相阻塞。stop_callback对象在lk之后构造、之前析构保证回调注销时互斥量和条件变量仍然存活。如果 token 没有关联任何 stop-statestop_callback什么也不做函数退化为普通的sleep_for。常见坑点❌ 用一个全局bool标志做取消工作线程里while (!flag)轮询。✅ 用std::stop_token或至少std::atomicbool普通bool被多线程读写属于数据竞争是未定义行为。❌ 把「超时」直接当成「取消」超时后线程继续跑或者被detach。✅ 超时和取消是两个独立状态wait_for返回 false 后要再用stop_requested()区分并让线程走到统一退出路径。❌ 调用cv.wait(lk)时不带谓词只在醒来后if (ready)。✅ 用cv.wait(lk, pred)或带 stop_token 的谓词重载避免虚假唤醒导致的误判。❌ 在stop_callback里做耗时操作、加锁或阻塞等待。✅ 回调运行在调用request_stop()的线程上应只做置标志、notify_one()这类轻量动作。❌ 在注册到某个 stop-state 的回调内部再对该 stop-state 调用request_stop()。✅ 这种自反调用会死锁把请求交给另一个线程或只设置独立标志。❌ 认为std::jthread能强行终止线程于是回调里不检查 stop_token。✅ jthread 只负责请求停止和 join线程必须自己到达检查点否则析构会一直等下去。❌ 在等待返回后只判断返回值不判断停止状态或者反过来只看stop_requested()忽略已经取到的数据。✅ 先看返回值是否表示就绪再决定是消费数据还是退出停止与就绪可以同时成立。总结组件作用关键语义std::stop_source发起停止请求request_stop()只生效一次返回是否首次请求std::stop_token查询与传递停止状态stop_requested()/stop_possible()可拷贝的轻量句柄std::stop_callback停止时执行回调在请求线程上同步执行析构等待回调结束std::jthread自动管理生命周期析构时 request_stop jointoken 作为首参注入condition_variable_any可中断等待点新增 stop_token 重载取消时返回谓词值C20 并没有给线程加上「强制终止」能力它做的是把协作式取消标准化请求端、观察端、回调端职责清晰等待点可以同时接受超时和停止信号。真正简化代码的地方在于取消不再需要自己维护一套atomicbool加轮询而是复用标准类型并且和jthread的生命周期管理自然衔接。使用时只要记住两条线程必须主动到达检查点等待必须带谓词。