
Go 服务里的资源泄漏,很多不是因为忘记写 Close,而是取消路径没有被认真处理。HTTP 请求超时、上游连接断开、任务被调度器取消时,业务逻辑可能还没有走到 defer,事务、临时文件、连接和后台协程就已经处在不确定状态。
context.AfterFunc 可以在 Context 被取消时自动触发一段回调逻辑。它看起来很小,但生产代码里要特别注意三个边界:回调会在独立 goroutine 中执行,stop 只尝试停止回调,清理逻辑必须能处理并发触发。
最朴素的取消清理,是单独启动一个 goroutine 等待 <-ctx.Done(),然后执行 cleanup()。这能工作,但每注册一次清理都要创建一个 goroutine,而且调用方没有统一的停止句柄。正常路径完成后,如何阻止取消回调、如何判断回调是否已经开始、如何等待清理结束,都需要额外约定。
context.AfterFunc 把这个模式收进标准库:
stop := context.AfterFunc(ctx, func() {
cleanup()
})
defer stop()
它的语义是:当 ctx 被取消时,在独立 goroutine 中执行回调函数。如果 ctx 注册前已经取消,回调也会被立即安排到独立 goroutine 中执行。
返回的 stop 函数用于停止 ctx 与回调之间的关联。stop() 返回 true,表示回调尚未运行并且已经被阻止;返回 false,表示回调可能已经启动,也可能之前已经停止。关键点在于:stop 不等待已经启动的回调结束。
stop 的返回值更像一次竞争结果:true 表示成功阻止回调,false 只表示没有阻止成功。它不说明回调执行到了哪一步,也不代表清理已经结束。
因此,凡是清理逻辑会影响后续可见状态,就不能只依赖 stop。比如关闭连接、删除临时文件、回滚事务、释放分布式锁,都要考虑正常路径和取消路径同时触发。
如果调用方需要确认清理已经结束,可以显式增加完成信号:
done := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
defer close(done)
cleanup()
})
正常路径也要处理 stop() 成功的情况,否则等待方可能永久阻塞:
if stop() {
close(done)
}
<-done
这套写法把 stop 的“尝试停止”语义,补成了业务上更常需要的“清理路径已收敛”语义。
AfterFunc 常用于“取消发生时补一刀”,但补刀不代表清理函数可以随便写。生产代码里,正常路径和取消路径经常会争抢同一个资源。
以事务为例,业务成功时要提交,取消时要回滚。取消和提交可能同时发生,回滚兜底必须最多执行一次:
var once sync.Once
stop := context.AfterFunc(ctx, func() {
once.Do(func() { _ = tx.Rollback() })
})
defer func() {
if stop() { once.Do(func() { _ = tx.Rollback() }) }
}()
这段代码的重点不是“取消时一定回滚”,而是“任何路径最多触发一次回滚”。数据库事务通常允许提交后回滚返回错误,但业务代码不应把安全性寄托在具体驱动的容错行为上。
如果提交成功,可以调用 stop() 阻止取消路径继续回滚。但提交成功后的 stop() 仍然可能返回 false,因为取消回调可能已经开始。业务代码仍要依靠 sync.Once 或明确状态机避免重复操作。
并不是所有 defer 都应该改成 AfterFunc。它更适合处理“只在取消发生时才需要额外执行”的动作,或者把取消信号桥接到不支持 Context 的旧接口。比如某个阻塞调用只暴露 Close,没有接收 Context 参数,可以在取消时关闭底层连接:
stop := context.AfterFunc(ctx, func() {
_ = conn.Close()
})
defer stop()
连接关闭后,阻塞的 Read 或 Write 通常会返回错误,外层逻辑再根据 ctx.Err() 判断是否由取消引起。临时文件兜底清理也适合这个模式:正常路径接管文件后调用 stop(),取消路径只负责兜底删除,不参与主流程状态迁移。
当代码里多次出现 AfterFunc + done,可以封装一个小工具,把容易写错的边界集中起来:
func onCancel(ctx context.Context, fn func()) func() {
done := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
defer close(done); fn()
})
return func() {
if stop() { close(done) }
<-done
}
}
调用方拿到的不是原始 stop,而是一个“停止或等待完成”的函数,例如 wait := onCancel(ctx, cleanup) 后再 defer wait()。这个封装适合测试清理、临时文件清理、可重复关闭的网络资源等场景。如果清理函数本身不能重复执行,还要额外加 sync.Once。
context.AfterFunc 的价值,在于把“取消发生后触发动作”变成标准库能力。它让清理逻辑可以贴近资源创建处,减少额外 goroutine 和分散的 select 监听。
真正要掌握的是它的并发语义:回调独立执行,stop 只尝试停止,不等待完成;取消路径和正常路径可能同时触发;清理函数必须短小、幂等,并且在需要时提供完成信号。
把这些边界处理好,AfterFunc 就能成为 Go 服务里实用的资源生命周期工具,尤其适合连接关闭、事务兜底、临时文件删除和旧接口取消桥接。