← [ BACK_TO_NOTES ]

Go context

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

#concept #growing #dev #go

[!info] related notes

Go context

这个概念为什么会出现

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

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

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

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

最小可运行示例

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

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

用户点击购买后,handler 最多愿意等 50ms 做库存预扣。
下游如果更慢(锁竞争、网络抖),不能无限挂着占住请求槽位——
应发出取消信号并处理超时;下游何时释放 goroutine、连接,取决于它是否支持取消以及清理流程。

context.WithTimeout 就是“给这条调用链设总预算”;
子函数通过 select 监听 ctx.Done(),观察到取消后返回。取消不会回滚已发生的副作用,也不能证明远端操作未提交;真实扣库存还需要事务、幂等与结果核对。

package main

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

// reserveStock 只模拟等待,不实际修改库存。
// 取消是协作式的;具体 RPC/SQL API 与驱动的支持程度需查文档。
func reserveStock(ctx context.Context, sku string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	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 {
		// HTTP 状态码取决于接口契约;409 表示冲突,不能泛指超时。
		fmt.Println("checkout:", err)
	}
}

建议运行:

go run .

通常输出(若进程长时间未获调度,两个分支可能同时就绪):

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

结合场景再看三个关注点

  1. 超时后 Done 关闭,业务必须自己退出
    不观察取消的工作可能继续执行;即使检查了 ctx,也无法消除检查与副作用之间的竞争。select 的多个就绪分支没有取消优先级。

  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关闭表示应停止;不可取消的 ctx 可返回 nil,对应 select 分支不会就绪
ErrDone 关闭后返回 Canceled 或 DeadlineExceeded;否则为 nil
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 都应在不用时调用;它不等待工作结束
  • WithoutCancel(parent)(Go 1.21+)保留值查找,但不继承取消与截止时间,Done() 为 nil;脱离请求的任务仍需自己的生命周期管理
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。WithCancelCause(Go 1.20+)可携带自定义原因,用 context.Cause(ctx) 读取;这不会让 Err() 返回任意业务错误。

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. 每次 WithValue 返回新 ctx,不修改父链;保存的 map、指针等值仍可能被修改,需由应用保证并发安全。

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、谁等待结束。取消后的 ctx 仍可查询;工作应协作退出。优先“参数传入”而不是“字段暗藏”。

传播与协作取消

取消不是强制线程杀死:

  1. 使用支持 ctx 的 I/O API:http.NewRequestWithContext 仅构造请求,后续 Client.Do 与响应体读取受该 ctx 控制;数据库取消能力需核实具体驱动
  2. 在 select 里等 ctx.Done()
  3. 循环中轮询 ctx.Err()

若忽略取消,工作可能自然结束,也可能无限等待并泄漏;cancel 无法强制终止它。见 go-goroutine-channel-context-and-cancellation-relationship。

与 channel 超时的关系

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

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

设计动机

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

边界情况与反直觉行为

  1. 忘记 cancel
    派生 ctx 可能保留到父取消或自身截止;及时 cancel 解除关联并释放资源。WithCancel 本身不创建 timer,超时/截止版本可能创建。
  2. 把 Background 传进每个请求
    失去取消与超时。应使用请求 ctx(如 r.Context())。
  3. 在已取消 ctx 上继续重活
    必须先检查或让下游立刻返回。
  4. Value 类型断言失败
    v, ok := value.(T) 失败返回零值和 false;v := value.(T) 失败会 panic。
  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. 错误返回 保留操作本身的错误;确认因取消退出时才返回 ctx.Err(),避免吞掉业务错误或返回 nil。上层可用 errors.Is 判断包装后的取消错误。
  4. 启动 goroutine 前想清楚:用哪个 ctx?谁 Wait?
  5. 测试 用 WithCancel 立即 cancel 断言快速返回。
  6. 日志/trace 从 ctx Value 取 request id,保持业务函数签名干净。
  7. 优雅停机 区分停止接收、等待在途请求与主动取消;Server.Shutdown 不会自动取消活动请求的 ctx(go-graceful-shutdown)。

可验证实验

  1. WithTimeout 短于工作时间,确认提前返回 DeadlineExceeded。
  2. 父 cancel 后子 select 立刻返回。
  3. 子 cancel 不影响父 Err()。
  4. 忘记 cancel 的 WithTimeout(对照文档理解资源释放责任)。
  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.Canceled;stop 仍应调用(幂等释放本层资源)。 工程:后台用服务生命周期 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/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本