Go context.AfterFunc 实战(Go 1.21):context 一取消就自动跑清理,告别手写 goroutine 监听 Done
写 Go 服务时,你大概率写过这样一段代码:开一个 goroutine 死等 ctx.Done(),一旦 context 被取消就去做收尾------关连接、退订阅、删临时文件。写多了你会发现这套模板到处复制粘贴,还容易漏掉细节:context 早就取消了你才注册怎么办?任务提前正常结束了,那个监听 goroutine 还挂着不就泄漏了?
Go 1.21 加了个 context.AfterFunc 专门收拾这类场景。这篇就把它讲透:能干什么、怎么用、stop 的返回值到底代表什么,以及三个真实会踩的坑。
朴素写法:自己开 goroutine 等 Done
假设你订阅了一个消息队列,context 取消时要退订:
go
func subscribe(ctx context.Context, topic string) {
sub := mq.Subscribe(topic)
// 开一个 goroutine 死等取消信号
go func() {
<-ctx.Done()
sub.Unsubscribe() // 收尾:退订
}()
// ... 正常消费逻辑
}
这段能跑,但有几个问题:
- 模板到处重复 :每处需要收尾的地方都要手写一遍
go func(){ <-ctx.Done(); ... }()。 - 没法取消这个监听 :如果消费逻辑正常结束了、根本没等到取消,这个 goroutine 会一直阻塞在
<-ctx.Done()直到 context 最终取消,期间白白占着一个 goroutine。 - 重复触发难控制:想在收尾前判断「是不是已经手动清理过了」得自己加锁加标志位。
正确写法:context.AfterFunc
AfterFunc 的签名很简洁:
go
func AfterFunc(ctx Context, f func()) (stop func() bool)
含义:当 ctx 被取消(cancel 或超时)后,在一个新的 goroutine 里调用 f。它返回一个 stop 函数,调用它可以取消这次注册。用它重写上面的例子:
go
func subscribe(ctx context.Context, topic string) (stop func() bool) {
sub := mq.Subscribe(topic)
// ctx 一取消就自动退订,不用自己开 goroutine
stop = context.AfterFunc(ctx, func() {
sub.Unsubscribe()
})
return stop
}
如果消费逻辑正常结束、你想主动收尾并解除这个注册,调用 stop() 即可,再也不用一个 goroutine 干等着。
stop 的返回值:true 和 false 分别代表什么
这是最容易理解错的地方。stop() 返回 bool:
- 返回
true:说明你赶在f执行之前 成功阻止了它------f不会再被调用。 - 返回
false:说明阻止失败 ,原因有二:要么f已经开始执行(或执行完)了,要么你之前已经调用过stop()。
注意:stop() 返回 false 时并不会等待 f 执行完 。如果你需要「确保 f 跑完再往下走」,得自己配合 channel 或 WaitGroup 同步。看个完整可跑的例子:
go
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
fmt.Println("清理逻辑执行了")
close(done)
})
// 场景 A:主动取消注册(任务提前正常结束)
if stop() {
fmt.Println("成功阻止,f 不会执行") // 走这里
} else {
<-done // f 已经在跑,等它结束
fmt.Println("f 已执行完")
}
cancel() // 此时再取消 ctx,f 也不会跑了,因为已经 stop
time.Sleep(50 * time.Millisecond)
}
输出只会打印「成功阻止,f 不会执行」。因为我们在 cancel() 之前就 stop() 了。
坑一:ctx 已经取消时注册,f 会「立刻」跑
AfterFunc 不要求你在 context 取消前注册。如果传进去的 ctx 已经是取消状态 ,f 会马上在一个新 goroutine 里被调度执行:
go
ctx, cancel := context.WithCancel(context.Background())
cancel() // 先取消
context.AfterFunc(ctx, func() {
fmt.Println("即使 ctx 已取消,我也会被执行") // 会打印
})
time.Sleep(10 * time.Millisecond)
这其实是优点:你不用像手写版那样担心「注册晚了错过取消信号」。但要意识到 f 是异步跑的,别假设 AfterFunc 返回时 f 已经结束。
坑二:f 在独立 goroutine 里跑,必须自己保证并发安全
f 和你的主逻辑是并发执行的。如果 f 里访问了主逻辑也在读写的共享变量,该加锁还得加锁,该用 atomic 还得用。别因为「它只是个清理函数」就掉以轻心------用 -race 一跑就现原形。
坑三:别在 f 里做重活或阻塞太久
f 是收尾用的,应该干净利落地关资源、发信号。如果你在里面跑一个可能长时间阻塞的操作,而调用方又靠 stop() 返回 false 后 <-done 去等它,就可能把调用方卡住。收尾逻辑本身如果也需要超时,记得自己再包一层带 deadline 的控制。
一个实用组合:用 AfterFunc 把「取消」转成 channel 信号
有时你想在 select 里同时等多个信号,AfterFunc 可以优雅地把 context 取消桥接成一个一次性 channel:
go
func cancelChan(ctx context.Context) <-chan struct{} {
ch := make(chan struct{})
context.AfterFunc(ctx, func() { close(ch) })
return ch
}
// 用起来:
select {
case <-cancelChan(ctx):
// 被取消
case msg := <-someOtherChan:
// 正常收到消息
}
当然 ctx.Done() 本身就是个 channel,这里更多是演示「桥接」这个思路------当你需要把取消动作和别的自定义回调绑在一起时就有用了。
小结
context.AfterFunc(ctx, f):context 取消后自动在新 goroutine 跑f,替代到处手写的go func(){ <-ctx.Done(); ... }()。- 返回的
stop():返回true表示成功阻止f;返回false表示f已在跑或已 stop 过,且不会等待f结束。 - 三个坑:ctx 已取消时
f会立刻异步跑;f与主逻辑并发,共享数据要自己加锁;f里别做长阻塞。 - 一句话记忆:AfterFunc 是「取消即回调」的官方模板,把监听 goroutine 的生命周期管理交给标准库,你只管写收尾逻辑和决定要不要 stop。