十行代码实现超时
context.Context 的职责是告诉代码什么时候该停下来。下面给一个慢操作 50 毫秒的时间,context 一发出信号,它就放弃:
第一次调用在 10 毫秒内完成,返回 rows <nil>。第二次需要 200 毫秒,但 context 在 50 毫秒时到期(从创建时开始计算),所以返回 context deadline exceeded。
没有任何东西会被强制停止。Go 无法从外部杀死一个 goroutine。context 只是一个信号,代码必须主动检查它:在 ctx.Done() 上 select,在各步骤之间检查 ctx.Err(),或者把 ctx 传给会替你检查的库函数(http.NewRequestWithContext、db.QueryContext、exec.CommandContext)。
Context 接口
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
| 方法 | 返回值 |
|---|---|
Done() | 一个在 context 被取消或超时时关闭的通道(对永远不会被取消的 context 为 nil) |
Err() | 有效期间为 nil,之后为 context.Canceled 或 context.DeadlineExceeded |
Deadline() | 截止时间和 true;没有截止时间时 ok == false |
Value(key) | 当前 context 或其祖先中以 key 存储的值,或 nil |
context 是不可变的。你永远不会修改一个 context;而是用某个 With 函数从它派生出子 context,子 context 再加上取消信号、截止时间或值。
context 从哪里来
每棵 context 树都从一个根开始:
context.Background()用于main、init、测试和服务器的顶层初始化。context.TODO()用于函数应该接收 context、但调用方暂时还没有的情况。它的行为和Background完全一样;这个名字只是方便以后重构时找到它的标记。
在 HTTP 处理函数中不需要创建根 context,而是使用 r.Context(),服务器会在客户端断开连接或处理函数返回时取消它。
WithCancel:按需停止
context.WithCancel 返回一个子 context 和一个 cancel 函数。调用 cancel 会关闭子 context 的 Done 通道,以及所有从它派生出的 context 的 Done 通道。
生产者的发送操作和 ctx.Done() 放在同一个 select 中,这正是它能够停下来的原因:如果只写 out <- i,一旦消费者停止读取,它就会永远阻塞,这个 goroutine 也就泄漏了。最后的 for range nums 会一直等到生产者关闭通道。在取完剩余数据的过程中,生产者可能还会成功发送一两个值,因为当 select 的两个分支同时就绪时,Go 会随机选一个;取消是及时的,但不是瞬间的。
cancel 可以安全地多次调用,也可以从任意 goroutine 调用。只有第一次调用会起作用。
WithTimeout 和 WithDeadline
WithTimeout(parent, d) 等价于 WithDeadline(parent, time.Now().Add(d))。表达“最多这么长时间”用超时,有一个绝对时间点时用截止时间。
时间一到,Done 就会关闭,Err 返回 context.DeadlineExceeded。如果先调用了 cancel,Err 返回 context.Canceled。请用 errors.Is 判断是哪一种,因为库通常会包装这个错误:
一定要调用 cancel,即使超时会自己触发也一样。在两者之一发生之前,context 会一直占着一个定时器和父 context 中的一个位置,而 defer cancel() 能在函数返回时立即释放这两者。go vet 会报告被丢弃的 cancel 函数:the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak。
子 context 不能比父 context 活得更久
context 构成一棵树。取消父 context 会取消它的所有后代。子 context 的截止时间可以比父 context 更早,但不能更晚:更早的截止时间总是生效。
这正是 context 在多层代码之间有用的原因。HTTP 处理函数拿到一个随请求结束而失效的 context;三层之下的数据库调用从中派生出一个 2 秒的超时。如果客户端在 100 毫秒后挂断,查询就会在那时被取消,而不是两秒之后。
阻塞时一定要在 ctx.Done() 上 select
任何需要等待的 goroutine(等待通道发送、接收或定时器)都应该同时等待 ctx.Done()。对于从不阻塞的 CPU 密集型循环,每隔一段时间检查一次 ctx.Err():
for i, item := range items {
if i%1000 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
process(item)
}
简单的等待可以在 select 中使用 time.After,但如果等待可能经常被取消,最好使用可以停止的定时器(或 context 超时)。
取消原因(Go 1.20 和 1.21)
ctx.Err() 只会告诉你 canceled 或 deadline exceeded。要记录具体原因,请使用 Cause 系列函数:
WithCancelCause 在 Go 1.20 中加入,WithTimeoutCause 和 WithDeadlineCause 在 Go 1.21 中加入。Err 仍然返回标准值,所以已有的检查照常工作;context.Cause 提供详细信息。
谨慎使用 WithValue
context.WithValue(parent, key, value) 附加一个值。ctx.Value(key) 会沿着父 context 链查找它。
关于值的规则:
- 键要使用未导出的类型,绝不要用普通的
string。两个包如果都用"user",就会互相覆盖。(go vet发现不了这个问题,staticcheck可以。) - 用
WithRequestID和RequestID这样的带类型辅助函数封装访问,让调用方永远看不到any或键本身。 - 只存放随 API 传递的请求范围数据:追踪 ID 和请求 ID、已认证的用户、日志记录器。绝不要存可选参数、数据库句柄或配置。这些应该放在函数参数或结构体字段中,编译器能检查它们,读代码的人也能看到它们。
- 查找时会沿着链一层一层往上走,所以每多加一个值,查找其他值时就要多走一步。
惯例
- 任何执行 I/O、会阻塞或调用此类操作的函数,都把
ctx context.Context作为第一个参数:func Fetch(ctx context.Context, url string) error。 - 不要把 context 存在结构体中。 在每次方法调用时传入它。一个 context 属于一次操作,而结构体的生命周期通常比它长。(例外是代表单次操作的类型,比如
http.Request。) - 绝不要传
nil作为 context。如果没有更好的选择,就用context.TODO()。 - 因 context 而停止时,返回
ctx.Err(),或用%w包装它,让调用方能区分超时和真正的失败。
HTTP 服务器和客户端中的 context
服务器端:客户端断开连接、处理函数返回或 HTTP/2 流被重置时,r.Context() 会被取消。客户端:http.NewRequestWithContext 让请求遵守超时或取消。下面的程序通过 httptest 同时运行两端:
客户端在 50 毫秒时放弃并关闭连接。服务器察觉到这一点,它的请求 context 被取消,处理函数随即停止,而不是再花 450 毫秒去生成一份没人会看的报告。在真实的处理函数中,你会把 r.Context() 一路传给每个数据库调用和 HTTP 调用,它们会一起停止。
其他辅助函数(Go 1.21)
context.WithoutCancel(ctx)返回一个带有相同值、但不会随ctx一起被取消的 context。适用于请求结束后仍必须完成的工作,比如写审计日志。context.AfterFunc(ctx, f)在ctx结束后,在单独的 goroutine 中运行f,并返回一个用于取消注册的stop函数。
常见错误
- 没有调用
cancel。 在WithCancel、WithTimeout或WithDeadline之后,务必紧接着写defer cancel()。 - 启动一个忽略
ctx的 goroutine。 如果它阻塞时没有在ctx.Done()上 select,取消就不起作用,goroutine 也会泄漏。 - 在调用链深处新建
context.Background()。 这会切断与调用方截止时间和取消信号的联系。请传递你拿到的ctx。 - 用
==比较错误。 请使用errors.Is(err, context.DeadlineExceeded);大多数库都会包装这个错误。 - 用
WithValue传递依赖。 藏在 context 里的数据库句柄,就是一个编译器再也无法检查的参数。 - 以为取消是瞬间完成的。 代码只有在下一次检查时才会察觉。没有检查的长循环会一直运行下去。
常见问题
Go 中的 context 是做什么用的?
context.Context 告诉一个函数以及它调用的所有代码什么时候该放弃:可能是调用方取消了,可能是截止时间到了,也可能是客户端断开了连接。它还可以携带请求范围的值,比如请求 ID。按照惯例,它是第一个参数,命名为 ctx。
context.Background 和 context.TODO 有什么区别?
两者都返回一个永远不会被取消、没有截止时间也没有值的空 context,行为完全相同。Background() 是 main、测试和顶层初始化代码的根 context。TODO() 标记一个本该传入真正 context、但外围代码暂时还没有的地方,方便以后找到它。
为什么调用 context.WithTimeout 之后必须调用 cancel?
WithTimeout、WithDeadline 和 WithCancel 会把新 context 注册到它的父 context 上,还可能启动一个定时器。调用 cancel 可以在你用完后立即释放这些资源,而不是等到超时触发或父 context 被取消。创建后紧接着写 defer cancel();丢弃 cancel 函数时 go vet 会给出警告。
Go 中的“context deadline exceeded”是什么意思?
它是 context.DeadlineExceeded 的文本,也就是 context 的截止时间过去之后 ctx.Err() 返回的错误。遵守 context 的函数(例如 HTTP 客户端和数据库驱动)在超时后会返回它(通常经过包装)。用 errors.Is(err, context.DeadlineExceeded) 来判断。
应该用 context.WithValue 传递参数吗?
不应该。它只适合跨越 API 边界、中间的函数无需了解的请求范围数据,比如追踪 ID 或已认证的用户。函数完成工作所需的一切都应该放在参数里,由编译器来检查。