阅读契约:先记住一句话:cancel() 只是发出“请停止”的信号,不会瞬间杀死 goroutine,也不会替你等待它结束。读完前半篇,你应能把结束过程分成发信号、worker 观察并返回、清理资源、等待全部结束四步。

前半篇先讲调用者必须遵守的结束协议;传播细节再固定到 Go 1.26.0 tagcontext/context.gonet/http/server.go;API 契约来自 context 文档Server.Shutdown 文档。 字段布局、fallback goroutine 与 Shutdown 的 polling 是当前实现;取消传播、Done/Cause/Err 与 Shutdown 公共语义才是调用者契约。

一、按下取消键后,程序还要做三件事

想象 fetchd 同时抓取两个网站,1.5 秒 deadline 到了。context 会关闭 Done(),像广播室亮起“停止”灯; 但两个 worker 可能正在计算、等待网络,或者准备把结果发进 channel。它们必须在自己的阻塞点或 select 里看见这盏灯,然后主动返回。

worker 返回前还可能要关闭 response body、停止 timer 或释放临时资源。最后,创建这些 worker 的 handler 还要确认它们都已退出,才能安全结束。 所以完整顺序不是“cancel = done”,而是发信号 → 各自停止 → 各自清理 → 创建者等待

阶段谁负责完成信号常见误读
发布取消parent、deadline 或显式 cancelDone() closed以为 worker 已停止
观察并退出工作每个 worker / blocking API函数返回或 goroutine done只在循环外检查一次 ctx
cleanup资源拥有者Body/Conn/timer/临时资源关闭把 cleanup 交给 cancel 自动完成
join创建 goroutine 的 ownerWaitGroup、errgroup 或 done channelcancel() 当成 Wait

表格把这四步对应到具体负责人。context 是控制面,不是任务管理器。它用一个只读 channel 广播“不要再开始/继续无意义工作”,却不知道 goroutine 在做什么,也没有 goroutine handle 可以等待。 谁创建 goroutine,谁就必须同时设计它的停止点与 join 点。fetchd 的 handler 若提前返回,而 worker 仍尝试向无人接收的结果 channel 发送,泄漏仍会发生;第四篇用有界 buffer 解决了一种形状,但通用答案仍是取消 + 可退出 send + join。

二、Context 是树状查询接口,不是数据背包

Context 只有 DeadlineDoneErrValue 四个方法。derived context 指向 parent,取消沿树向下传播;Value 沿 parent 向上查找。 文档要求不要把 Context 存进 struct,不要传 nil,也不要用 Value 代替普通参数。Value 只适合跨 API 的 request-scoped 数据,例如 trace ID,而不是 timeout、logger 配置或可选开关的万能容器。

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

关闭 channel 是广播:任意数量 waiter 都能观察同一个事件;也因为只是关闭,没有把具体错误塞进 channel,所以接收者再调用 Err()Cause() 取原因。 Done() 允许返回 nil,表示永不取消;把 nil channel 放进 select 会永久禁用该 case,这正是 Background/TODO 与 WithoutCancel 的行为边界。

三、cancelCtx 把发布与子树管理放在一起

fetchd 的 1.5 秒预算到期:
context 第一次记下 Err / Cause,并关闭 Done
  → worker 在网络调用或 select 中看见信号
  → worker 关闭自己拥有的 response body 后返回
  → handler 收齐结果或 Wait
  → 此时才能证明所有 worker 已结束

下面的 propagateCancel 只解释停止信号怎样到达 child;它不替 worker 清理资源,也不提供 join。 先把 cancelCtx 当成一棵“发布停止通知”的树,再读 children、timer 与回调这些实现分支。

cancelCtx 嵌入 parent Context。doneatomic.Value 延迟创建 channel;从未调用 Done 的 context 无需分配 channel。err 也用 atomic load 服务高频检查,children map 与 cause 由 mutex 保护。 Err() 读到非 nil error 后还会接收一次 Done,确保调用者观察错误时 channel 已经关闭。

type cancelCtx struct {
    Context
    mu       sync.Mutex
    done     atomic.Value
    children map[canceler]struct{}
    err      atomic.Value
    cause    error
}
布局与延迟 Done:cancelCtx、Done 与 Err

3.1 propagateCancel 有三条传播路

建立 child 时先检查 parent.Done。parent 永不取消就无需注册;parent 已取消则同步取消 child。最常见的 parent 是标准库 *cancelCtx 或可追溯到它的派生类型,child 直接登记到 parent.children,取消时递归调用。 若 parent 提供 AfterFunc,child 注册回调并用 stopCtx 保存注销函数;只有无法识别的自定义 Context 才启一个 fallback goroutine,在 parent.Done 与 child.Done 之间 select。

cancelCtx 源码结构与传播:propagateCancel 优先注册到 parent children,其次使用 parent AfterFunc,最后才启动 fallback goroutine;cancel 发布错误、关闭 done、递归取消 children 并 removeChild
if p, ok := parentCancelCtx(parent); ok {
    p.mu.Lock()
    if p.children == nil {
        p.children = make(map[canceler]struct{})
    }
    p.children[child] = struct{}{}
    p.mu.Unlock()
    return
}
if a, ok := parent.(afterFuncer); ok {
    c.mu.Lock()
    stop := a.AfterFunc(func() {
        child.cancel(false, parent.Err(), Cause(parent))
    })
    c.Context = stopCtx{Context: parent, stop: stop}
    c.mu.Unlock()
    return
}
go func() {
    select {
    case <-parent.Done():
        child.cancel(false, parent.Err(), Cause(parent))
    case <-child.Done():
    }
}()
完整路径:propagateCancel;父子识别与移除:parentCancelCtx / removeChild

调用返回的 CancelFunc 不只是提前关 Done。它还把 child 从 parent.children 删除,或注销 parent AfterFunc;deadline context 还会停止 timer。忘记调用 cancel,child 可能一直被 parent 引用到 parent 自己取消。 因此 ctx, cancel := context.WithTimeout(...); defer cancel() 的价值包括释放树节点和 timer,不只是“保险地再取消一次”。

3.2 第一次取消决定当前节点的 Err 与 cause

cancelCtx.cancel 在锁内检查 err:已设置就立即返回,所以每个节点 first cancel wins。第一次取消先保存 err/cause,再关闭或发布共享 closed channel,随后递归取消所有 children 并清空 map。 parent 先取消时,child 继承 parent cause;child 先以本地 cause 取消,则 child 保留本地原因,后来 parent 的原因不会覆盖它。

parent, cancelParent := context.WithCancelCause(context.Background())
child, cancelChild := context.WithCancelCause(parent)

cancelChild(errWorker)
cancelParent(errClient)

context.Cause(parent) // errClient
context.Cause(child)  // errWorker

ctx.Err() 故意保持小而稳定,只返回 context.Canceledcontext.DeadlineExceededCause(ctx) 才携带领域原因。 生产日志和 response mapping 可以同时记录两者:Err 用于分类生命周期,Cause 用于解释“客户端断开”“上游预算耗尽”或“worker 校验失败”。不要把敏感 payload 塞进 cause 后无差别回传给客户端。

3.3 timerCtx 把 deadline 变成一次取消

WithDeadlineCause 先比较 parent deadline:父级更早时直接返回一个 WithCancel child,不再加更晚的 timer。否则创建 timerCtx,登记到父树,并用 time.AfterFunc 在到期时以 DeadlineExceeded 和指定 cause 取消。 手动 cancel 会先走 cancelCtx,再 removeChild、停止并清空 timer。

type timerCtx struct {
    cancelCtx
    timer *time.Timer
    deadline time.Time
}

func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
    c.cancelCtx.cancel(false, err, cause)
    if removeFromParent { removeChild(c.cancelCtx.Context, c) }
    c.mu.Lock()
    if c.timer != nil { c.timer.Stop(); c.timer = nil }
    c.mu.Unlock()
}
WithDeadlineCause 与 timerCtx

3.4 AfterFunc 的 stop 只赢得“是否开始”竞争

context.AfterFunc(ctx, f) 用内嵌 cancelCtx 订阅 parent,并用一个 sync.Once 在“stop 阻止执行”和“cancel 启动 go f()”之间裁决。 stop 返回 true 表示它阻止了 f 开始;返回 false 表示 f 已开始,或之前已 stop。最容易漏掉的一句 API 文档是:stop 不等待 f 完成

Go timerCtx 与 context.AfterFunc 边界:较早 parent deadline 避免新 timer,手动 cancel 停 timer;AfterFunc 用 sync.Once 决定 stop 或 go f,stop=false 不等待已运行回调
stop := context.AfterFunc(ctx, func() {
    close(started)
    cleanup()
    close(done)
})

if !stop() {
    <-done // 需要完成语义时,自己 join
}

同一个 Context 上的多个 AfterFunc 彼此独立,也没有执行顺序保证。f 运行在自己的 goroutine,若它会拿锁,注册方不能持着同一把锁等待 done,否则很容易自锁。 适合场景是唤醒被 sync.Cond 或系统调用包裹的等待、触发轻量 cleanup;复杂生命周期仍应由拥有者 goroutine 和明确 join 管理。

3.5 WithoutCancel 切断控制面,也切断 deadline

context.WithoutCancel(parent) 仍向 parent 查询 Value,却让 Deadline 返回 none、Done 返回 nil、Err 与 Cause 返回 nil。它不是“忽略这一次 cancel”,而是完整切断父取消与 deadline。 适合 request 返回后仍需短暂完成的审计/投递,但必须立刻在外层套新的有界 timeout,并由进程级 owner join;否则 request leak 会变成 process leak。

detached := context.WithoutCancel(r.Context())
auditCtx, cancel := context.WithTimeout(detached, 2*time.Second)
defer cancel()
return writeAudit(auditCtx, event)

四、HTTP request context 的关闭点

HTTP/1 server 在 connection 上先创建 connection context;每次 readRequestcontext.WithCancel(ctx),把 cancel func 存进 response。文档保证 incoming request 的 context 在客户端连接关闭、HTTP/2 request 被取消或 ServeHTTP 返回时取消。 HTTP/1 实现中 handler 返回后立刻调用 w.cancelCtx();connection reader 检测读错误时也会取消 connection context,从而向当前 request 传播。

ctx, cancelCtx := context.WithCancel(ctx)
req.ctx = ctx
w = &response{cancelCtx: cancelCtx, req: req, /* ... */}

serverHandler{c.server}.ServeHTTP(w, w.req)
w.cancelCtx()
request context 建立:readRequest;handler 返回后的取消:conn.serve

所以 handler 内创建的 worker 不应把 request context 保存到返回之后继续无限使用。handler 应先停止接收新工作,取消 child,等待 worker/errgroup,最后再 return;return 触发的 request cancel 是兜底,不是 owner 的 join 协议。 反过来,客户端断开可提前关闭 Done,但网络栈何时观察断开受协议与 I/O 状态影响,不能把它当毫秒级心跳。

4.1 Server.Shutdown 等 active,不中断 active

Shutdown 先标记 inShutdown、关闭 listener,让 Serve 返回 ErrServerClosed;并发启动 RegisterOnShutdown callbacks,等待 listener goroutine;随后关闭 idle connections,并以 1ms 起步、指数退避到最多 500ms 的 timer 轮询 active connections,直到它们回到 idle。 传入 Shutdown 的 context 只限制“等多久”,并不会成为每个 active request 的 parent,也不会自动取消 handler。

HTTP 请求和关闭生命周期:request context 在连接取消或 handler 返回时关闭,worker 清理并 join;Server.Shutdown 关闭 listener 和 idle connection、等待 active 变 idle,但不打断 active handler,也不等待 hijacked WebSocket
func (s *Server) Shutdown(ctx context.Context) error {
    s.inShutdown.Store(true)
    s.mu.Lock()
    lnerr := s.closeListenersLocked()
    for _, f := range s.onShutdown { go f() }
    s.mu.Unlock()
    s.listenerGroup.Wait()
    timer := time.NewTimer(nextPollInterval())
    defer timer.Stop()
    for {
        if s.closeIdleConns() { return lnerr }
        select {
        case <-ctx.Done(): return ctx.Err()
        case <-timer.C:    timer.Reset(nextPollInterval())
        }
    }
}
ShutdowncloseIdleConns

hijacked connection(包括常见 WebSocket 路径)不在 Shutdown 的关闭或等待范围。RegisterOnShutdown 只负责启动协议专用通知,callback 本身也是 goroutine,Shutdown 不等它结束;应用必须维护这些 connection/session 的 registry,并做自己的 broadcast + join。 main goroutine 也必须等待 Shutdown 返回,不能在 ListenAndServe 刚返回 ErrServerClosed 时直接退出进程。

五、实验把 signal、stop 与 join 分开

context_lab_test.gotesting/synctest 确定性证明 cancel 返回时 worker 仍可卡在 cleanup;释放 cleanup 并 Wait 后才完成。另一个测试让 AfterFunc callback 已启动但受 channel 阻塞,此时 stop 返回 false 且立即返回,显式 done 才代表完成。

cancel()
synctest.Wait()

select {
case <-workerDone:
    t.Fatal("cancel unexpectedly joined worker")
default:
}

close(cleanupRelease)
synctest.Wait()
<-workerDone

集成测试用一个会通知 Close 的 listener 精确知道 Shutdown 已关闭监听器;在 handler release channel 仍关闭前,Shutdown 必须未返回;释放 handler 后,request、Shutdown 与 Serve 分别以成功、nil 与 ErrServerClosed 结束。 测试不依赖“睡 50ms 看看”,2 秒 timer 只作为失败保险。

cd go-runtime/examples/fetchd
go test -run 'Test(Cancel|Timeout|AfterFunc|ServerShutdown)' -count=20
go test ./...
go test -race ./...
go vet ./...

六、回到 fetchd 的结束清单

  1. 入口接受 ctx,出口不保存 ctx。Context 是调用链生命周期,不是可长期缓存的依赖。
  2. 创建 goroutine 同时创建 join。优先 errgroup/WaitGroup;只 cancel 没有完成语义。
  3. 所有可能阻塞点都能观察取消。网络 API 传 ctx,channel send/receive 用 select,循环定期检查。
  4. 永远调用 CancelFunc。即使 parent 稍后会取消,也要及时 removeChild、stop timer。
  5. Err 分类,Cause 解释。first cause 是诊断证据,但不要泄漏敏感数据。
  6. AfterFunc callback 有自己的完成信号。stop=false 之后需要等待就显式 join。
  7. WithoutCancel 后重新加预算。detached 工作必须受 process lifetime 管理。
  8. Shutdown 顺序是停止入口、通知后台、等待 active、最后退出。WebSocket/hijacked 自己维护 registry 与 join。

可复用结论是:context 解决的是取消状态与原因传播;正确结束还需要每个 owner 完成 cleanup,并用独立的 join 证明 goroutine 已退出。 下一篇会继续追问这些 worker 和 request 对象去了哪里:从逃逸分析、size class 与 mcache 读到 GC mark assist、write barrier 和可观测的 allocation pressure。

参考源码与文档