Go context

context 在调用链上传取消、超时/截止时间与请求范围值;Done/Err、派生树传播,以及勿把 ctx 当结构体字段的惯例与例外。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go context

这个概念为什么会出现

只会 go f() 远远不够。工程里真正难的是:

  • 请求已经断开,下游 RPC/SQL 还在跑
  • 总时限 2s,子调用还要再分预算
  • 同一条调用链上的 goroutine 要一起停
  • 需要透传 request-id,但不想污染每个业务函数的领域参数列表到失控

context.Context 成为标准答案:把截止时间、取消信号与少量请求范围元数据,沿着 API 调用链显式向下传。它管的是“这条工作还该不该继续”,不是“业务对象有哪些字段”。

[!abstract] 一句话理解 Context 是可派生的请求范围控制句柄:父取消则子取消;Done 关闭表示退出信号,Err 解释原因;值透传应极克制,且 ctx 通常作为函数第一个参数而不是长期塞进结构体。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:下单接口给“扣库存”子调用设 50ms 预算

用户点击购买后,handler 最多愿意等 50ms 做库存预扣。
下游如果更慢(锁竞争、网络抖),不能无限挂着占住请求槽位——
取消子调用并返回超时,把线程/连接让给别人。

context.WithTimeout 就是“给这条调用链设总预算”;
子函数通过 select 监听 ctx.Done(),超时就停。

package main

import (
	"context"
	"fmt"
	"time"
)

// reserveStock 模拟“扣减库存”的慢操作。
//
// 业务意图:
// - 正常路径约 100ms 完成(演示用 sleep 代替 RPC/SQL);
// - 若请求预算先耗尽,必须立刻停并返回 ctx.Err(),
//   避免超时后还在改库存。
//
// 教学点:
// - 取消是协作式的:函数自己 select Done,运行时不会强杀;
// - 支持 ctx 的标准库/驱动同理,内部会听 Done。
func reserveStock(ctx context.Context, sku string) error {
	select {
	case <-time.After(100 * time.Millisecond):
		// 模拟下游终于成功——但若预算只有 50ms,这条路通常走不到。
		fmt.Println("reserved", sku)
		return nil
	case <-ctx.Done():
		// 预算用尽或上级 cancel:停止并上报原因(Canceled / DeadlineExceeded)。
		fmt.Println("reserve stopped:", sku, ctx.Err())
		return ctx.Err()
	}
}

func main() {
	// 请求级根:真实 HTTP 里常用 r.Context(),这里用 Background 顶上。
	root := context.Background()

	// 给本请求的“扣库存”步骤只留 50ms。
	ctx, cancel := context.WithTimeout(root, 50*time.Millisecond)
	// 即使已经超时,也要 cancel:释放 WithTimeout 创建的 timer 等资源。
	defer cancel()

	if err := reserveStock(ctx, "sku-42"); err != nil {
		// handler 可映射为 504/409 等;这里只展示错误回到 main。
		fmt.Println("checkout:", err)
	}
}

建议运行:

go run .

期望输出:

reserve stopped: sku-42 context deadline exceeded
checkout: context deadline exceeded

结合场景再看三个关注点

  1. 超时后 Done 关闭,业务必须自己退出
    reserveStock 不听 ctx 就会在超时后继续改库存——这是事故,不是“context 没用”。

  2. defer cancel() 是纪律
    成功、失败、提前返回都要释放 timeout 资源。

  3. ctx 管“还该不该继续”,不管业务字段
    SKU、用户 ID 仍走普通参数;ctx 只传取消/截止与极少量请求元数据。

核心概念与准确模型

接口要点

Package context

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}
方法含义
Done关闭的 channel 表示应停止;可 select
ErrDone 关闭后返回 CanceledDeadlineExceeded
Deadline截止时间(若有)
Value按 key 取请求范围值

创建与派生

context.Background()   // 根:main、init、测试顶层
context.TODO()         // 尚不清楚用谁时的占位,应尽快替换

ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, d)
ctx, cancel := context.WithDeadline(parent, t)
ctx := context.WithValue(parent, key, val)

派生形成

  • 子 ctx 在父取消、自己 cancel、或自己超时/截止时变为 Done
  • 取消向下传播,不会“子取消父”
  • 每次 WithCancel/WithTimeout/WithDeadline 返回的 cancel 都应在不用时调用
flowchart TD
    BG["Background"] --> REQ["WithTimeout 请求级"]
    REQ --> DB["WithValue / 子调用"]
    REQ --> RPC["WithCancel 某分支"]
    REQ -->|超时或 cancel| DONE["子树 Done"]

Done 与 Err

select {
case <-ctx.Done():
    return ctx.Err()
default:
}
  • context.Canceled:调用了 cancel 或父取消
  • context.DeadlineExceeded:超时或过了 deadline

Err() 在 Done 未关闭前返回 nil

Value:请求范围元数据

type ctxKey int
const keyReqID ctxKey = 1

ctx = context.WithValue(ctx, keyReqID, "abc")
id, _ := ctx.Value(keyReqID).(string)

约定:

  1. key 用未导出类型,避免包间碰撞。
  2. 只放跨中间件/API 边界的横切数据:request id、auth 主体、trace span……
  3. 不要把业务必需参数(userID 作为核心入参、整颗配置树)塞进 Value 逃避函数签名。
  4. Value 是不可变链路:每次 WithValue 返回新 ctx。

API 惯例

Go Wiki:Contexts

  1. 需要时,ctx 作为第一个参数,命名 ctx
  2. 不传 nil;不知道时用 context.TODO()
  3. 不把 Context 放在结构体里长期保存——默认惯例
  4. 函数若启动 goroutine,应让其能观察 ctx 或文档化生命周期。

“不要存进 struct”的准确 nuance

常见禁令:

type Server struct {
    ctx context.Context // 通常错误
}

原因:

  • ctx 有生命周期,结构体往往更长
  • 容易用错请求级 ctx 去跑后台任务
  • 取消语义变得隐式、难测

可接受的例外形态(仍要谨慎):

  • 方法签名继续显式收 ctx,结构体只存 独立于单请求的 依赖
  • 某个 worker 值代表“这一次运行”,构造时注入 ctx,且生命周期与该 run 一致
  • 例如:type job struct { ctx context.Context; ... } 仅在该 job 范围内使用

原则:ctx 的寿命 ≥ 使用它的工作寿命,且谁 cancel 一目了然。优先“参数传入”而不是“字段暗藏”。

传播与协作取消

取消不是强制线程杀死:

  1. 阻塞在支持 ctx 的调用上(http.NewRequestWithContext、DB driver 等)
  2. select 里等 ctx.Done()
  3. 循环中轮询 ctx.Err()

若忽略 Done,goroutine 仍会泄漏。见 go-goroutine-channel-context-and-cancellation-relationship

与 channel 超时的关系

  • 本地一次性超时可用 time.After / NewTimer
  • 跨 API 边界的取消/截止时间应传 context,以便子调用继续派生

请求超时预算常层层 WithTimeout/WithDeadline 收紧,而不是每个子调用重新从“现在”算更长的时间。

设计动机

  1. 标准化请求生命周期
    HTTP/RPC 中间件与业务代码说同一种取消语言。
  2. 显式优于 TLS 隐式上下文
    函数签名可见依赖,便于测试与推理。
  3. 取消可组合
    树状派生自然表达“整请求停”与“子树停”。

边界情况与反直觉行为

  1. 忘记 cancel
    WithTimeout/WithCancel 泄漏 timer 与子树资源直到父结束。
  2. 把 Background 传进每个请求
    失去取消与超时。应使用请求 ctx(如 r.Context())。
  3. 在已取消 ctx 上继续重活
    必须先检查或让下游立刻返回。
  4. Value 类型断言失败
    静默零值;应用方要明确处理。
  5. 派生过多 WithValue
    链变长、可读性下降;非热点一般可接受,但仍应克制。
  6. parent 已 Done 再 WithCancel
    子 ctx 立即取消。

常见误区

[!warning] 常见误区:context 当参数垃圾袋 错误:WithValue 塞 user、repo、config、db。
正确:横切元数据走 Value;领域依赖走参数或 struct 字段。

[!warning] 常见误区:认为 cancel 会打断任意代码 错误:以为调用 cancel 后函数栈自动停。
正确:必须协作检查 Done 或使用支持 ctx 的阻塞 API。

[!warning] 常见误区:传 nil context 错误:Do(nil, ...) 在下游 panic 或语义不清。
正确:Background/TODO/请求 ctx。

[!warning] 常见误区:结构体存请求 ctx 做后台循环 错误:把第一个 HTTP 请求的 ctx 存进 Server,用于所有后台工作。
正确:Server 用 Background 或独立生命周期 ctx;每请求用 r.Context()

工程实践

  1. handler 入口ctx := r.Context(),向下传。
  2. 所有出口 defer cancel()
  3. 错误返回 优先 return ctx.Err(),便于上层识别取消。
  4. 启动 goroutine 前想清楚:用哪个 ctx?谁 Wait?
  5. 测试WithCancel 立即 cancel 断言快速返回。
  6. 日志/trace 从 ctx Value 取 request id,保持业务函数签名干净。
  7. 优雅停机 根 cancel 触发,in-flight 请求用各自 ctx 或 Shutdown 语义(go-graceful-shutdown)。

可验证实验

  1. WithTimeout 短于工作时间,确认提前返回 DeadlineExceeded
  2. 父 cancel 后子 select 立刻返回。
  3. 子 cancel 不影响父 Err()
  4. 忘记 cancelWithTimeout(对照文档理解资源释放责任)。
  5. WithValue 与未导出 key,另一包无法轻易伪造同 key。
  6. 不检查 Done 的死循环在 cancel 后仍运行(反例)。

本节总结

  • 本质:调用链上的取消/截止时间/轻量元数据载体。
  • 关键 API:WithCancel/Timeout/Deadline/Value、Done、Err。
  • 传播:父→子树;协作式退出。
  • 纪律:首参 ctx、不传 nil、defer cancel、Value 克制、慎存 struct。
  • 下一步:与 channel/select 组合;HTTP 与停机场景落地。

自测题

概念题

  1. WithTimeout 返回的 cancel 为什么仍要调用?
  2. 子 ctx 取消会取消父 ctx 吗?
  3. 哪些数据适合 WithValue,哪些不适合?

代码推理题

ctx, cancel := context.WithCancel(context.Background())
child, stop := context.WithCancel(ctx)
cancel()
<-child.Done()
fmt.Println(child.Err())
stop()

child.Err() 是什么?stop 是否仍应调用?

工程思考题

服务有“进程级后台同步”和“每 HTTP 请求查询”。两类工作的 ctx 根应如何选?

参考答案

展开
  1. 释放与该 ctx 关联的资源(如 timer);若工作提前结束,不 cancel 可能拖到截止才回收。
  2. 不会;取消只向下传播。
  3. 适合 request id / 认证主体 / trace;不适合业务核心入参与依赖注入容器。
    代码:父取消使 child Done,通常 context.Canceledstop 仍应调用(幂等释放本层资源)。
    工程:后台用服务生命周期 ctx(来自 main/run);HTTP 用 r.Context();切勿混用。

延伸阅读与资料来源

资料类型支撑
Package context标准库API
Go Blog — Go Concurrency Patterns: Context博客设计与用法
Code Review Comments — ContextsWiki惯例
Spec 无单独章节,语义以包文档为准文档Done/Err 行为

笔记元信息

  • 建议文件名:go-context.md
  • 所属阶段:阶段四
  • 本篇状态:已深化
  • 建议下一篇:取消传播关系
创建于 2026/6/20 更新于 2026/7/15