取消、超时与优雅退出:最难的那部分
「取消一个任务」听起来是最简单的操作。它是这本书里最难的一章。原因只有一句话:没有人能强行杀死一个正在运行的任务。你只能请它自己停下来 —— 而它可能不听,可能听不见,可能听见了但正卡在一个没法停的地方,可能停下来了但把数据改坏了一半。从 Java 废弃 Thread.stop() 的那天起,整个行业就接受了一个事实:取消只能是协作式的。这一章讲清这个协作的全部规则。
为什么不能强杀
Java 有过 Thread.stop()。它真的能强行终止一个线程。它在 Java 1.2(1998)就被废弃了,而且是 Java 历史上被废弃得最坚决的一个 API。
// 假设线程正在执行这段代码:
synchronized (account) {
account.balance -= 100; // ← 就在这一行的【正后面】被强杀了
recipient.balance += 100; // ← 这一行永远不会执行
}
// 结果:
// 1. 100 块钱【凭空消失了】
// 2. 锁被释放了(JVM 会释放),但它保护的数据【已经处于不一致状态】
// 3. 而其他线程会拿到这把锁,然后基于【坏掉的数据】继续工作
//
// 你不是杀死了一个线程。你是【把整个系统的一致性炸了】。
你不知道它被杀的时候,正处在什么状态。
- 它可能持有着一把锁(强杀释放锁 → 别人拿到一个被改坏一半的数据结构)
- 它可能正在写文件(写了一半)
- 它可能正在改一个链表(指针改了一半)
- 它可能刚从数据库扣了钱,还没记账
所以取消必须是协作式的:由那个任务自己,在一个它知道是安全的点上,主动停下来。
这不是一个「设计得不够好」的问题。这是一个逻辑上的必然。—— 除非你能做到「进程级别的隔离」,就像 Erlang(每个 Actor 有自己的堆,杀了它不影响任何人)。而这正是 Erlang「let it crash」哲学能成立的前提:隔离是可恢复的前提(第 11、24 章)。
协作式取消的三个环节
取消要真正生效,需要三样东西同时到位。缺任何一个,取消就是个摆设。
| 环节 | Kotlin | Go | Python | |
|---|---|---|---|---|
| ① | 发出信号 「请你停下来」 |
job.cancel()withTimeout(5.s) |
cancel()(本质: close(done)) |
task.cancel()asyncio.timeout(5) |
| ② | 信号被传播下去 一路传到最深处 |
✅ 自动(Job 树) | ❌ 手动 你必须把 ctx 一路传 |
✅ 自动(TaskGroup) |
| ③ | 被调方真的检查它 这一步最常被漏掉 |
✅ 半自动 每个挂起点自动检查 但 CPU 循环里要手写 ensureActive() |
❌ 全手动select { case <-ctx.Done(): } |
✅ 半自动 每个 await 自动检查但同步阻塞调用里注入不进去 |
「超时」和「取消」不是一回事
这是本章最重要的一节。
// 你以为你做了什么: ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() result, err := slowService.Call(ctx) // 100ms 超时 // 「超时了,我就不等了,快速失败」 ✓ // 你实际上做了什么 —— 取决于 slowService 有没有真的检查 ctx:
你以为你在「保护自己 + 快速失败」—— 实际上你在用完全无用的负载压垮下游。
而且更糟:客户端会重试。于是下游同时承受着「超时但还在跑的旧请求」+「重试的新请求」。
这是雪崩最典型的成因之一。
「超时」在应用层,「取消」必须传到网络层。
// ❌ 弱超时
resp, _ := http.Get(url) // 用的是 context.Background()
// ✅ 强超时:ctx 传进 HTTP 请求
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req) // ctx 取消 → 【socket 真的被关掉】
// → 对端的 TCP 连接断了
// → 对端的 handler 的 r.Context() 也被取消了
// → 【取消一路传到了对端的数据库查询】
// ✅ 数据库也一样
rows, err := db.QueryContext(ctx, "SELECT ...") // 不是 db.Query!
// PostgreSQL 驱动会真的发一个 CancelRequest 给服务器。
这条「取消传播链」能不能一路走通,是区分「玩具系统」和「生产系统」的一条硬线。
怎么验证?做个实验:让你的服务超时,然后看下游的 QPS 有没有下降。如果没有 —— 你的超时是弱的。
取消之后:清理代码的困境
取消发生了。现在你要清理:关连接、回滚事务、删临时文件。但你已经被取消了 —— 你还能执行代码吗?
suspend fun process() {
val conn = openConnection()
try {
conn.query(...) // ← 在这里被取消了
} finally {
conn.close() // ✅ finally 会执行
logCleanup() // ❌ 如果 logCleanup 是 suspend 的 ——
} // 它会【立刻抛 CancellationException】!
} // 因为协程已经处于「取消中」状态,
// 任何新的挂起调用都会立刻失败。
// ✅ 正确:在 NonCancellable 上下文里做需要挂起的清理
suspend fun process() {
val conn = openConnection()
try {
conn.query(...)
} finally {
withContext(NonCancellable) { // ← 「这一段不许被取消」
conn.closeAsync() // suspend 的清理,现在能跑完了
logCleanup()
}
}
}
用它,因为清理必须完成。
但绝对不要用它包住业务逻辑 —— 那等于宣布「这一段永远无法取消」。如果里面有个慢查询,你的服务就永远关不掉了。
规则:NonCancellable 里的代码必须是「快且有界」的。关个连接、写条日志、发个指标。不要在里面发网络请求。
func process(ctx context.Context) error {
conn := openConnection()
defer conn.Close() // ✅ defer 一定会执行
if err := doWork(ctx); err != nil {
// ❌ 用已经取消的 ctx 去做清理?那个清理调用会立刻失败。
// cleanup(ctx)
// ✅ 用一个【新的、干净的】ctx 做清理
cleanCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), // ← Go 1.21+:保留 value,去掉取消
5*time.Second,
)
defer cancel()
cleanup(cleanCtx)
return err
}
return nil
}
async def process():
conn = await open_connection()
try:
await do_work()
finally:
# ❌ 如果已经被取消了,这个 await 会立刻抛 CancelledError
# await conn.close()
# ✅ shield 保护清理代码不被取消
await asyncio.shield(conn.close())
# 或者更稳:
# await asyncio.wait_for(asyncio.shield(conn.close()), timeout=5)
「取消传播」和「清理必须完成」是天然矛盾的。三门语言都提供了同一个逃生舱:
- Kotlin:
withContext(NonCancellable) { ... } - Go:
context.WithoutCancel(ctx)(1.21+) - Python:
asyncio.shield(...)
它们的语义完全一样:「这一小段,不许被取消。」
而且它们的使用纪律也完全一样:只用于清理,必须快,必须有超时兜底。
三个最常见的「取消被吞掉」
// ❌ runCatching 抓的是 Throwable —— 它会吞掉 CancellationException
val result = runCatching { api.call() }
// 取消信号被吃了。协程「以为自己成功了」,继续往下跑。
// ❌ 同一个坑的另一个面目
try {
api.call()
} catch (e: Exception) { // CancellationException 是 Exception 的子类!
log.error(e)
}
// ✅
try {
api.call()
} catch (e: CancellationException) {
throw e // ← 先放取消过去
} catch (e: Exception) {
log.error(e)
}
# Python 3.8 起,CancelledError 继承自 BaseException(不是 Exception)——
# 所以 `except Exception` 【抓不到】它。这是个正确的设计。
# ✅ 这样写是安全的:
try:
await api.call()
except Exception as e: # 抓不到 CancelledError,很好
log.error(e)
# ❌ 但这样就吞了:
try:
await api.call()
except BaseException: # ← 抓到了 CancelledError
log.error("出错") # ← 而且没有 re-raise
# ✅ 需要清理的话:
try:
await api.call()
except asyncio.CancelledError:
await cleanup()
raise # ← 必须重新抛出!
// ❌ 把「被取消」当成普通错误吞掉
result, err := doWork(ctx)
if err != nil {
log.Warn("失败了,用默认值") // ← 但如果 err 是 context.Canceled 呢?
return defaultValue, nil // 你把「服务正在关闭」当成了「查询失败」
}
// ✅ 区分对待
result, err := doWork(ctx)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err // 取消/超时 → 往上传,不要吞
}
if err != nil {
log.Warn("真的失败了,用默认值")
return defaultValue, nil
}
优雅退出:一份完整的清单
服务收到 SIGTERM(K8s 滚动更新、容器重启)后,正确的行为是:
| 顺序 | 做什么 | 为什么 |
|---|---|---|
| 1 | 从负载均衡里摘掉自己 (健康检查开始返回 503) | 先停止接新流量。很多人漏了这一步,导致刚开始关闭就有新请求进来。 (K8s 里: preStop hook + sleep 5,给 LB 一点时间反应。) |
| 2 | 停止 accept 新连接 | 但不要关掉已建立的连接 |
| 3 | 等待正在处理的请求完成 (给一个上限,比如 30 秒) | 这是「优雅」的全部含义。不要把用户的请求打断在一半。 |
| 4 | 超过上限还没完的,强制取消 | 不能无限等 —— K8s 会在 terminationGracePeriodSeconds(默认 30s)后发 SIGKILL,那时候你就没有任何机会了。 |
| 5 | 关闭下游资源 (DB 连接池、消息队列、缓存) | 按依赖的反序关。 |
| 6 | flush 日志和指标 | 最容易忘的一步。你的最后一条日志(可能正好解释了为什么要重启)经常就是这么丢的。 |
func main() {
srv := &http.Server{Addr: ":8080", Handler: handler}
// 监听信号
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done() // ← 等 SIGTERM
log.Info("收到关闭信号,开始优雅退出")
// 1. 先让健康检查返回 503(让 LB 摘掉我们)
health.SetNotReady()
time.Sleep(5 * time.Second) // ← 给 LB 反应的时间。【这一步经常被忘】
// 2~4. 停止 accept + 等待在途请求(最多 25 秒)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Error("优雅退出超时,强制关闭", err)
}
// 5. 关下游
db.Close()
mq.Close()
// 6. flush
logger.Sync()
metrics.Flush()
log.Info("退出完成")
}
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
Runtime.getRuntime().addShutdownHook(Thread {
runBlocking { // ← 这是 runBlocking 的正当用途之一
health.setNotReady()
delay(5.seconds) // 给 LB 时间
withTimeoutOrNull(25.seconds) {
appScope.coroutineContext.job.children.forEach { it.join() }
} ?: log.warn("优雅退出超时")
appScope.cancel() // 强制取消剩下的
db.close()
logger.flush()
}
})
如果你的服务被强杀(SIGKILL、OOM、机器宕机),正在处理的那些请求会怎么样?
- 如果你已经从消息队列里 ack 了消息,但还没处理完 —— 这条消息永远丢了。
- 如果你已经扣了库存,但还没创建订单 —— 数据不一致。
优雅退出能覆盖「计划内的关闭」(99% 的情况:部署、扩缩容)。
但它覆盖不了「非计划的死亡」(OOM、宕机、网络分区)。那个只能靠幂等 + 重试 + 事务来兜底 —— 而那已经是分布式系统的话题了,不是并发的话题。
但值得记住这条边界:并发编程的正确性,止步于进程的边界。跨过那条线,你需要的是完全不同的一套工具。
1. 验证你的超时是不是「强超时」:让服务超时,看下游 QPS 有没有降。这个实验很多团队从来没做过,而结果经常是令人不安的。
2. 给你的服务发一个 SIGTERM,看它怎么死的。在途的请求被打断了吗?日志 flush 了吗?如果它是「立刻退出」—— 你每次部署都在丢请求。
3. 搜 catch (e: Exception) / except BaseException / 吞掉 context.Canceled 的地方。每一处都是一个「取消失效」的 bug。
4. K8s 用户:检查你的 terminationGracePeriodSeconds 和你代码里的 shutdown timeout。如果代码里等 60 秒,而 K8s 30 秒就 SIGKILL —— 你的优雅退出是假的。
下一章:把前面所有的坑集中起来解剖。十种线上并发 bug,每一种都给:症状、成因、复现、修法,以及怎么在 code review 里一眼认出它。