从源码角度解析C++20新特性如何简化线程超时取消
为什么需要超时控制
超时控制是很常见的需求,最普遍的场景是为了防止程序卡住或者长时间占用资源,程序会主动取消掉一些超过允许运行时间的或者无响应的线程,比如一些耗时很长的网络连接处理线程等。当然用户等得不耐烦了手动点击取消任务执行也勉强可以算在内。
通常超时发生或者用户点击取消之后,我们都期待线程能迅速终止执行并让整个程序保持一个完整且安全的状态。然而现实是复杂的,想实现上述功能对于线程来说是一件难事,尤其在Linux系统上。
第一个难点是如何让线程知道自己要退出。对于进程来说这不是难点,因为不管进程在做什么,我们都可以靠向其发送信号来立即中断进程的执行(前提是线程没有屏蔽这个信号),这样进程的停止请求可以被立即感知到,进程从而可以尽快完成善后工作退出执行。同样的招数对多线程程序来说就没那么好用了——信号默认是发给整个进程的,为了能让每个线程独立地接收信号,我们需要保存线程的标识符并在每个线程中设置接收和屏蔽信号的mask,这大大增加了程序的复杂性;其次信号处理函数是整个进程内所有线程共享的,我们需要额外的手段来保证并发安全,同时还得兼顾信号处理函数需要可重入、快速执行的最佳实践,这会提高程序的开发难度。
第二个难点在于如何保证线程一定会退出执行。前面说到信号可以打断进程的执行,但这只是通知,实际上进程完全可以在信号处理函数返回后无视这个通知继续运行,或者有一种更普遍的场景——程序正好卡在某个系统调用上,而程序又设置了系统调用被信号中断后自动重启,这样即使我们有效通知了进程,进程也会在收完通知之后再次进入系统调用从而无法响应停止请求。所以作为保底手段,Linux可以发送SIGKILL这个信号强制终止进程,这个信号无法捕获也无法屏蔽,是我们货真价实的“底牌”。
上述的情况在多线程中同样存在,而且我们没有“底牌”可用——因为不管给哪个线程发送SIGKILL,都会杀死整个进程而不是单独接收到信号的那个线程。另外即使有办法强制终止线程(比如早期的JVM),我们还会遇到资源释放的问题。进程退出执行之后,内核会尽可能释放进程持有的所有资源,打开的文件会被关闭,缓冲区的内容会被刷新,文件锁之类的同步机制也会正常解锁;但线程并没有这种自动清理机制,清理工作完全需要手动执行,一旦进程没有释放自己持有的资源就退出,系统就会遇到各种数据损坏和死锁等并发问题,排查和修复会极其困难。
为了克服上述难点并安全高效地实现终止超时线程的执行,我们需要一些额外的控制手段。这也一直都是开发者中的热门话题。
在介绍C++20如何简化超时控制之前,我们先来看看前人的智慧成果。
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 4000 |
worker 1: canceled |
worker 0: canceled |
worker 2: canceled |
run time: 4.00431275s |
Tasks canceled |
$ ./test 7500 |
worker 0: done |
worker 2: done |
worker 1: canceled |
run time: 7.507776458s |
Tasks canceled |
$ ./test 11000 |
worker 1: done |
worker 2: done |
worker 0: done |
run time: 8.509193125s |
All work done! |
可以看到超时控制发挥了作用,尽管内置的time计时有一些误差,但程序的总体的运行时间是小于等于超时时间的。
Golang的超时控制可以通过context简单实现,但需要工作线程主动检查主动配合,前文我们也提到了强制终止工作线程很可能会造成并发问题,因此所有的线程超时控制中都是采用的这种协作式退出机制,即使天生并发的语言也不能免俗。作为代价,我们需要谨慎编码以免工作线程无法响应退出请求,同时还需要付出一点在循环里检查是否需要退出执行的性能损失。
C++中的典型超时控制实现
c++没有方便好用的context,想要实现协作式退出得自己造轮子。
Golang好用是因为标准库和运行时调度器隐藏了实现的细节:WithTimeout实际上会创建一个定时器,到时间后调度器会执行定时器的回调函数主动关闭ctx内部的channel,这样<-ctx.Done()就会从阻塞变成非阻塞,协程就能检查到这一变化从而退出执行。
核心只在于两点,以合适的方法标记线程已被取消和异步地在超时后设置取消标记。
