幕 IV · 深水区CH 22进度 22/25

取消、超时与优雅退出:最难的那部分

「取消一个任务」听起来是最简单的操作。它是这本书里最难的一章。原因只有一句话:没有人能强行杀死一个正在运行的任务。你只能它自己停下来 —— 而它可能不听,可能听不见,可能听见了但正卡在一个没法停的地方,可能停下来了但把数据改坏了一半。从 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 章)。

协作式取消的三个环节

取消要真正生效,需要三样东西同时到位。缺任何一个,取消就是个摆设。

环节KotlinGoPython
发出信号
「请你停下来」
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:
弱超时:你不等了,但活还在跑被调方不检查 ctx
超时! 返回错误,处理下一个请求
下游服务 还在算!还占着连接!还在打数据库!最后返回一个没人要的结果。
灾难在哪 假设你超时了 1000 个请求/秒。那 1000 个请求仍然在打你的下游数据库。
你以为你在「保护自己 + 快速失败」—— 实际上你在用完全无用的负载压垮下游
而且更糟:客户端会重试。于是下游同时承受着「超时但还在跑的旧请求」+「重试的新请求」。
这是雪崩最典型的成因之一。
◆ 定论 · 强超时必须一路穿透到最底层

「超时」在应用层,「取消」必须传到网络层。

// ❌ 弱超时
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 是把双刃剑

用它,因为清理必须完成。

但绝对不要用它包住业务逻辑 —— 那等于宣布「这一段永远无法取消」。如果里面有个慢查询,你的服务就永远关不掉了。

规则: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 连接池、消息队列、缓存)
依赖的反序关。
6flush 日志和指标最容易忘的一步。你的最后一条日志(可能正好解释了为什么要重启)经常就是这么丢的。
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 里一眼认出它