Context:取消、超时与值传递
Context — 取消、超时与值传递
在服务器程序中,每个传入的请求通常在自己的 goroutine 中处理,而处理过程中可能会启动更多 goroutine 来访问数据库、调用 RPC、请求下游服务。当一个请求被取消或超时时,所有为该请求工作的 goroutine 都应该迅速退出,释放资源。这就是 context 包要解决的核心问题。
context 包由 Sameer Ajmani 设计,首次在 2014 年的博客文章 "Go Concurrency Patterns: Context"(The Go Blog)中介绍,Go 1.7 时正式纳入标准库。它的设计目标只有一个:跨 API 边界和 goroutine 传递截止时间(deadline)、取消信号(cancellation)和请求范围数据(request-scoped values)。
Level 1:你需要知道的
Context 是什么
context.Context 是一个接口,定义了四个方法:
type Context interface {
// 返回截止时间。如果没有设置截止时间,ok = false
Deadline() (deadline time.Time, ok bool)
// 返回一个 channel,当 context 被取消时关闭
Done() <-chan struct{}
// Done channel 关闭后,返回取消原因
Err() error
// 返回与 key 关联的值,如果没有则返回 nil
Value(key interface{}) interface{}
}
你不需要自己实现这个接口——标准库提供了所有你需要的实现。
两个根 Context
每个 context 树的根节点只有两种选择:
// 用于 main 函数、初始化和测试
ctx := context.Background()
// 用于你不确定该用什么 context 的地方(临时占位)
ctx := context.TODO()
两者在功能上完全相同——都是永远不会被取消的空 context。区别纯粹是语义和文档层面的:TODO() 表示"我还没想好这里应该传什么 context,之后要回来修"。
WithCancel:手动取消
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 确保函数退出时释放资源
go worker(ctx)
time.Sleep(3 * time.Second)
cancel() // 通知 worker 退出
time.Sleep(1 * time.Second) // 给 worker 时间清理
}
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("worker stopped:", ctx.Err())
return
default:
fmt.Println("working...")
time.Sleep(500 * time.Millisecond)
}
}
}
WithCancel 返回一个新的 context 和一个 cancel 函数。调用 cancel() 会:
- 关闭该 context 的
Done()channel - 关闭所有子 context 的
Done()channel(级联取消) - 设置
Err()返回context.Canceled
关键规则:创建了 cancel 函数就必须调用它。 不调用会导致资源泄漏——context 内部的 goroutine 和 channel 不会被释放。用 defer cancel() 是最安全的模式。
WithTimeout 和 WithDeadline:自动超时
// WithTimeout:从现在开始计时
func fetchData(ctx context.Context) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err // 可能是超时:ctx.Err() == context.DeadlineExceeded
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// WithDeadline:指定绝对时间
func processBeforeClose(ctx context.Context) {
deadline := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
select {
case <-ctx.Done():
fmt.Println("deadline reached:", ctx.Err())
case result := <-doWork(ctx):
fmt.Println("completed:", result)
}
}
WithTimeout(parent, d) 等价于 WithDeadline(parent, time.Now().Add(d))。
超时嵌套规则:子 context 的超时不能超过父 context。
parentCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 子 context 请求 30 秒超时——但实际上 10 秒后就会被取消
// 因为父 context 先超时
childCtx, childCancel := context.WithTimeout(parentCtx, 30*time.Second)
defer childCancel()
WithValue:传递请求范围数据
type contextKey string
const (
keyRequestID contextKey = "request_id"
keyUserID contextKey = "user_id"
)
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New().String()
ctx := context.WithValue(r.Context(), keyRequestID, requestID)
ctx = context.WithValue(ctx, keyUserID, getUserFromAuth(r))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handler(w http.ResponseWriter, r *http.Request) {
requestID := r.Context().Value(keyRequestID).(string)
userID := r.Context().Value(keyUserID).(string)
log.Printf("[%s] user %s requested data", requestID, userID)
}
使用自定义类型作为 key(避免冲突):
// 错误:用 string 作 key,可能与其他包冲突
ctx = context.WithValue(ctx, "user_id", "123")
// 正确:用未导出的自定义类型,保证唯一性
type contextKey string
const keyUserID contextKey = "user_id"
ctx = context.WithValue(ctx, keyUserID, "123")
Context 传递的黄金规则
- Context 作为第一个参数传递:
// 正确
func FetchUser(ctx context.Context, id string) (*User, error)
// 错误:不要把 context 放在结构体里
type Client struct {
ctx context.Context // 不要这样做!
}
- 不要传 nil context:
// 如果不确定用什么,用 context.TODO()
func doSomething(ctx context.Context) {
if ctx == nil {
ctx = context.TODO() // 但最好在调用方就传正确的 context
}
}
- 相同的 context 可以传给多个 goroutine:
func processAll(ctx context.Context, items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
process(ctx, it) // 同一个 ctx,取消时所有 goroutine 都收到信号
}(item)
}
wg.Wait()
}
常见错误
错误 1:忽略 context 取消信号
// 错误:虽然接受了 ctx,但完全没检查
func badWorker(ctx context.Context) {
for i := 0; i < 1000000; i++ {
heavyComputation(i) // 即使 ctx 被取消,也会执行完所有循环
}
}
// 正确:在长循环中定期检查
func goodWorker(ctx context.Context) error {
for i := 0; i < 1000000; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
heavyComputation(i)
}
return nil
}
错误 2:在错误的层级取消
// 错误:在循环内部创建 WithCancel,但 cancel 从未被调用
func bad(ctx context.Context) {
for i := 0; i < 100; i++ {
ctx, _ := context.WithCancel(ctx) // 泄漏!cancel 被丢弃了
go doWork(ctx)
}
}
// 正确:保存 cancel 并在适当时机调用
func good(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for i := 0; i < 100; i++ {
go doWork(ctx)
}
}
错误 3:用 WithValue 传递非请求范围数据
// 错误:数据库连接不是请求范围数据
ctx = context.WithValue(ctx, "db", dbConn) // 不要这样!
// 错误:可选参数不应该通过 context 传
ctx = context.WithValue(ctx, "verbose", true) // 不要这样!
// 正确的 WithValue 用途:
// - Request ID
// - 认证信息(user ID, token)
// - 分布式追踪 span
// - 请求的 locale/language
实战:HTTP 中间件链
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", getUsers)
// 中间件链:logging -> timeout -> auth -> handler
handler := loggingMiddleware(
timeoutMiddleware(5*time.Second,
authMiddleware(mux)))
http.ListenAndServe(":8080", handler)
}
func timeoutMiddleware(timeout time.Duration, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
// 用新的 context 替换请求的 context
r = r.WithContext(ctx)
done := make(chan struct{})
go func() {
next.ServeHTTP(w, r)
close(done)
}()
select {
case <-done:
// handler 正常完成
case <-ctx.Done():
// 超时
http.Error(w, "request timeout", http.StatusGatewayTimeout)
}
})
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
user, err := validateToken(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), keyUser, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Level 2:它是怎么运行的
Context 在 HTTP 请求中的完整生命周期
当一个 HTTP 请求到达 Go 服务器时:
客户端请求 → net/http.Server
→ Server 为请求创建 context(基于连接的 BaseContext)
→ 请求对象 r.Context() 返回该 context
→ 如果客户端断开连接,context 被取消
→ handler 通过 r.Context() 获取 context
→ handler 传递 context 给下游调用
// net/http 内部(简化)
func (srv *Server) Serve(l net.Listener) error {
baseCtx := context.Background()
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(l)
}
for {
conn, _ := l.Accept()
connCtx := srv.ConnContext(baseCtx, conn) // 可定制
go srv.serveConn(connCtx, conn)
}
}
func (c *conn) serve(ctx context.Context) {
// 当连接关闭时取消 context
ctx, cancelCtx := context.WithCancel(ctx)
defer cancelCtx()
// ... 读取请求 ...
req.ctx = ctx // 请求绑定到连接的 context
}
关键点: 当客户端断开 TCP 连接时,net/http 会自动取消请求的 context。这意味着如果你的 handler 正在做一个耗时操作(比如数据库查询),你可以通过检查 ctx.Done() 来提前终止,而不是白白做完一个客户端已经不需要的请求。
Context 与 Goroutine 生命周期管理
Context 的最大价值在于管理 goroutine 的生命周期——特别是"级联取消"。
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 启动 3 个并行的数据获取
userCh := make(chan *User, 1)
ordersCh := make(chan []*Order, 1)
recsCh := make(chan []*Recommendation, 1)
go func() {
user, _ := fetchUser(ctx, r.URL.Query().Get("id"))
userCh <- user
}()
go func() {
orders, _ := fetchOrders(ctx, r.URL.Query().Get("id"))
ordersCh <- orders
}()
go func() {
recs, _ := fetchRecommendations(ctx, r.URL.Query().Get("id"))
recsCh <- recs
}()
// 等待所有结果或 context 取消
select {
case <-ctx.Done():
// 客户端断开或超时——3 个 goroutine 都会收到取消信号
http.Error(w, "request cancelled", http.StatusServiceUnavailable)
return
case user := <-userCh:
// ... 继续等其他结果
_ = user
}
}
当请求被取消时,ctx.Done() 被关闭,传递给 fetchUser、fetchOrders、fetchRecommendations 的同一个 ctx 也会"感知"到取消。如果这些函数内部正在做 HTTP 请求或数据库查询(且正确使用了 ctx),它们会提前终止。
Context 树的结构
Context 形成一棵树——每个 With* 调用都创建一个子节点:
Background
├── WithCancel (请求 A)
│ ├── WithTimeout (数据库查询, 5s)
│ ├── WithTimeout (外部 API 调用, 10s)
│ │ └── WithValue (trace span)
│ └── WithValue (user info)
└── WithCancel (请求 B)
└── WithTimeout (处理超时, 30s)
取消是自上而下传播的:取消一个节点会取消它的所有后代,但不影响兄弟节点或祖先节点。
Context 内部实现
标准库中有几种 context 实现:
// emptyCtx: Background() 和 TODO() 的实现
type emptyCtx struct{}
func (emptyCtx) Deadline() (time.Time, bool) { return time.Time{}, false }
func (emptyCtx) Done() <-chan struct{} { return nil } // 永远不会被取消
func (emptyCtx) Err() error { return nil }
func (emptyCtx) Value(key any) any { return nil }
// cancelCtx: WithCancel 的实现
type cancelCtx struct {
Context // 嵌入父 context
mu sync.Mutex
done atomic.Value // chan struct{}, 懒创建
children map[canceler]struct{} // 子 context 集合
err error
}
// timerCtx: WithTimeout/WithDeadline 的实现
type timerCtx struct {
*cancelCtx
timer *time.Timer
deadline time.Time
}
// valueCtx: WithValue 的实现
type valueCtx struct {
Context // 嵌入父 context
key, val any
}
WithValue 的查找是链表遍历:
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key) // 递归向上查找
}
这意味着 Value 的时间复杂度是 O(n),n 是从当前节点到根节点的路径长度。不要在 context 链中存储大量值——它不是用来当 map 的。
cancel 传播机制
当调用 cancel() 时:
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // 已经被取消过了
}
c.err = err
c.cause = cause
// 关闭 done channel
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan) // 复用一个已关闭的全局 channel
} else {
close(d)
}
// 级联取消所有子 context
for child := range c.children {
child.cancel(false, err, cause)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c) // 从父 context 中移除自己
}
}
注意 closedchan 优化:如果在取消之前没有人调用过 Done()(没有人在等待取消信号),就不需要创建和关闭一个真正的 channel——直接使用一个预先关闭的全局 channel。
WithTimeout 的实现
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 如果父 context 的 deadline 更早,直接创建 cancelCtx
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c) // 将自己注册为父的子 context
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded, nil) // 已过期
return c, func() { c.cancel(false, Canceled, nil) }
}
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, nil)
})
return c, func() { c.cancel(true, Canceled, nil) }
}
为什么即使超时后也要调用 cancel? time.AfterFunc 创建了一个 Timer。如果操作在超时前完成了,调用 cancel 会停止 Timer 并释放关联资源。如果不调用,Timer 会一直存活到超时——虽然不会导致功能错误,但会浪费一小块内存。
Context 与数据库操作
func queryUsers(ctx context.Context, db *sql.DB) ([]User, error) {
// database/sql 原生支持 context
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users WHERE active = true")
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
// 如果 ctx 被取消,rows.Next() 会返回 false
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
database/sql 在 Go 1.8 引入了 Context 方法族(QueryContext、ExecContext 等)。当 context 被取消时:
- 如果查询还没发送到数据库——直接返回错误
- 如果查询正在执行——向数据库发送 cancel 信号(MySQL 的
KILL QUERY) - 如果结果正在读取——停止读取,关闭连接
gRPC 中的 Context
gRPC 是 context 的另一个重度用户。客户端设置的 deadline 会通过 gRPC 协议传播到服务端:
// 客户端
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.UserRequest{Id: "123"})
if err != nil {
if status.Code(err) == codes.DeadlineExceeded {
log.Println("server took too long")
}
}
// 服务端
func (s *server) GetUser(ctx context.Context, req *pb.UserRequest) (*pb.UserResponse, error) {
// ctx 已经带有客户端设置的 deadline!
user, err := s.db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", req.Id)
if err != nil {
return nil, err
}
return &pb.UserResponse{User: user}, nil
}
Deadline 通过 gRPC 的 metadata 传播:客户端计算剩余时间,编码到 grpc-timeout header 中,服务端据此设置本地 context 的 deadline。
优雅关闭(Graceful Shutdown)
func main() {
// 创建一个根 context,用于关闭信号
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
// 在后台启动 server
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// 等待关闭信号
<-ctx.Done()
log.Println("shutting down...")
// 给正在处理的请求 30 秒时间完成
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatal("forced shutdown:", err)
}
log.Println("server stopped gracefully")
}
注意:Shutdown 用的是一个新的 context.Background() 为基础的 context,而不是已经被取消的 ctx——因为我们需要给 Shutdown 一个新的超时期限。
Level 3:规范怎么定义的
Context 的设计背景和争议
context 包的设计来自 Google 内部的实践。Sameer Ajmani 在 2014 年的博客文章 "Go Concurrency Patterns: Context" 中首次公开介绍了这个设计。它解决的是 Google 内部大规模微服务架构中的实际问题:一个用户请求可能触发数十个 RPC,每个 RPC 可能触发更多 RPC,形成一棵调用树。当用户取消请求时,整棵调用树都需要知道。
争议 1:Context 应该显式传递还是隐式传递?
Go 选择了显式传递(作为第一个参数),而不是像 Java 的 ThreadLocal 那样隐式关联到线程。理由:
- 显式传递使依赖关系可见——一看函数签名就知道它支持取消
- goroutine 不绑定线程,
ThreadLocal语义在 Go 中行不通 - 显式传递容易测试——传入不同的 context 即可模拟不同场景
争议 2:WithValue 是否是好设计?
这是 context 包争议最大的部分。批评者(包括 Dave Cheney, "Context is for cancelation" 2017)认为:
- WithValue 本质上是一个无类型的、immutable 的全局变量
- 它绕过了函数签名的类型系统
- 它使依赖关系不可见(你不知道函数内部需要 context 中的什么值)
支持者认为:
- 请求范围数据(trace ID, auth token)确实需要一种跨越 API 边界的传递方式
- 替代方案(把所有数据都放在函数参数中)在深层调用链中不现实
- WithValue 的使用应该严格限制在请求范围数据
争议 3:Context 放在第一个参数是否合适?
Russ Cox 在 Go issue #28342 中讨论了一种替代方案:把 context 放在方法的 receiver 中。最终未采纳,因为:
- 不是所有函数都有 receiver
- 一致性比完美更重要——所有函数都遵循相同的约定
Go 标准库中 Context 的演进
| 版本 | 变化 |
|---|---|
| Go 1.7 | context 包从 golang.org/x/net/context 移入标准库 |
| Go 1.8 | database/sql 增加 Context 方法 |
| Go 1.14 | os/signal.NotifyContext |
| Go 1.16 | signal.NotifyContext 简化信号处理 |
| Go 1.20 | context.WithCancelCause 允许传递取消原因 |
| Go 1.21 | context.WithoutCancel 创建不会被父取消的子 context |
| Go 1.21 | context.AfterFunc 注册取消回调 |
Go 1.20: WithCancelCause
ctx, cancel := context.WithCancelCause(parent)
// 取消时提供原因
cancel(fmt.Errorf("user %s rate limited", userID))
// 获取取消原因
err := context.Cause(ctx)
fmt.Println(err) // "user xxx rate limited"
这解决了一个长期痛点:以前 ctx.Err() 只返回 context.Canceled,你无法知道为什么被取消。
Go 1.21: WithoutCancel
// 创建一个不会被父 context 取消的子 context
// 但仍然继承父 context 的 Value
func WithoutCancel(parent Context) Context
使用场景:当你需要在请求 handler 返回后继续做一些异步工作(比如异步写日志),但仍然需要请求中的 trace ID:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 请求返回后继续执行的后台任务
bgCtx := context.WithoutCancel(ctx) // 不会被请求取消影响
go func() {
// bgCtx 仍然有 trace ID 等 Value
// 但不会因为请求结束而被取消
asyncLog(bgCtx, "request processed")
}()
w.WriteHeader(http.StatusOK)
}
Go 1.21: AfterFunc
stop := context.AfterFunc(ctx, func() {
// 当 ctx 被取消时执行
cleanup()
})
// 如果不再需要这个回调:
stop() // 防止回调执行(如果还没被触发)
这为 context 取消提供了一个回调机制,替代了"启动一个 goroutine 监听 ctx.Done()"的模式:
// 旧模式:需要启动 goroutine
go func() {
<-ctx.Done()
conn.Close()
}()
// 新模式:更简洁,不需要额外 goroutine
stop := context.AfterFunc(ctx, func() {
conn.Close()
})
defer stop()
context.Context 接口的设计决策
为什么 Done() 返回 channel 而不是提供 Wait() 方法?
Channel 可以和 select 一起使用,这是 Go 并发编程的核心模式:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-ch:
return result, nil
}
如果用 Wait() 方法,就无法与其他 channel 组合使用——你只能在单独的 goroutine 中调用 Wait(),这使得代码更复杂。
为什么 Value 的 key 是 interface{} 而不是 string?
为了避免不同包之间的 key 冲突。如果 key 是 string,两个不相关的包可能用相同的 key(如 "user")存储不同的值。用未导出的自定义类型作为 key,保证了命名空间隔离:
// package auth
type contextKey struct{}
var userKey = contextKey{}
// package logging
type contextKey struct{} // 不同的类型,即使名字相同也不冲突
var userKey = contextKey{}
与其他语言的取消机制比较
| 特性 | Go context | Java CancellationToken | C# CancellationToken | Rust tokio |
|---|---|---|---|---|
| 传递方式 | 显式参数 | 显式参数 | 显式参数 | 隐式(task local) |
| 取消传播 | 树形级联 | 链式 | 链式 | 树形 |
| 值传递 | WithValue | ThreadLocal | AsyncLocal | task_local! |
| 超时 | WithTimeout | 外部 Timer | WithTimeout | tokio::time::timeout |
| 同步/异步 | 同步(Done channel) | 异步(callback) | 同步(WaitHandle) | 异步 |
Go 的设计与 C# 的 CancellationToken 最为相似——都是显式传递、支持级联取消。主要区别在于 Go 用 channel 实现等待,而 C# 用 WaitHandle/callback。
Level 4:边界与陷阱
陷阱 1:不要把 Context 放到 struct 里
这是 context 官方文档中最强调的规则:
// 错误
type Server struct {
ctx context.Context
// ...
}
// 错误
type Request struct {
ctx context.Context
url string
method string
}
为什么?
-
生命周期不匹配:struct 通常是长寿命的,context 是短寿命的。把 context 存在 struct 中模糊了"这个 context 代表哪个操作"。
-
不清楚该用哪个:如果 struct 有一个 ctx 字段,同时方法又接收 ctx 参数,到底用哪个?
-
无法更新:一旦存储在 struct 中,你无法在调用链深处替换为子 context(比如添加 timeout)。
// 正确做法:每个方法接收 context 参数
type Server struct {
db *sql.DB
}
func (s *Server) HandleRequest(ctx context.Context, req *Request) error {
// ctx 代表这个具体请求的生命周期
return s.db.QueryContext(ctx, "...")
}
唯一例外: http.Request 将 context 存储在 struct 中,但这是历史原因(context 在 Request 之后引入),且 http.Request 本身就是请求范围的对象。
陷阱 2:WithValue 只传请求范围数据
什么是请求范围数据? 与单个请求的生命周期绑定的数据——请求开始时创建,请求结束时不再需要。
// 正确的 WithValue 用途
ctx = context.WithValue(ctx, traceIDKey, "abc-123") // Trace ID
ctx = context.WithValue(ctx, userKey, authenticatedUser) // 认证用户
ctx = context.WithValue(ctx, spanKey, opentelemetry.Span{}) // 追踪 span
// 错误的 WithValue 用途
ctx = context.WithValue(ctx, "db", database) // 不是请求范围
ctx = context.WithValue(ctx, "logger", logger) // 应该用依赖注入
ctx = context.WithValue(ctx, "config", appConfig) // 应该用参数或 struct 字段
ctx = context.WithValue(ctx, "retries", 3) // 应该是函数参数
经验法则: 如果数据在不同请求之间是相同的,它就不是请求范围数据。
陷阱 3:Context 泄漏
每个 WithCancel/WithTimeout/WithDeadline 都会在内部创建 goroutine 或 timer。如果不调用 cancel,这些资源不会被释放:
// 泄漏示例
func leak(ctx context.Context) {
ctx, _ = context.WithTimeout(ctx, 5*time.Second) // cancel 被丢弃了!
// 5 秒后 timer 才会清理
// 如果这个函数被高频调用,会累积大量未释放的 timer
}
// 正确
func noLeak(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // 函数退出时立即释放
}
go vet 检测: 从 Go 1.16 开始,go vet 会警告未使用的 cancel 函数。
陷阱 4:Context.Value 的性能
Value() 的查找是沿着 context 链线性遍历的:
// 查找路径:current → parent → grandparent → ... → Background
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
c = ctx.Context
case *timerCtx:
c = ctx.cancelCtx.Context
case emptyCtx:
return nil
default:
return c.Value(key)
}
}
}
如果 context 链很深(比如中间件嵌套 20 层,每层加一个 Value),最深处的 Value 查找需要遍历 20 个节点。
缓解策略:
- 减少 Value 的数量:把多个值打包成一个 struct
// 不好:3 次 WithValue,查找深度增加 3
ctx = context.WithValue(ctx, requestIDKey, reqID)
ctx = context.WithValue(ctx, userKey, user)
ctx = context.WithValue(ctx, spanKey, span)
// 更好:1 次 WithValue,打包所有请求元数据
type RequestMetadata struct {
RequestID string
User *User
Span trace.Span
}
ctx = context.WithValue(ctx, metadataKey, &RequestMetadata{
RequestID: reqID,
User: user,
Span: span,
})
- 在热路径上缓存 Value 结果
func handler(ctx context.Context) {
// 一次查找,多次使用
meta := ctx.Value(metadataKey).(*RequestMetadata)
// ... 在函数内多次使用 meta,而不是每次都调用 ctx.Value()
}
陷阱 5:取消后的清理时间
取消是即时的,但有时你需要给 goroutine 一些时间来清理:
func worker(ctx context.Context) error {
for {
select {
case <-ctx.Done():
// 取消了,但需要时间清理
cleanupCtx, cancel := context.WithTimeout(
context.Background(), // 注意:用 Background,不是已取消的 ctx
5*time.Second,
)
defer cancel()
return cleanup(cleanupCtx)
case task := <-taskCh:
process(task)
}
}
}
注意:清理用的 context 必须基于 context.Background(),不能用已经取消的 ctx——否则清理操作也会立即被取消。
陷阱 6:select 中 ctx.Done() 的优先级
// 问题:如果 ctx 被取消,但 ch 也有数据就绪,select 随机选择
for {
select {
case <-ctx.Done():
return ctx.Err()
case v := <-ch:
process(v)
}
}
如果需要确保取消信号优先被处理:
for {
select {
case <-ctx.Done():
return ctx.Err()
case v := <-ch:
// 处理前再检查一次
select {
case <-ctx.Done():
return ctx.Err()
default:
}
process(v)
}
}
陷阱 7:Context 的不可变性
Context 是不可变的——每次 With* 调用都创建一个新的 context,原来的不受影响:
func middleware(ctx context.Context) context.Context {
ctx2 := context.WithValue(ctx, keyA, "value")
// ctx 没有变化!
fmt.Println(ctx.Value(keyA)) // nil
fmt.Println(ctx2.Value(keyA)) // "value"
return ctx2
}
这意味着你必须返回新的 context(或传递给后续调用),否则修改不可见:
// 错误:middleware 修改了 ctx 但没传回来
func bad(ctx context.Context) {
enrichContext(ctx) // 内部的 WithValue 创建了新 ctx,但没人用
doWork(ctx) // 用的还是原来的 ctx,没有新值
}
// 正确
func good(ctx context.Context) {
ctx = enrichContext(ctx) // 用返回的新 ctx
doWork(ctx)
}
真实案例:Context 在 Kubernetes 中的使用
Kubernetes API Server 处理一个请求的 context 链:
// 1. Server 创建请求 context
ctx := request.WithRequestInfo(req.Context(), requestInfo)
// 2. 认证中间件添加用户信息
ctx = request.WithUser(ctx, user)
// 3. 审计中间件添加审计 ID
ctx = audit.WithAuditID(ctx, auditID)
// 4. 超时中间件设置请求超时
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
// 5. Handler 内部:创建 watch 时使用 context
watcher, err := storage.Watch(ctx, key, opts)
// 当请求被取消时,watcher 自动停止
Kubernetes 还定义了 wait.PollUntilContextCancel 来替代旧的 wait.Poll:
// 旧 API(deprecated)
err := wait.Poll(interval, timeout, conditionFunc)
// 新 API(context-aware)
err := wait.PollUntilContextCancel(ctx, interval, immediate, conditionFunc)
面试考点
-
context.Background() 和 context.TODO() 的区别?
- 功能相同,语义不同。Background 用于根节点,TODO 是"临时占位,之后要改"
-
为什么不应该把 Context 放在 struct 里?
- 生命周期不匹配、不清楚该用哪个 ctx、无法在调用链中替换
-
WithTimeout 超时后还需要调用 cancel 吗?
- 需要。超时前完成时调用 cancel 可以提前释放 timer 资源
-
context.Value 的查找复杂度是什么?
- O(n),n 是 context 链的深度(线性遍历到根)
-
如何在取消后做清理工作?
- 基于 context.Background() 创建新的 timeout context,不能复用已取消的 ctx
-
WithValue 应该存什么,不应该存什么?
- 应该:request ID、认证信息、trace span
- 不应该:DB 连接、logger、config、函数参数
总结
Context 是 Go 服务端编程的基础设施——它解决的是分布式系统中最基本的问题:取消传播和请求范围数据传递。
核心要点:
- Context 形成树结构,取消自上而下级联传播
- 永远传递 context,不要存储在 struct 中
defer cancel()是不可协商的——每个 WithCancel/WithTimeout 都必须有对应的 cancel 调用- WithValue 只用于请求范围数据,不是通用 KV 存储
- Context 是不可变的——With* 返回新 context,不修改原来的
- 在清理操作中使用基于 Background() 的新 context
- 理解 ctx.Done() 在 select 中没有优先级——需要手动实现
正确使用 context 是写好 Go 服务的必备技能。它不复杂,但需要纪律——每个函数都传 ctx,每个 cancel 都 defer,每个长操作都检查 Done()。