Go Context 的使用#
1. Context 本质说明#
Context 源码定义#
context.Context 本质上是一个接口,用来在多个函数、goroutine 或请求处理链路之间传递“上下文状态”。它最常见的用途不是传业务参数,而是传递取消信号、超时时间、截止时间以及少量跨调用链共享的数据。
可以把它理解为一个轻量的控制器:上游创建 Context,下游持续监听它。一旦上游决定取消任务,或任务超过指定时间,下游就可以及时停止工作,避免 goroutine 泄漏或无意义的资源消耗。
Context在 go 语言源码中定义如下:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{} // 只读 channel
Err() error
Value(key any) any
}这个接口只有四个方法:
| 方法 | 作用 |
|---|---|
Deadline() | 返回当前任务的截止时间,如果没有截止时间则 ok = false |
Done() | 返回一个只读 channel,当任务被取消或超时时会关闭 |
Err() | 返回取消原因,例如 context.Canceled 或 context.DeadlineExceeded |
Value(key) | 根据 key 读取上下文中携带的值 |
因此,学习 Context 的核心就是理解两件事:如何把取消/超时信号传下去,以及下游如何监听这个信号并及时退出。
emptyContext#
emptyCtx 是标准库里最基础的空 Context 实现。它实现了 Context 接口要求的四个方法,但这些方法都只返回“空语义”:没有截止时间、没有取消信号、没有错误、也没有携带任何值。后面的 backgroundCtx 和 todoCtx 都是通过嵌入 emptyCtx,直接获得这四个方法,从而成为可用的 Context。源代码如下所示:
type emptyCtx struct{}
func (emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}
func (emptyCtx) Done() <-chan struct{} {
return nil
}
func (emptyCtx) Err() error {
return nil
}
func (emptyCtx) Value(key any) any {
return nil
}Context 的两个祖先:Background 与 TODO#
Background() 和 TODO() 是标准库提供的两个最基础的根 Context。它们本身不会主动取消、没有截止时间,也不携带任何值。
从源码上看,backgroundCtx 和 todoCtx 并没有各自重新实现 Deadline、Done、Err、Value 四个方法,而是都嵌入了前面提到的 emptyCtx。因此它们天然拥有了 emptyCtx 的空实现,只是在语义上分别表示“正式根节点”和“临时占位节点”。
type backgroundCtx struct{ emptyCtx }
func (backgroundCtx) String() string {
return "context.Background"
}
type todoCtx struct{ emptyCtx }
func (todoCtx) String() string {
return "context.TODO"
}
// Background returns a non-nil, empty [Context]. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context {
return backgroundCtx{}
}
// TODO returns a non-nil, empty [Context]. Code should use context.TODO when
// it's unclear which Context to use or it is not yet available (because the
// surrounding function has not yet been extended to accept a Context
// parameter).
func TODO() Context {
return todoCtx{}
}
struct{ emptyCtx }:只有类型名,没有字段名。Go 会把它当成匿名字段,字段名默认就是类型名,逻辑上可以近似理解为:type backgroundCtx struct { emptyCtx emptyCtx // 逻辑上可以这样理解,但源码写法更简洁 }但需要注意,源码里的
struct{ emptyCtx }是匿名字段写法,会带来方法提升;外层的backgroundCtx可以直接使用emptyCtx的Deadline、Done、Err、Value方法。
这段源码可以拆成三层理解:
emptyCtx提供最基础的空Context行为。backgroundCtx嵌入emptyCtx,并通过String()标记自己是context.Background。todoCtx嵌入emptyCtx,并通过String()标记自己是context.TODO。
Background() 通常用于没有上游 Context 的地方,例如 main 函数、初始化逻辑、测试代码、后台任务入口,或者作为一个请求处理链路的最顶层 Context。
TODO() 则表示“这里暂时还不知道该传哪个 Context”。它通常只作为过渡写法使用,比如函数还没来得及改造成接收 ctx context.Context 参数时,可以先用 context.TODO() 占位,后续再替换成真正的父级 Context。
需要注意的是,Context 是一条向下传递的链路。没有上游 Context 时,可以从 context.Background() 开始派生:
root := context.Background()
ctx, cancel := context.WithTimeout(root, 3*time.Second)
defer cancel()但如果函数已经收到了上游传入的 ctx,就应该从这个 ctx 继续派生:
childCtx, cancel := context.WithCancel(ctx)
defer cancel()这样上游的取消、超时和截止时间才能继续向下传递。如果在业务链路中随意重新使用 context.Background(),就会切断原有链路,下游可能无法感知请求已经结束。
2. 四大派生函数#
2.1 WithCancel:手动取消#
context.WithCancel(parent) 用来创建一个可以被手动取消的子 Context。它会返回两个值:
ctx, cancel := context.WithCancel(parent)ctx:派生出来的子Contextcancel:取消函数,调用后会立即关闭ctx.Done()返回的只读 channel
当下游任务正在监听 ctx.Done() 时,一旦上游调用 cancel(),下游就可以立刻感知取消信号,并提前结束当前任务。
下面这个例子模拟“获取 IP 需要 3 秒,但主流程在 2 秒后主动取消任务”:
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
var wait sync.WaitGroup
wait.Add(1)
ctx, cancel := context.WithCancel(context.Background())
start := time.Now()
go func() {
defer wait.Done()
ip, err := getIP(ctx)
fmt.Println("ip:", ip, "err:", err)
}()
go func() {
time.Sleep(2 * time.Second)
cancel()
}()
wait.Wait()
fmt.Println("执行完成:", time.Since(start))
}
func getIP(ctx context.Context) (string, error) {
select {
case <-time.After(3 * time.Second):
return "192.168.200.1", nil
case <-ctx.Done():
return "", ctx.Err()
}
}这段代码中,getIP 并不是单纯 time.Sleep(3 * time.Second),而是通过 select 同时等待两个事件:
time.After(3 * time.Second):模拟耗时任务正常完成ctx.Done():监听上游是否取消任务
因为 2 秒后调用了 cancel(),所以 ctx.Done() 会先被关闭,getIP 会提前返回 context canceled,程序总耗时约 2 秒。
需要注意,context 不会强制杀死 goroutine。它只是关闭 Done() channel,把取消信号传递出去;真正停止执行,需要下游函数自己监听 ctx.Done() 并主动返回。
下面是一种容易写错的方式:
func GetIp(ctx context.Context) (ip string, err error) {
go func() {
select {
case <-ctx.Done():
fmt.Println("取消", ctx.Err().Error())
err = ctx.Err()
wait.Done()
return
}
}()
time.Sleep(3 * time.Second)
ip = "192.168.200.1"
wait.Done()
return
}这段代码的问题在于:内部新开的 goroutine 只是监听到了取消信号,但它不能让外层的 GetIp 立即停止。外层函数仍然会继续执行 time.Sleep(3 * time.Second),然后继续返回 IP。
同时,内部 goroutine 直接修改返回变量 err,还在里面调用 wait.Done(),这会让职责变得混乱:监听到取消并不等于整个 GetIp 任务已经完成。如果后面 GetIp 自己又调用一次 wait.Done(),还可能导致 WaitGroup 计数器被多减。
更清晰的写法是:WaitGroup 由启动 goroutine 的外层负责,业务函数只负责业务逻辑和响应 ctx.Done()。
WaitGroup的工作方式可以理解为一个计数器:
Add(n):登记有n个任务需要等待Done():表示有一个任务完成,计数器减一Wait():阻塞当前 goroutine,直到计数器归零
Add和Done不会自动绑定到某一个具体 goroutine。机制上它只关心计数器是否归零;但工程实践中,应该让每个Done()对应一个真实完成的任务,避免在“监听到某个事件”时就提前把任务标记为完成。
2.2 WithDeadline:截止时间自动取消#
context.WithDeadline(parent, deadline) 用来创建一个带“绝对截止时间”的子 Context。当系统时间到达 deadline 后,这个 Context 会自动取消,ctx.Done() 返回的 channel 会被关闭,ctx.Err() 会返回 context.DeadlineExceeded。
它和 WithCancel 的区别在于:
WithCancel:需要手动调用cancel()才会取消WithDeadline:到达指定时间点后自动取消
仍然沿用前面“获取 IP 需要 3 秒”的例子。如果我们希望这个操作最晚只能执行到 2 秒后,就可以这样写:
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
var wait sync.WaitGroup
wait.Add(1)
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
start := time.Now()
go func() {
defer wait.Done()
ip, err := getIP(ctx)
fmt.Println("ip:", ip, "err:", err)
}()
wait.Wait()
fmt.Println("执行完成:", time.Since(start))
}
func getIP(ctx context.Context) (string, error) {
select {
case <-time.After(3 * time.Second):
return "192.168.200.1", nil
case <-ctx.Done():
return "", ctx.Err()
}
}这段代码中:
deadline := time.Now().Add(2 * time.Second)表示截止时间是当前时间 2 秒后getIP原本需要 3 秒才能返回 IP- 但 2 秒后
ctx.Done()会自动关闭 getIP会提前返回context deadline exceeded
输出结果大致如下:
ip: err: context deadline exceeded
执行完成: 2.00...这里的关键仍然是 getIP 内部的 select:
select {
case <-time.After(3 * time.Second):
return "192.168.200.1", nil
case <-ctx.Done():
return "", ctx.Err()
}time.After(3 * time.Second) 表示获取 IP 需要 3 秒后才会完成,而 ctx.Done() 会在 2 秒后因为到达截止时间而关闭。由于 ctx.Done() 先发生,所以 select 会进入取消分支,函数不会继续等待 3 秒,而是直接返回 ctx.Err()。
所以 WithDeadline 的核心作用是:不是等待某个固定时长,而是指定一个明确的截止时间点。只要到达这个时间点,不管任务有没有完成,都会触发取消信号。
另外,WithDeadline 虽然会在到达截止时间后自动取消,但它依然会返回一个 cancel 函数:
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()这里的 defer cancel() 不是为了手动提前取消,而是为了在函数结束时释放和这个 Context 相关的资源。实际开发中,只要调用了 WithCancel、WithTimeout、WithDeadline 这类函数,通常都应该在合适的位置调用返回的 cancel。
实际开发中,如果你关心的是“最多执行多久”,通常用 WithTimeout 更直接;如果你关心的是“最晚不能超过某个具体时间点”,就更适合用 WithDeadline。
2.3 WithTimeout:固定时长自动取消#
context.WithTimeout(parent, timeout) 用来创建一个带“固定超时时长”的子 Context。它和 WithDeadline 非常类似,最终效果都是:到达指定时间后自动取消,ctx.Done() 关闭,ctx.Err() 返回 context.DeadlineExceeded。
不同点在于参数表达方式:
WithDeadline接收的是一个明确的时间点,例如time.Now().Add(2 * time.Second)WithTimeout接收的是一个时间长度,例如2 * time.Second
从 Go 源码可以看出,WithTimeout 本质上就是对 WithDeadline 的一层封装:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}也就是说,下面这两种写法在语义上是接近的:
ctx, cancel := context.WithDeadline(parent, time.Now().Add(2*time.Second))
defer cancel()ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()在实际使用中,WithTimeout 更常见,因为很多场景关心的是“这个操作最多执行多久”,而不是“这个操作必须在哪个具体时间点之前结束”。
2.4 WithValue:携带键值对#
WithValue 用来在 Context 链路中携带少量键值对数据。它的特点是:函数参数表面上只传了一个 ctx context.Context,但下游可以通过 ctx.Value(key) 读取上游放进去的值。
因此,WithValue 必须和 Value() 方法搭配使用:
- 上游使用
context.WithValue(parent, key, value)写入值 - 下游使用
ctx.Value(key)读取值
示例:
package main
import (
"context"
"fmt"
)
type contextKey string
const userIDKey contextKey = "userID"
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, userIDKey, "user-001")
handleRequest(ctx)
}
func handleRequest(ctx context.Context) {
printUserID(ctx)
}
func printUserID(ctx context.Context) {
userID, ok := ctx.Value(userIDKey).(string)
if !ok {
fmt.Println("未找到 userID")
return
}
fmt.Println("当前用户:", userID)
}从 main 到 printUserID,函数之间看起来只传递了一个 ctx,但 userID 已经被挂在这条 Context 链路上,因此下游可以在需要时取出来。
需要注意的是,WithValue 不适合传递主要业务参数。它更适合放请求级别的元信息,例如用户 ID、trace ID、请求 ID 等。为了避免 key 冲突,通常会自定义 key 类型,而不是直接使用普通字符串作为 key。
3. Context 的 Timeout 继承链#
Context 是一条从父级向子级派生的链路。这里说的“继承”不是面向对象里的继承,而是指:子 Context 会受到父 Context 的影响。
最重要的规则是:
- 父
Context被取消后,所有由它派生出来的子Context都会被取消 - 子
Context被取消后,不会反向取消父Context - 如果父子
Context都设置了截止时间,最终会以更早到期的那个为准
也就是说,子 Context 不能突破父 Context 的生命周期。如果父级最多只能执行 2 秒,那么子级即使设置了 5 秒,也不会真的等到 5 秒后才取消,因为父级在 2 秒时已经取消了。
例如:
parentCtx, parentCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer parentCancel()
childCtx, childCancel := context.WithTimeout(parentCtx, 5*time.Second)
defer childCancel()这段代码中,parentCtx 的超时时间是 2 秒,childCtx 的超时时间是 5 秒。虽然子 Context 设置得更长,但它是从 parentCtx 派生出来的,所以它最多也只能存活 2 秒。2 秒后父 Context 超时,子 Context 的 Done() 也会一起关闭。
如果反过来:
parentCtx, parentCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer parentCancel()
childCtx, childCancel := context.WithTimeout(parentCtx, 2*time.Second)
defer childCancel()这时父 Context 可以存活 5 秒,但子 Context 自己只设置了 2 秒,所以 childCtx 会在 2 秒后先被取消,而 parentCtx 不会因为子级取消而取消。
因此,对于存在父子关系的 Context 来说,实际生效的截止时间可以理解为:
实际截止时间 = 父 Context 截止时间 和 子 Context 截止时间 中更早的那个这个规则可以保证上游的控制能力不会被下游绕开。上游一旦设置了整体超时时间,下游即使继续派生新的 Context,也只能在这个整体时间范围内工作。
参考#
20分钟搞懂go语言中的context——枫枫知道 go语言的context——枫枫知道的博客 golang context该怎么玩儿