为什么需要超时控制超时控制是很常见的需求最普遍的场景是为了防止程序卡住或者长时间占用资源程序会主动取消掉一些超过允许运行时间的或者无响应的线程比如一些耗时很长的网络连接处理线程等。当然用户等得不耐烦了手动点击取消任务执行也勉强可以算在内。通常超时发生或者用户点击取消之后我们都期待线程能迅速终止执行并让整个程序保持一个完整且安全的状态。然而现实是复杂的想实现上述功能对于线程来说是一件难事尤其在Linux系统上。第一个难点是如何让线程知道自己要退出。对于进程来说这不是难点因为不管进程在做什么我们都可以靠向其发送信号来立即中断进程的执行前提是线程没有屏蔽这个信号这样进程的停止请求可以被立即感知到进程从而可以尽快完成善后工作退出执行。同样的招数对多线程程序来说就没那么好用了——信号默认是发给整个进程的为了能让每个线程独立地接收信号我们需要保存线程的标识符并在每个线程中设置接收和屏蔽信号的mask这大大增加了程序的复杂性其次信号处理函数是整个进程内所有线程共享的我们需要额外的手段来保证并发安全同时还得兼顾信号处理函数需要可重入、快速执行的最佳实践这会提高程序的开发难度。第二个难点在于如何保证线程一定会退出执行。前面说到信号可以打断进程的执行但这只是通知实际上进程完全可以在信号处理函数返回后无视这个通知继续运行或者有一种更普遍的场景——程序正好卡在某个系统调用上而程序又设置了系统调用被信号中断后自动重启这样即使我们有效通知了进程进程也会在收完通知之后再次进入系统调用从而无法响应停止请求。所以作为保底手段Linux可以发送SIGKILL这个信号强制终止进程这个信号无法捕获也无法屏蔽是我们货真价实的“底牌”。上述的情况在多线程中同样存在而且我们没有“底牌”可用——因为不管给哪个线程发送SIGKILL都会杀死整个进程而不是单独接收到信号的那个线程。另外即使有办法强制终止线程比如早期的JVM我们还会遇到资源释放的问题。进程退出执行之后内核会尽可能释放进程持有的所有资源打开的文件会被关闭缓冲区的内容会被刷新文件锁之类的同步机制也会正常解锁但线程并没有这种自动清理机制清理工作完全需要手动执行一旦进程没有释放自己持有的资源就退出系统就会遇到各种数据损坏和死锁等并发问题排查和修复会极其困难。为了克服上述难点并安全高效地实现终止超时线程的执行我们需要一些额外的控制手段。这也一直都是开发者中的热门话题。在介绍C20如何简化超时控制之前我们先来看看前人的智慧成果。Golang实现超时控制Golang是天生支持并发的语言这一点可谓名副其实尤其是在超时控制上。我们直接看个例子例子里有主线程和工作线程工作线程超时时间为5秒如果超过这个时间还有线程没完成工作就取消所有线程的执行。Golang里没有系统级的线程但我们可以用goroutine模拟。在工作线程中我们用sleep代替耗时的工作这样便于测试func Work(ctx context.Context, id int) error {for range 10 {select {case -ctx.Done():fmt.Printf(worker %d: canceled\n, id)return ctx.Err()default:}if rand.IntN(2) 0 {time.Sleep(500 * time.Millisecond)} else {time.Sleep(time.Second)}}fmt.Printf(worker %d: done\n, id)return nil}超时控制是ctx参数实现的每次循环处理前我们都会主动检查线程是否需要退出这种协作式的“请求-检查-响应”是各种语言中取消线程执行的常见做法。这个工作函数执行时间在5秒到10秒之间取值的步长在0.5秒加上go标准库默认随机数是均匀分布的所以整体执行时间的概率是正态分布的在7.5秒左右我们很容易看到超时和正常运行结束两种情况。所以我们把超时时间分别设为4秒、7.5秒、11秒来进行模拟运行实验func main() {// 从命令行获取超时时间单位毫秒timeout, err : strconv.Atoi(os.Args[1])if err ! nil {panic(err)}ctx, cancel : context.WithTimeout(context.Background(), time.Duration(timeout)*time.Millisecond)now : time.Now()defer cancel()g : errgroup.Group{}for i : range 3 {g.Go(func() error {return Work(ctx, i)})}err g.Wait()fmt.Printf(run time: %s\n, time.Since(now))if err ! nil {if errors.Is(err, context.DeadlineExceeded) {fmt.Println(Tasks canceled)return}panic(err)}fmt.Println(All work done!)}代码很简单关键在这行ctx, cancel : context.WithTimeout(context.Background(), 7500*time.Millisecond)只要我们设定的时间到了-ctx.Done()就会从阻塞变为非阻塞循环开始处的检查会发现这个变化然后会退出线程的执行。代码中使用了errgroup但这不是必须的实际上有很多办法可以通知主线程这里我选择了一种最通用的代价是代码会稍微复杂一些。运行代码会看到下面这样的输出结果有很大的随机成分下面只是无数种可能中的一种$ go build -o test$ ./test 4000worker 1: canceledworker 0: canceledworker 2: canceledrun time: 4.00431275sTasks canceled$ ./test 7500worker 0: doneworker 2: doneworker 1: canceledrun time: 7.507776458sTasks canceled$ ./test 11000worker 1: doneworker 2: doneworker 0: donerun time: 8.509193125sAll work done!可以看到超时控制发挥了作用尽管内置的time计时有一些误差但程序的总体的运行时间是小于等于超时时间的。Golang的超时控制可以通过context简单实现但需要工作线程主动检查主动配合前文我们也提到了强制终止工作线程很可能会造成并发问题因此所有的线程超时控制中都是采用的这种协作式退出机制即使天生并发的语言也不能免俗。作为代价我们需要谨慎编码以免工作线程无法响应退出请求同时还需要付出一点在循环里检查是否需要退出执行的性能损失。C中的典型超时控制实现c没有方便好用的context想要实现协作式退出得自己造轮子。Golang好用是因为标准库和运行时调度器隐藏了实现的细节WithTimeout实际上会创建一个定时器到时间后调度器会执行定时器的回调函数主动关闭ctx内部的channel这样-ctx.Done()就会从阻塞变成非阻塞协程就能检查到这一变化从而退出执行。核心只在于两点以合适的方法标记线程已被取消和异步地在超时后设置取消标记。
从源码角度解析C++20新特性如何简化线程超时取消
发布时间:2026/7/2 0:47:26
为什么需要超时控制超时控制是很常见的需求最普遍的场景是为了防止程序卡住或者长时间占用资源程序会主动取消掉一些超过允许运行时间的或者无响应的线程比如一些耗时很长的网络连接处理线程等。当然用户等得不耐烦了手动点击取消任务执行也勉强可以算在内。通常超时发生或者用户点击取消之后我们都期待线程能迅速终止执行并让整个程序保持一个完整且安全的状态。然而现实是复杂的想实现上述功能对于线程来说是一件难事尤其在Linux系统上。第一个难点是如何让线程知道自己要退出。对于进程来说这不是难点因为不管进程在做什么我们都可以靠向其发送信号来立即中断进程的执行前提是线程没有屏蔽这个信号这样进程的停止请求可以被立即感知到进程从而可以尽快完成善后工作退出执行。同样的招数对多线程程序来说就没那么好用了——信号默认是发给整个进程的为了能让每个线程独立地接收信号我们需要保存线程的标识符并在每个线程中设置接收和屏蔽信号的mask这大大增加了程序的复杂性其次信号处理函数是整个进程内所有线程共享的我们需要额外的手段来保证并发安全同时还得兼顾信号处理函数需要可重入、快速执行的最佳实践这会提高程序的开发难度。第二个难点在于如何保证线程一定会退出执行。前面说到信号可以打断进程的执行但这只是通知实际上进程完全可以在信号处理函数返回后无视这个通知继续运行或者有一种更普遍的场景——程序正好卡在某个系统调用上而程序又设置了系统调用被信号中断后自动重启这样即使我们有效通知了进程进程也会在收完通知之后再次进入系统调用从而无法响应停止请求。所以作为保底手段Linux可以发送SIGKILL这个信号强制终止进程这个信号无法捕获也无法屏蔽是我们货真价实的“底牌”。上述的情况在多线程中同样存在而且我们没有“底牌”可用——因为不管给哪个线程发送SIGKILL都会杀死整个进程而不是单独接收到信号的那个线程。另外即使有办法强制终止线程比如早期的JVM我们还会遇到资源释放的问题。进程退出执行之后内核会尽可能释放进程持有的所有资源打开的文件会被关闭缓冲区的内容会被刷新文件锁之类的同步机制也会正常解锁但线程并没有这种自动清理机制清理工作完全需要手动执行一旦进程没有释放自己持有的资源就退出系统就会遇到各种数据损坏和死锁等并发问题排查和修复会极其困难。为了克服上述难点并安全高效地实现终止超时线程的执行我们需要一些额外的控制手段。这也一直都是开发者中的热门话题。在介绍C20如何简化超时控制之前我们先来看看前人的智慧成果。Golang实现超时控制Golang是天生支持并发的语言这一点可谓名副其实尤其是在超时控制上。我们直接看个例子例子里有主线程和工作线程工作线程超时时间为5秒如果超过这个时间还有线程没完成工作就取消所有线程的执行。Golang里没有系统级的线程但我们可以用goroutine模拟。在工作线程中我们用sleep代替耗时的工作这样便于测试func Work(ctx context.Context, id int) error {for range 10 {select {case -ctx.Done():fmt.Printf(worker %d: canceled\n, id)return ctx.Err()default:}if rand.IntN(2) 0 {time.Sleep(500 * time.Millisecond)} else {time.Sleep(time.Second)}}fmt.Printf(worker %d: done\n, id)return nil}超时控制是ctx参数实现的每次循环处理前我们都会主动检查线程是否需要退出这种协作式的“请求-检查-响应”是各种语言中取消线程执行的常见做法。这个工作函数执行时间在5秒到10秒之间取值的步长在0.5秒加上go标准库默认随机数是均匀分布的所以整体执行时间的概率是正态分布的在7.5秒左右我们很容易看到超时和正常运行结束两种情况。所以我们把超时时间分别设为4秒、7.5秒、11秒来进行模拟运行实验func main() {// 从命令行获取超时时间单位毫秒timeout, err : strconv.Atoi(os.Args[1])if err ! nil {panic(err)}ctx, cancel : context.WithTimeout(context.Background(), time.Duration(timeout)*time.Millisecond)now : time.Now()defer cancel()g : errgroup.Group{}for i : range 3 {g.Go(func() error {return Work(ctx, i)})}err g.Wait()fmt.Printf(run time: %s\n, time.Since(now))if err ! nil {if errors.Is(err, context.DeadlineExceeded) {fmt.Println(Tasks canceled)return}panic(err)}fmt.Println(All work done!)}代码很简单关键在这行ctx, cancel : context.WithTimeout(context.Background(), 7500*time.Millisecond)只要我们设定的时间到了-ctx.Done()就会从阻塞变为非阻塞循环开始处的检查会发现这个变化然后会退出线程的执行。代码中使用了errgroup但这不是必须的实际上有很多办法可以通知主线程这里我选择了一种最通用的代价是代码会稍微复杂一些。运行代码会看到下面这样的输出结果有很大的随机成分下面只是无数种可能中的一种$ go build -o test$ ./test 4000worker 1: canceledworker 0: canceledworker 2: canceledrun time: 4.00431275sTasks canceled$ ./test 7500worker 0: doneworker 2: doneworker 1: canceledrun time: 7.507776458sTasks canceled$ ./test 11000worker 1: doneworker 2: doneworker 0: donerun time: 8.509193125sAll work done!可以看到超时控制发挥了作用尽管内置的time计时有一些误差但程序的总体的运行时间是小于等于超时时间的。Golang的超时控制可以通过context简单实现但需要工作线程主动检查主动配合前文我们也提到了强制终止工作线程很可能会造成并发问题因此所有的线程超时控制中都是采用的这种协作式退出机制即使天生并发的语言也不能免俗。作为代价我们需要谨慎编码以免工作线程无法响应退出请求同时还需要付出一点在循环里检查是否需要退出执行的性能损失。C中的典型超时控制实现c没有方便好用的context想要实现协作式退出得自己造轮子。Golang好用是因为标准库和运行时调度器隐藏了实现的细节WithTimeout实际上会创建一个定时器到时间后调度器会执行定时器的回调函数主动关闭ctx内部的channel这样-ctx.Done()就会从阻塞变成非阻塞协程就能检查到这一变化从而退出执行。核心只在于两点以合适的方法标记线程已被取消和异步地在超时后设置取消标记。