第 21 章 · 取消、超时与结构化并发
解决什么问题:同时启动的几个任务中,有的超时、有的不再需要时,怎样让它们真的停下来,而不是在后台悄悄跑完;又怎样保证一个函数返回时,它启动的任务都已经结束。
四种语言的态度:四种语言的取消都是协作式的。Python 在
await点抛CancelledError,并用TaskGroup做结构化并发;JS/TS 靠AbortSignal一个一个传给异步操作;C# 把CancellationToken一路传下去;Go 把context.Context当作第一个参数传,在select里等ctx.Done()。
同时发起三个任务:fast 要 50 毫秒,medium 要 150 毫秒,slow 要 2000 毫秒,整体只给 600 毫秒。到时间还没完成的任务要被真正取消:不能只是不再等它,让它在后台继续跑。为了证明取消真的到达了任务,每个任务在收到取消时自己记下“被取消了”,而不是由主程序推断。最后按名字排序,打印 完成:fast medium 和 已取消:slow,程序在 slow 被取消后立即结束,总共约 600 毫秒,不等满 2 秒。
启动一个任务只要一行,停下一个任务却难得多。原因在于任务被打断的那一刻可能正拿着锁、写了一半文件、开着一个网络连接;从外面直接把它掐掉,锁不会释放,文件留下半截,连接没人关。操作系统确实能强行终止线程,但 Windows 的 TerminateThread 文档明确警告它可能让进程处于不一致的状态,Java 早年的 Thread.stop 也因为同样的原因被废弃。所以本章四种语言都采用协作式取消(cooperative cancellation):外面只发出一个“请停下”的信号,任务在自己认为安全的地方检查这个信号,做完清理后自行退出。代价是任务必须配合:一段从不检查信号的代码,信号发了也没用。
信号的载体在各语言里叫法不同:取消令牌(cancellation token)、中止信号(abort signal)、上下文(context),多数语言里它是一个只能从“未取消”变成“已取消”、不能再变回来的标志,再加上“变化时通知我”的能力;Python 没有独立的信号对象,取消请求记在任务上。任务有两种检查方式:在循环里主动查询标志;或者在等待点(await、select、阻塞调用)把信号一起交给下层,下层在信号到来时提前结束等待。超时(timeout)是取消的一种:到了某个时刻,由计时器替你发出取消信号。
取消还要能传播(propagation)。一个请求处理函数启动了三个子任务,子任务又各自发起查询;请求被客户端放弃时,整棵树都应该停下。所以信号通常组织成树:子信号从父信号派生,父取消则子取消,子取消不影响父。
左边是本章场景的取消树:600 毫秒到时,根发出取消,经过父作用域到达还在等待的 slow,slow 自己退出,作用域等三个子任务都结束才返回。右边是没有这种结构时的样子:main 不再等 slow,但也没人通知它,它在后台跑满 2 秒,占着计时器、连接或内存,这就是孤儿任务(orphan task)。
把“子任务都结束才返回”做成语言或库的规则,就是结构化并发(structured concurrency)。Nathaniel J. Smith 2018 年发表《Notes on structured concurrency, or: Go statement considered harmful》,把 go 语句、spawn 这类“启动后不管”的原语比作 goto:控制流在这里分了叉,却没有约定在哪里汇合。他在自己的 Python 库 Trio 里用托儿所(nursery)代替:子任务只能在一个代码块里启动,代码块结束前必须等所有子任务结束;任何一个子任务出错,其余的被取消,错误交给父作用域。
图中子任务都在子作用域里开始、在子作用域里结束,就像函数调用一样层层嵌套:离开 main 时,不会有它启动的任务还在跑。由此有三个好处:子任务的错误不会丢失,总有一个父作用域接着;取消沿作用域自然往下传;父作用域里的 with、using、defer 清理资源时,可以确定已经没有子任务在用这些资源。
Python 怎么做
Section titled “Python 怎么做”Python 的 asyncio 从 3.4 起就有 Task.cancel(),3.11 又加了两样标准库工具:asyncio.timeout() 给一段代码设截止时间,asyncio.TaskGroup 实现结构化并发。线程(threading)则没有任何取消机制。
import asyncio
JOBS = {"fast": 0.05, "medium": 0.15, "slow": 2.0} # 秒done: list[str] = []cancelled: list[str] = []
async def job(name: str, seconds: float) -> None: try: await asyncio.sleep(seconds) # 取消从这个 await 点抛进来 except asyncio.CancelledError: cancelled.append(name) # 任务自己记下"被取消了" raise # 必须重新抛出,否则取消就被吞掉了 done.append(name)
async def main() -> None: try: async with asyncio.timeout(0.6): # 到时取消块里正在等的一切 async with asyncio.TaskGroup() as tg: # 子任务不能活过这个块 for name, seconds in JOBS.items(): tg.create_task(job(name, seconds)) except TimeoutError: pass # 超时就是一次取消,结果已经记下 print("完成:" + " ".join(sorted(done))) print("已取消:" + " ".join(sorted(cancelled)))
asyncio.run(main())
# 输出:# 完成:fast medium# 已取消:slow运行:python jobs.py。async with asyncio.TaskGroup() 这个块退出前,会等组里所有任务结束。600 毫秒到时,asyncio.timeout 取消的是 main 这个任务,它此刻正停在 TaskGroup 的退出处等子任务;TaskGroup 发现自己被取消,就把还没结束的 slow 也取消,等它真的退出,再把取消往外抛;asyncio.timeout 认出这是自己发起的取消,把它换成 TimeoutError。三个列表都在同一条线程上修改,不需要锁。3.11 之前常用 asyncio.wait_for 和 asyncio.gather,后者在出错时不会取消其余任务。
底层怎么实现。 task.cancel() 只是在任务上做个标记;如果任务此刻正等着一个 Future(asyncio.sleep 里就有一个),就顺手把这个 Future 也取消,计时器随之撤销。下一轮事件循环驱动这个任务时,不再调用协程的 send,而是调用 throw,把 CancelledError 从协程停住的那个 await 处抛出来。所以取消只能落在 await 点上,两个 await 之间的代码不会被打断。asyncio.timeout 在截止时刻用 loop.call_at 安排一次 task.cancel();3.11 给任务加了取消计数(cancelling() 和 uncancel()),它退出时据此判断这次取消是不是自己发起的,是才转换成 TimeoutError,否则原样往外传。
- 吞掉
CancelledError:3.8 起它继承自BaseException,except Exception抓不到,但except BaseException、裸except:或专门except CancelledError而不重新抛出,都会让取消失效。实测在async with asyncio.timeout(0.6)里直接吞掉,0.61 秒时代码照常往下走,外面根本收不到TimeoutError;在TaskGroup的子任务里吞掉后接着工作,整个组要等它做完,实测程序 2.0 秒才结束。官方文档要求有意吞掉时调用uncancel(),否则cancelling()一直大于 0,之后的TaskGroup和asyncio.timeout会误判。 - 阻塞调用无法取消:取消只能从
await点进来。把 slow 里的await asyncio.sleep(2)换成time.sleep(2),事件循环整整停 2 秒,到期的计时回调没机会运行;实测程序 2.0 秒后才结束,slow 被记作完成,外面却仍收到TimeoutError。asyncio.to_thread包起来也一样:取消只让你不再等它,线程里的函数照样跑完。 asyncio.create_task启动的任务不属于任何组:外层超时只取消正在等待的那个任务,组外的任务不受影响,成了孤儿。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JS 的 Promise 本身不能取消:它只代表“将来的一个结果”,不知道是谁、用什么在产生这个结果。取消靠 AbortController 和 AbortSignal:控制器发出信号,信号要交给每一个愿意配合的异步操作。Node 15 起它们是全局对象;AbortSignal.timeout(ms) 从 Node 17.3 起可用,返回一个到时自动中止的信号;AbortSignal.any([...]) 从 Node 20.3 起可用,任意一个来源中止,它就中止,用来把父信号和超时合在一起。
import { setTimeout as sleep } from "node:timers/promises";
const JOBS: [string, number][] = [["fast", 50], ["medium", 150], ["slow", 2000]];const done: string[] = [];const cancelled: string[] = [];
// 外层控制器代表"调用者":比如用户点了取消,就调用 parent.abort()const parent = new AbortController();// 子信号:父信号中止或 600 毫秒到时,哪个先发生都算取消const signal = AbortSignal.any([parent.signal, AbortSignal.timeout(600)]);
async function job(name: string, ms: number, signal: AbortSignal): Promise<void> { try { await sleep(ms, undefined, { signal }); // 计时器收到信号就清掉自己 done.push(name); } catch (err) { if (!signal.aborted) throw err; // 不是取消造成的错误,照常抛出 cancelled.push(name); // 任务自己记下"被取消了" }}
// allSettled:等每个任务都结束(完成或取消),一个失败不影响等其余的await Promise.allSettled(JOBS.map(([name, ms]) => job(name, ms, signal)));console.log("完成:" + done.sort().join(" "));console.log("已取消:" + cancelled.sort().join(" "));
// 输出:// 完成:fast medium// 已取消:slow运行:tsx jobs.ts。node:timers/promises 的 setTimeout 是 Promise 版的计时器,第三个参数可以带 signal。600 毫秒到时,slow 的计时器被清掉,它的 Promise 以 AbortError 拒绝;实测错误消息是 The operation was aborted,cause 是一个 TimeoutError,消息为 The operation was aborted due to timeout。事件循环里不再有待办的计时器,进程随即退出。Promise.allSettled 等到每个 Promise 都有了结果才继续,这里的作用和 TaskGroup 的退出相同,只是要靠程序员自己写。
底层怎么实现。 AbortSignal 是一个 EventTarget,有 aborted、reason 两个属性。controller.abort() 把 aborted 设为真,然后同步派发 abort 事件。每个支持取消的 API 收到 signal 时,都在上面登记一个监听函数,事件来了就释放自己的底层资源(清掉计时器、关闭套接字、杀掉子进程),再拒绝自己返回的 Promise。AbortSignal.timeout 内部是一个计时器;AbortSignal.any 返回的信号记住它的各个来源,任一来源中止时跟着中止,这就是父子传播。所以取消能走多深,取决于链条上的每个函数是否都把 signal 往下传。
- 只是不再等待:常见的超时写法
Promise.race([work, timeout])只决定谁的结果先被拿到。实测Promise.race([sleep(2000), sleep(600)])在约 600 毫秒时返回,但进程要到 2013 毫秒才退出,因为那个 2 秒的计时器还活着;真实场景里就是请求还在发、文件还在写。 - 自己写的异步函数接收了 signal 却不往下传,或者是一段不
await的同步计算:超时计时器和别处的abort()都要等当前同步代码跑完才有机会执行,循环里要自己查signal.aborted或调用signal.throwIfAborted(),并在中间让出事件循环。 catch里不区分原因:取消和真正的失败都表现为 Promise 被拒绝,按signal.aborted或错误的name分开处理,否则超时会被当成故障上报。
C# 怎么做
Section titled “C# 怎么做”C# 的取消在标准库里,从 .NET Framework 4.0 的任务并行库(Task Parallel Library,TPL)起就有:CancellationTokenSource 负责发出取消,CancellationToken 是交给任务的只读令牌。几乎所有异步 API 都有一个 CancellationToken 参数,惯例是放在参数表最后。
using System;using System.Linq;using System.Threading;using System.Threading.Tasks;
(string Name, int Ms)[] jobs = [("fast", 50), ("medium", 150), ("slow", 2000)];
// 600 毫秒后自动取消;using 保证用完释放(它内部有计时器)using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(600));
var results = await Task.WhenAll(jobs.Select(j => Job(j.Name, j.Ms, cts.Token)));var done = results.Where(r => r.Done).Select(r => r.Name).Order();var cancelled = results.Where(r => !r.Done).Select(r => r.Name).Order();Console.WriteLine("完成:" + string.Join(" ", done));Console.WriteLine("已取消:" + string.Join(" ", cancelled));
static async Task<(string Name, bool Done)> Job(string name, int ms, CancellationToken token){ try { await Task.Delay(ms, token); // 令牌传给下层:取消时 Delay 立刻结束 return (name, true); } catch (OperationCanceledException) { return (name, false); // 任务自己确认"被取消了" }}
// 输出:// 完成:fast medium// 已取消:slow运行:dotnet run jobs.cs。new CancellationTokenSource(TimeSpan) 构造时就启动计时,也可以先建好再调用 CancelAfter。每个任务返回自己的名字和结果,Task.WhenAll 把它们收进数组,不需要共享列表。对计算型的循环,在每一轮调用 token.ThrowIfCancellationRequested(),已取消就抛 OperationCanceledException。父子传播用 CancellationTokenSource.CreateLinkedTokenSource(parentToken):父令牌取消时,链接出来的子源跟着取消。
底层怎么实现。 CancellationToken 是一个结构体,里面只有一个指向 CancellationTokenSource 的引用,所以可以随意复制、往下传,持有者只能查询和登记回调,不能自己发出取消。Cancel() 把源的状态改为已取消,然后在调用线程上依次执行所有用 token.Register 登记的回调;带超时的源内部有一个计时器,到期时在线程池线程上做同样的事。Task.Delay(ms, token) 正是登记了这样一个回调:回调撤掉计时器,把返回的 Task 置为“已取消”状态,await 一个已取消的 Task 就抛出 TaskCanceledException,它是 OperationCanceledException 的子类。
- 没把令牌传给下层:
Job收了token,里面却写await Task.Delay(ms)。实测 slow 照样等满约 2015 毫秒,然后报告“完成”,Task.WhenAll也跟着等,取消只停在了方法签名上。代码分析规则 CA2016 专门提示把收到的令牌转发给被调用的方法。 - 只捕获
TaskCanceledException:实测Task.Delay取消时抛的是System.Threading.Tasks.TaskCanceledException: A task was canceled.,而ThrowIfCancellationRequested抛的是System.OperationCanceledException: The operation was canceled.,后者不是前者的子类,会漏过去。应当捕获OperationCanceledException。 CancellationTokenSource要释放:带超时的源持有计时器,链接出来的源在父令牌上登记了回调;不Dispose,长寿的父令牌会一直引用这些子源,在请求量大的服务器上表现为内存缓慢上涨。
Go 怎么做
Section titled “Go 怎么做”Go 的取消靠标准库 context。context.WithCancel 和 context.WithTimeout 从一个父 context 派生出子 context,同时返回一个 cancel 函数;任务通过 ctx.Done() 拿到一个通道,通道被关闭就表示该停了。惯例是把 ctx 作为函数的第一个参数一路往下传。
package main
import ( "context" "fmt" "slices" "strings" "sync" "time")
// job 等 d 这么久;ctx 先被取消就提前返回 falsefunc job(ctx context.Context, d time.Duration) bool { timer := time.NewTimer(d) defer timer.Stop() select { case <-timer.C: return true case <-ctx.Done(): // 取消信号:这个通道被关闭 return false // 任务自己确认"被取消了",随即退出 }}
func main() { names := []string{"fast", "medium", "slow"} delays := []time.Duration{50, 150, 2000} ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) defer cancel() // 提前结束时也释放 ctx 的计时器
finished := make([]bool, len(names)) var wg sync.WaitGroup for i := range names { wg.Add(1) go func() { defer wg.Done() finished[i] = job(ctx, delays[i]*time.Millisecond) }() } wg.Wait() // 所有子 goroutine 结束后才往下走
var done, cancelled []string for i, name := range names { if finished[i] { done = append(done, name) } else { cancelled = append(cancelled, name) } } slices.Sort(done) slices.Sort(cancelled) fmt.Println("完成:" + strings.Join(done, " ")) fmt.Println("已取消:" + strings.Join(cancelled, " "))}
// 输出:// 完成:fast medium// 已取消:slow运行:go run jobs.go。等待和取消写在同一个 select 里,这是第 20 章讲过的多路等待:哪个通道先就绪就走哪个分支。ctx.Err() 说明取消的原因,超时是 context.DeadlineExceeded,实测打印为 context deadline exceeded,主动调用 cancel 则是 context.Canceled。WaitGroup 在这里充当结构化并发的作用域,但这是程序员自己的约定,go 语句本身不要求任何人等它。golang.org/x/sync/errgroup 把“等所有 goroutine、第一个出错就取消其余的”包装成一个类型,它在 Go 团队维护的扩展库里,不在标准库中。
底层怎么实现。 Context 是一个接口,有 Deadline、Done、Err、Value 四个方法。WithCancel 返回的 cancelCtx 里有一把锁、一个第一次调用 Done() 时才创建的通道,以及一个记录所有子 context 的集合;派生时,子 context 把自己挂到最近的那个可取消的祖先上。cancel 关闭自己的通道,再逐个取消子 context,最后把自己从父节点摘下。关闭通道是一次广播:所有等在 <-ctx.Done() 上的 goroutine 同时就绪,这和第 20 章 close 唤醒所有等着接收的 goroutine 是同一回事。WithTimeout 只是在此之上加一个 time.AfterFunc 计时器,到期调用同一个 cancel。没有任何 API 能从外部终止一个 goroutine,context 只负责把信号送到。
- 忘了调用
cancel:子 context 会一直挂在父 context 上,直到父 context 被取消;带超时的还要等计时器到期。go vet能发现这种情况,实测提示the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak。 - goroutine 不检查
ctx.Done():把job里的select换成time.Sleep(d),取消信号照发,slow 照样睡满 2 秒,wg.Wait()也跟着等;如果主程序不等它,它就成了孤儿,第 19 章那种泄漏又出现了。 - 只在部分路径上传
ctx:标准库里接受ctx的 API 才会响应取消,例如http.NewRequestWithContext、exec.CommandContext;io.Reader的Read没有ctx参数,读网络连接时要靠SetReadDeadline或关闭连接让它返回。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 标准库 asyncio;线程不能取消 |
标准库 AbortController |
标准库 CancellationToken |
标准库 context |
| 实现机制 | 在 await 点 throw 进协程 |
事件派发 + 各 API 自行清理 | 源对象 + 登记的回调 | 关闭 Done 通道广播 + context 树 |
| 取消信号的载体 | task.cancel() 记在任务上,到达时是 CancelledError |
AbortSignal |
CancellationToken |
context.Context |
| 超时的写法 | async with asyncio.timeout(s) |
AbortSignal.timeout(ms) |
new CancellationTokenSource(TimeSpan) |
context.WithTimeout(ctx, d) |
| 取消如何到达子任务 | TaskGroup 取消组内任务 |
把 signal 传下去,AbortSignal.any 合并 |
令牌传下去,CreateLinkedTokenSource |
从父 ctx 派生,传下去 |
| 结构化并发 | TaskGroup(3.11) |
没有;Promise.allSettled 手动等 |
没有;Task.WhenAll 手动等 |
没有;WaitGroup 手动等 |
| 不配合取消的代码会怎样 | 吞掉异常或阻塞调用:取消失效 | 工作继续,只是没人等结果 | 没传令牌:跑完才返回 | 不查 Done():跑完或成孤儿 |
最大的差异在“结构化并发”一行:只有 Python 的标准库提供了现成的结构化作用域(TaskGroup),另外三种语言要靠程序员每次都记得等。
C# 的取消是和任务一起设计的。2010 年 .NET Framework 4.0 推出 TPL 时,微软同时给出 CancellationTokenSource 和 CancellationToken,并把“源和令牌分开、取消靠协作”写成统一的取消模型;后来的 async API 几乎都照这个模式加上令牌参数,编译器的代码分析还会提醒转发。后果是 .NET 的库普遍支持取消,问题集中在应用代码忘了往下传。
Go 先有 goroutine,后有 context。2009 年的 go 语句不返回任何句柄,既不能等,也不能取消。Google 内部为请求处理写了 context 包,2014 年作为扩展库公开(即今天的 golang.org/x/net/context),同年 Go 官方博客发文介绍;2016 年的 Go 1.7 把它并入标准库,net/http 的请求和 os/exec 随之支持,database/sql 在 1.8 加上带 Context 的方法。此前已有的接口没法改签名,于是标准库里一部分 API 有 ctx 版本,一部分要靠截止时间或关闭连接来打断。goroutine 泄漏在 Go 里常见,根源就是 go 语句没有约定谁来等、谁来停。
Python 的 asyncio 从一开始就能 cancel(),但和 create_task 一样是“启动后不管”的风格。Trio 证明了托儿所加取消作用域的可行性,Python 3.11 把 TaskGroup 和 asyncio.timeout 加进标准库,后者在语义上接近 Trio 的取消作用域。旧接口仍然保留,所以同一个代码库里常有两种写法并存。线程没有取消,原因和概念一节说的一样:从外面打断一条线程并不安全。
JS 的 Promise 在 ES2015 标准化时没有取消;TC39 有过可取消 Promise 的提案,2016 年被撤回。取消最终由浏览器为 fetch 引入:WHATWG 的 DOM 标准在 2017 年加入 AbortController,Node 再把它搬过来。它是一个通用的信号对象而不是 Promise 的一部分,所以每个 API 都要单独接受 signal,第三方库支持得参差不齐。这些历史决定了后果:C# 的库普遍可取消;Go 要靠 context 纪律防泄漏;Python 在 3.11 之后才有标准的结构化写法;JS 最容易出现“不再等待但仍在运行”的工作。
旁注:别的语言
Section titled “旁注:别的语言”Kotlin 协程把结构化并发做成了默认规则。launch 和 async 是 CoroutineScope 上的扩展函数,必须在某个作用域里才能调用;coroutineScope { } 等到所有子协程结束才返回,一个子协程失败,其余的被取消。要启动不受管的协程,得显式写 GlobalScope.launch,库把它标成需要特别注意的 API。
withTimeout(600) { // 600 毫秒时取消块内的一切 coroutineScope { // 三个子协程都结束才返回 launch { delay(50) }; launch { delay(150) }; launch { delay(2000) } }} // 抛出 TimeoutCancellationExceptionKotlin 的取消同样是协作式的:delay 这类挂起函数会检查取消,纯计算的循环要自己调用 ensureActive()。
- 凡是可能等很久的函数,都接收一个取消参数并往下传:Python 的协程天然可取消,JS 加
signal参数,C# 加CancellationToken参数,Go 把ctx放在第一个参数。 - 并发启动子任务时,用一个作用域把它们框起来:Python 用
TaskGroup,JS 用Promise.allSettled加同一个 signal,C# 用Task.WhenAll加同一个令牌,Go 用WaitGroup加同一个ctx;不要留下没人等的任务。 - 捕获取消后做完清理就重新抛出或返回:Python 不吞
CancelledError,C# 捕获OperationCanceledException而不是它的子类,JS 用signal.aborted区分取消和故障,Go 返回ctx.Err()。 - 计算型循环和阻塞调用不会自己响应取消:循环里定期检查信号;Python 的阻塞调用换成异步版本,或接受“只能不再等它”。
- 系统里大量是需要随时整体取消的请求树,而所用语言的库普遍不支持取消时,每个调用点都要手工补信号,取消会成为长期的维护负担;这类系统值得考虑默认结构化的写法,例如 Python 的
TaskGroup或 Kotlin 的协程作用域。
写一个程序:启动一个需要 1 秒才能完成的任务,超时设为 100 毫秒;任务要真的被取消,并由任务自己确认收到了取消;程序打印 超时,任务已取消 后立即结束。用 C# 和 Go 各写一遍。
提示:把 1 秒的工作拆成 100 步、每步 10 毫秒,每一步之前检查一次取消信号。C# 里用哪个方法在已取消时抛出异常?捕获哪种异常才不会漏?Go 里任务怎样把“因为超时而停下”告诉主程序,主程序又怎样判断原因是超时而不是别的错误?
- 从外面强行终止任务会留下不一致的状态,所以四种语言都用协作式取消:发出信号,任务在等待点或检查点自己退出;超时就是由计时器发出的取消。
- 取消信号组织成树,父取消则子取消;结构化并发要求子任务的生命周期不超过父作用域,从而不留孤儿、不丢错误。
- Python 在
await点把CancelledError抛进协程,3.11 起用asyncio.timeout和TaskGroup;JS/TS 把AbortSignal传给每个操作;C# 把CancellationToken一路传下去;Go 派生context并在select里等ctx.Done()。 - 常见的坑都是“取消没送到”:Python 吞掉
CancelledError或调用阻塞函数,JS 只是不再等待,C# 没把令牌传给下层,Go 忘了cancel或不检查Done()。 - 差异来自历史:C# 的令牌和 TPL 一起设计,Go 在 goroutine 之后补了 context,Python 从 Trio 借来
TaskGroup,JS 的AbortController来自浏览器的fetch。