想象你是一场万人演唱会的总指挥。舞台上有灯光组、音响组、道具组,几十个工作人员各司其职,每个人都埋头忙着手里的活儿。突然,电闪雷鸣,大雨倾盆,演出必须立刻取消。你会怎么做?挨个跑去找每个人说“别干了”?等你跑完一圈,雨都停了。正确的做法是:拿起对讲机一声令下——“全场取消!”信号沿着指挥链层层传下去:总指挥 → 舞台总监 → 各组长 → 每一个工作人员。所有人同时收到指令,齐刷刷停手,有序撤离。 Go 语言里的 context 就是这把“指挥对讲机”。Go 程序常常同时跑着成千上万个 goroutine(可以理解成轻量级的“工作人员”),它们各自处理任务互不打扰。可一旦请求超时、用户断开连接、服务要关停,谁来通知这些 goroutine“收工”?context 就是答案。它用一棵“指挥树”把并发任务串起来:父任务派生子任务,取消信号只允许从上往下传。再配上 WithCancel 的手动按钮、WithTimeout 的倒计时闹钟、WithDeadline 的绝对截点,以及 WithValue 贴在工单上的身份便签,一个井然有序的并发世界就此建成。

岗位关联

学透 context,你就掌握了 Go 并发编程的“总开关”。无论写微服务、管请求超时还是排查 goroutine 泄漏,它都是绕不开的护身符,也是大厂面试的高频考点。

目录
  1. 可视化演示
  2. context 概论
  3. 树形结构
  4. WithCancel:手动取消
  5. WithTimeout:超时自动取消
  6. WithDeadline:绝对时间截点
  7. WithValue:链路传值
  8. 传播规则
  9. 泄漏防范
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示Go context树形取消传播、WithTimeout超时控制(定时器倒计时+自动取消)及WithValue值传递(请求ID沿树传播),动态演示并发任务生命周期管理。

本节配有交互动效(进入视口后自动加载,约 3KB)。

树形结构:context取消信号传播 青绿为根context 浅绿为子context 红色为已取消 cancel(parent) → cancel(all children) 父context取消时信号自动传播到所有子context,实现级联取消

context 概论

歌手的耳返里传来导演的声音,灯光师手里的对讲机突然安静了——你能想象一个没有“总指挥”的演唱会现场吗?灯光组不知道什么时候该亮灯,音响组不知道什么时候该收声,每个人各干各的,舞台上必然一片混乱。在 Go 的并发世界里,goroutine 就是这些各自忙碌的工作人员,几十上百个任务像蚂蚁一样穿梭。可一旦外部环境变化——用户关网页、请求超时、服务要停机——谁来通知这些“蚂蚁”停下来?

context 就是那位总指挥。它是 Go 标准库提供的“统一控制台”,专责向所有并发任务广播“继续”或“停止”的命令。它的名字叫“上下文”,但你完全可以把每个 context 想象成一张任务工单:goroutine 开工时接过工单,工单上写明了“什么时候该停”“超时限制多久”“这条请求带了什么信息”。

context 包核心是一个简洁的接口,定义了四件“总指挥必备技能”:

回到演唱会:总指挥这间“控制室”看不见摸不着,却让几百号人步调一致。你的 Go 服务同样如此——有了 context 这条指挥线,整个服务在流量洪峰中也能进退有序、优雅关停,这正是它成为标准库的底气。

Context={Deadline(),  Done(),  Err(),  Value(key)}\text{Context} = \left\{ \text{Deadline()},\; \text{Done()},\; \text{Err()},\; \text{Value(key)} \right\}
这就像总指挥的任职资格清单:问截止时间、听取消门铃、查结束原因、看工单便签。
  1. Deadline()返回这个 context 是否设定了“最晚收工时间”,以及具体时间点。
    Deadline()→(t,  ok)\text{Deadline()}\rightarrow (t,\; \text{ok})
  2. Done()返回一个“门铃”(channel)。context 被取消或超时时,门铃自动关闭,所有监听它的 goroutine 立刻知道“该走了”。
    Done()→close(ch)\text{Done()}\rightarrow \text{close}(\text{ch})
  3. Err()收工后回头看一眼原因——是主动喊停 Canceled,还是超时 DeadlineExceeded。
    Err()→{Canceled,  DeadlineExceeded}\text{Err()}\rightarrow \{\text{Canceled},\; \text{DeadlineExceeded}\}
  4. Value(key)从工单的便签栏按 key 取值,相当于“请求 ID 查询接口”。
    Value(key)→v\text{Value(key)}\rightarrow v

树形结构

指挥中心不会面向几百号人一个个喊话,而是通过组织架构逐层传达:总指挥 → 舞台总监 → 各组长 → 每个工作人员。每一层只向上级负责一条线,但指令可以层层传递到最末端。Go 的 context 也复制了这棵指挥树。

最顶端是 context.Background() 创建的根节点,相当于“总裁办”。当你接到请求、需要派生子任务时,用父 context 派生出子 context,就像总裁办给项目部发通知,项目部再转发给执行组。三个关键特征:

1. 父派生子:调用 context.WithCancel(parent) 或 context.WithTimeout(parent, d) 时,基于父 context 生成子 context——部门下面又设小组;

2. 信号单向传播:父喊“收工”,子、孙必须跟着收工;但孙子先跑了,爸爸不受影响;

3. 根是 Background:它永不取消、没有期限、不携带任何值,像一个从不发脾气的“太上皇”。

派生关系用公式表达:

演唱会现场的某个麦克风坏了,你只需要命令音响组停工,灯光组完全不受影响。这就是树的魅力——精确控制、局部关停、全局协调,一份信号顺着一根枝干传遍整棵树的末端。

parent→WithCancelchildcancelparent→WithTimeoutchildtimeoutparent \xrightarrow{\text{WithCancel}} child_{cancel} \quad parent \xrightarrow{\text{WithTimeout}} child_{timeout}
所有的子 context 都是从父母辈“分裂”出来的,子节点自带父母辈的全部约束,再叠加自己的新约束。
  1. WithCancel 派生给父 context 装一个“手动停止按钮”,返回子 context 与 cancel 函数。
    child,  cancel=WithCancel(parent)child, \; cancel = \text{WithCancel}(parent)
  2. WithTimeout 派生给父 context 加一个“倒计时闹钟”,到点自动取消。
    child,  cancel=WithTimeout(parent,d)child, \; cancel = \text{WithTimeout}(parent, d)
  3. 单向传播取消沿树向下流动,子 context 取消不会反向影响父 context。
    cancel(child)⇒cancel(descendant),cancel(child)⇏cancel(parent)\text{cancel}(child) \Rightarrow \text{cancel}(descendant), \quad \text{cancel}(child) \not\Rightarrow \text{cancel}(parent)

WithCancel:手动取消

演唱会进行到一半,歌手嗓子突然哑了。导演当机立断,通过对讲机喊:“取消演出!”这就是手动取消——外部条件突变,控制者主动按下停止按钮。Go 里对应 context.WithCancel:

拿到 cancel 函数后,你可以把它交给任何需要“喊停”的代码。比如用户刷新了页面、客户端断开连接,服务端 handler 立刻调用 cancel(),整条链路上的 goroutine 都会收到“别干了”的信号。

典型场景:

  • 用户中断:客户端断开连接,服务端立即取消数据库查询;
  • 主流程失败:主 goroutine 提前返回,所有支线任务一并收起;
  • 优雅关停:进程收到退出信号,正在处理的请求“做完或取消”再退出。

手动取消的动作逻辑:

导演按下对讲机按钮的瞬间,整个指挥链上的人同步收到指令;你在 Go 里按下 cancel() 的那个瞬间,同样有一整棵 goroutine 树在同步“熄灯”——这就是手动取消的威力。

WithCancel(parent)⇒{ctx.Done() closedwhen cancel() callednotify all descendantspropagate downward\text{WithCancel}(parent) \Rightarrow \begin{cases} \text{ctx.Done() closed} & \text{when cancel() called}\\ \text{notify all descendants} & \text{propagate downward} \end{cases}
cancel 一旦按下,子 context 的 Done() 通道就会关闭,所有正在 <-ctx.Done() 上等待的 goroutine 会同时醒来。
  1. 创建WithCancel 返回子 context 和 cancel 按钮,二者成对出现。
    ctx, cancel←WithCancel(parent)ctx, \, cancel \leftarrow \text{WithCancel}(parent)
  2. 触发调用 cancel(),子 context 的 Done() 通道关闭,发出取消信号。
    cancel()⇒close(ctx.Done())\text{cancel}() \Rightarrow \text{close}(ctx.\text{Done}())
  3. 传播信号继续传给后代,直到树的所有叶子节点。
    donectx⇒donechild⇒donegrandchild\text{done}_{ctx} \Rightarrow \text{done}_{child} \Rightarrow \text{done}_{grandchild}
go2 行
ctx, cancel := context.WithCancel(context.Background())defer cancel() // 函数退出时别忘了按停

WithTimeout:超时自动取消

演唱会现场,大屏幕跳出一行字:“无人机灯光表演仅限 3 分钟,超时即刻终止。”这不是谁在手动喊停,而是时间到了自动取消。context.WithTimeout 就是这枚“定时炸弹”:

它创建了一个带倒计时闹钟的 context,到点自动触发取消。这在服务端编程里是刚需——任何一次网络请求都不允许无限期挂起。为什么必须超时控制?

  • 数据库连接池满了,查询请求在排队,没有超时的话 goroutine 越积越多,最终内存爆炸;
  • 上游服务响应慢,如果没有超时,下游服务会被拖垮,形成雪崩;
  • 用户早就刷新了页面,服务端却还在执行一个永远完不成的任务。

WithTimeout 的运作原理是换算出一个绝对截止点:

导演给无人机表演上了 3 分钟倒计时——不是不相信操作员,而是为了保证整场演出节奏不失控。你的 HTTP 服务给每个查询请求挂上超时,同样是为了一个慢请求不拖垮整个服务。

deadline=tnow+d\text{deadline} = t_{now} + d
WithTimeout(父,d) 会在内部把“当前时刻 + 超时时长”算出一个绝对时刻 deadline,再挂定时器,到点自动取消。
  1. 设定倒计时把当前时刻加上超时时长 d,得到 deadline。
    deadline=tnow+d\text{deadline} = t_{now} + d
  2. 注册定时器到达 deadline 时刻,自动调用 cancel(),等价于手动取消。
    t=deadline⇒cancel()t = \text{deadline} \Rightarrow \text{cancel}()
  3. Err() 反馈超时取消后 Err() 返回 DeadlineExceeded,便于上层区分“超时”与“主动取消”。
    Err()=DeadlineExceeded\text{Err}() = \text{DeadlineExceeded}
go2 行
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)defer cancel()

WithDeadline:绝对时间截点

导演拿起记事本看了一眼:“今晚 9 点半整,所有烟花必须放完,不管准备到哪一步。”注意,这不是“3 分钟内放完”,而是一个绝对时刻——以墙上的钟为准。Go 里用 context.WithDeadline:

两者内部其实殊途同归:WithTimeout 先把相对时长换算成绝对时间,再交给同一个“闹钟机制”。但语义定位不同:WithTimeout 关心“这次调用最多等多久”,WithDeadline 关心“整个系统必须在某个时刻前收官”。

实际场景对比:

  • WithTimeout:一次 RPC 调用最多等 500ms;
  • WithDeadline:集群迁移必须在周六凌晨 2 点前全部完成——无论何时开始,以墙钟为准。

两者关系用一个公式说清:

导演的记事本上写着“21:30 烟花终了”——这是写死在墙上的时刻,不是启动时的相对秒数。在设计分布式任务时,你也需要这种“以墙钟为准”的硬性截点,保证所有节点对齐同一个截止时刻。

WithTimeout(parent,d)≡WithDeadline(parent,tnow+d)\text{WithTimeout}(parent, d) \equiv \text{WithDeadline}(parent, t_{now} + d)
WithTimeout 是 WithDeadline 的“快捷方式”——一个设相对时长,一个设绝对时间,底层是同一个机制。
  1. 等价转换WithTimeout 把相对时长 d 加上当前时刻,转成绝对时间交给 WithDeadline。
    tdeadline=tnow+dt_{deadline} = t_{now} + d
  2. 直接设置WithDeadline 直接使用传入的绝对时刻作为 deadline。
    tdeadline=tinputt_{deadline} = t_{input}
  3. 选择原则关心“等多久”用 WithTimeout,关心“必须在某时刻前完成”用 WithDeadline。
    drel→WithTimeout,tabs→WithDeadlined_{rel} \rightarrow \text{WithTimeout}, \quad t_{abs} \rightarrow \text{WithDeadline}
go4 行
// 相对时长:从现在起 3 秒ctx1, _ := context.WithTimeout(parent, 3*time.Second)// 绝对时刻:到 2026-01-01 12:00:00 截止ctx2, _ := context.WithDeadline(parent, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))

WithValue:链路传值

演员上台前,助理在他手心里写了个编号“0723“。整个演出过程中,不管他走到哪个岗位,大家看一眼编号就知道他是谁、来自哪个节目组。这就是 context 的 WithValue——在请求链路上挂一张”身份便签”:

在微服务里,一个请求要经过网关 → 鉴权 → 业务 → 数据库多条链路。我们经常需要把请求 ID(trace ID)一路带下去,这样打日志时才能把同一个请求的所有步骤串起来,排查问题一目了然。

WithValue 的使用规范:

  • key 用自定义类型或常量,避免与其他库冲突;
  • 只放请求级数据(请求 ID、用户 ID、token),不放业务参数——那是函数参数该干的活;
  • 传递是只读的:子 context 能看到父的值,但不能修改父的值。

数据流动方向:

演员手上的编号一传十、十传百,所有岗位靠它对齐信息。你的请求 ID 也像这个编号,从入口网关一路传到最底层数据库,让一条请求的完整生命轨迹有迹可循。

child=WithValue(parent,k,v)⇒v=child.Value(k)child = \text{WithValue}(parent, k, v) \quad\Rightarrow\quad v = child.\text{Value}(k)
值的查找是“链式向上”的——子节点没有 key 就找父亲,一路找到根节点。这是只读、向下的数据通道。
  1. 写入WithValue 创建一个携带键值对的子 context。
    WithValue(parent,k,v)→child\text{WithValue}(parent, k, v) \rightarrow child
  2. 读取链路上任意一环调用 Value(k),拿到最近一层写入的值。
    v=child.Value(k)v = child.\text{Value}(k)
  3. 链条查找当前 context 没有该 key 时,自动向父 context 逐级查找。
    Value(k)child→missValue(k)parent→miss⋯\text{Value}(k)_{child} \xrightarrow{\text{miss}} \text{Value}(k)_{parent} \xrightarrow{\text{miss}} \cdots
go1 行
ctx := context.WithValue(parent, "request_id", "req-2026-0723")

传播规则

指挥部有一条铁律:每个接到指示的工作人员,必须把对讲机频道原封不动传给下一个交接班的人。漏传、私藏对讲机的一律处分。Go 的 context 传播同样有三条铁律:

1. 首参数传递:函数第一个参数必须是 ctx,形如 func DoSomething(ctx context.Context, args ...);

2. 跨 goroutine 传递:新建 goroutine 时,必须把父 context 传进去,不能起一个“无根”的任务;

3. 禁止存储:不能把 context 存进结构体字段或全局变量“留作后用”——context 是流动的,不是仓库。

为什么禁止存储?因为 context 的生命周期和请求绑定,把它存进结构体,等于把“当日指挥令”塞进档案柜,下次取出来时场景早变了,不同请求之间还会互相串数据。

一个违反规则的典型写法:

传播规则的数学表达:

指挥系统高效运转,靠的是“对讲机永远在手上”的纪律。你的 Go 服务想不乱套,也得守这条纪律:context 只传递、不私藏。

∀ goroutine boundary:  ctx→child goroutine\forall \, \text{goroutine boundary}:\; \text{ctx} \to \text{child goroutine}
每跨越一个 goroutine 边界,context 都要作为显式参数交接,不能放在全局变量里偷偷传递。
  1. 参数下传context 作为函数第一个参数显式传递。
    f(ctx,… )\text{f}(\text{ctx}, \dots)
  2. 边界不断go func() 启动子任务时,ctx 原样传入,不重新创建。
    go func(ctx)⇒ctxchild=ctxparent\text{go func}(\text{ctx}) \Rightarrow \text{ctx}_{child} = \text{ctx}_{parent}
  3. 禁止存储context 不得写入结构体字段或全局变量。
    ¬ (ctx∈struct field)\neg \,(\text{ctx} \in \text{struct field})
go9 行
// 错误:context 藏在结构体里type Server struct {    ctx context.Context // 别存这里!} // 正确:作为函数第一个参数流动func handle(ctx context.Context, req *Request) error {    return queryDB(ctx, req.ID)}

泄漏防范

演唱会结束后,导演对着空荡荡的舞台揉揉眼睛:“等等,灯光组那几个人怎么还没走?”所有人都道别离场了,只有几个工作人员还在后台待命——这就是 goroutine 泄漏:任务早就该结束了,但某些 goroutine 还悬在那儿,占着内存不走。

goroutine 泄漏的典型原因:

  • 起了 goroutine 却忘了规定它什么时候结束;
  • 等着 <-ctx.Done(),但没人触发 cancel,也没有超时,信号永远不来;
  • 调用了 WithCancel 却忘了执行返回的 cancel() 函数。

一个经典泄漏写法:

排查方法:用 Go 自带的 pprof 工具抓取 goroutine 栈,看哪些 goroutine 长时间停留在 chan receive 状态;或者用 runtime.NumGoroutine() 监控数量是否持续增长。最佳实践清单:defer cancel() 必须立刻写、子 goroutine 里 select 监听 ctx.Done()、所有外部 IO(数据库、网络请求)都挂超时 context。

防泄漏的代码模式:

导演清场时挨个房间检查是否还有工作人员滞留;你的 Go 服务也要持续观察 goroutine 数量曲线。不泄漏的并发代码,才是真正健壮的并发代码。

go func(ctx){work done⇒returnctx.Done()⇒return\text{go func}(\text{ctx}) \begin{cases} \text{work done} \Rightarrow \text{return}\\ \text{ctx.Done()} \Rightarrow \text{return} \end{cases}
一个健康的 goroutine 只有两个出口——活干完了,或者接到取消信号。两扇门都开着,才不会“有进无出”。
  1. 双通道退出select 同时等业务结果和 ctx.Done(),哪个先到都退出。
    select{result,  ctx.Done()}\text{select} \{ \text{result}, \; \text{ctx.Done()} \}
  2. defer 兜底函数一退出就执行 cancel(),不把取消责任留给别人。
    defer cancel()\text{defer cancel}()
  3. pprof 定位用 pprof 的 goroutine profile 找出堆积在 chan receive 上的 goroutine。
    pprof.Lookup("goroutine")⇒stack info\text{pprof.Lookup("goroutine")} \Rightarrow \text{stack info}
go6 行
func leak(ctx context.Context) {    go func() {        <-ctx.Done() // 如果没人取消,这个 goroutine 永远等在这里        fmt.Println("收工")    }()}

AI 应用场景

微服务网关的请求级超时控制

阶段:服务架构设计 / 请求路由

解决的问题:网关把请求转发到下游多个服务时,若没有统一超时控制,一个慢服务会把整条调用链拖垮,最终引发雪崩。context 的 WithTimeout 给每个转发请求装上“倒计时闹钟”,任意一环超时立即触发整链取消。

具体案例:在线推荐系统的 API 网关,对每个下游调用挂 2 秒 WithTimeout,并用 WithValue 传递 trace ID。某一环超时后,所有关联的 goroutine 同时收工,日志里能按 trace ID 完整回放这次请求的每一步耗时。

AI 推理服务的优雅停止

阶段:模型服务生命周期管理

解决的问题:模型热更新时需要停旧服务,但正在执行的推理请求不能暴力杀掉。context 的取消信号可以通知推理 goroutine“处理完当前批次再退出”,实现无缝切换。

具体案例:一个 OCR 识别服务部署了旧版和新版两个实例,发布系统向旧实例发送 cancel 信号,正在识别图片的 goroutine 收到后完成手头任务再退出,排队中的请求被分流到新实例,整个过程用户无感知。

批量数据处理任务的父控子停

阶段:批处理调度

解决的问题:数据清洗任务启动了 10 万个 goroutine 跑子任务,中途某个模块出错需要整体停止,否则 10 万个 goroutine 会一直空转到任务结束。context 树让“一键全停”成为可能。

具体案例:训练样本清洗流水线中,主节点用 WithCancel 创建根 context 传给所有 worker,任一 worker 检测到脏数据比例异常时调用 cancel(),整棵任务树立刻收工,配合 pprof 确认没有 goroutine 滞留。

Python 演示

python102 行
import timeimport threading  class Context:    """模拟 Go 的 context.Context 树形节点"""     def __init__(self, parent=None):        self.parent = parent        self._cancelled = False        self._children = []        self._values = {}        self._lock = threading.Lock()     def _add_child(self, child):        self._children.append(child)     def done(self):        """相当于 ctx.Done():判断是否已取消(含父链传播)"""        if self.parent and self.parent.done():            self.cancel()        with self._lock:            return self._cancelled     def cancel(self):        """相当于 CancelFunc:取消自己并递归通知所有后代"""        with self._lock:            if self._cancelled:                return            self._cancelled = True            children = list(self._children)        for c in children:            c.cancel()     def value(self, key):        """相当于 ctx.Value(key):向上查找键值"""        if key in self._values:            return self._values[key]        if self.parent:            return self.parent.value(key)        return None  def with_cancel(parent):    child = Context(parent)    parent._add_child(child)    return child, child.cancel  def with_value(parent, key, value):    child = Context(parent)    child._values[key] = value    parent._add_child(child)    return child  def with_timeout(parent, seconds):    child = Context(parent)    parent._add_child(child)     def _timer():        time.sleep(seconds)        child.cancel()     threading.Thread(target=_timer, daemon=True).start()    return child  def worker(name, ctx, delay=0.2):    time.sleep(delay)    if ctx.done():        print("[%s] 收到取消信号,收工" % name)    else:        print("[%s] 正常完成任务" % name)  def main():    root = Context()    req_ctx = with_value(root, "request_id", "req-10086")    print("链路中读到的请求 ID:", req_ctx.value("request_id"))     timeout_ctx = with_timeout(req_ctx, 0.1)    threads = [        threading.Thread(target=worker, args=("task-%d" % i, timeout_ctx))        for i in range(4)    ]    for t in threads:        t.start()    for t in threads:        t.join()     manual_ctx, cancel = with_cancel(req_ctx)    t = threading.Thread(target=worker, args=("manual-task", manual_ctx))    t.start()    time.sleep(0.1)    print("按下 cancel(),手动取消……")    cancel()    t.join()  if __name__ == "__main__":    main()
目的:用纯 Python 模拟 Go context 的树形取消、超时取消与链路传值,让读者在任意环境直观感受 context 的传播模型。
  1. class Context:模拟 Go 的 context 节点:每个节点记住父节点、取消状态和子节点列表
  2. def done(self):相当于 ctx.Done() 的判断:先沿父链确认是否已被取消
  3. def cancel(self):相当于执行 CancelFunc:把自己标记为取消,并递归通知所有后代
  4. def value(self, key):相当于 ctx.Value(key):本节点没有就向上级逐层查找
  5. def with_cancel(parent):模拟 WithCancel:返回子节点和“取消按钮”(子节点的 cancel 方法)
  6. def with_timeout(parent, seconds):模拟 WithTimeout:后台线程倒计时,到点自动取消
  7. timeout_ctx = with_timeout(req_ctx, 0.1)给请求链路挂 0.1 秒超时闹钟,4 个 worker 全部“超时收工”
  8. manual_ctx, cancel = with_cancel(req_ctx)手动取消的场景:按下 cancel() 后任务收工

常见误区

误区我觉得把 ctx 赋值给 Server 结构体的字段,任何方法都能直接用,省得每次传参,多方便!

正解context 的生命周期与单个请求绑定,是“随请求流动”的对象。把它存进结构体,等于把当日指挥令锁进抽屉:请求 A 的取消信号可能被请求 B 读到,导致串号、误取消。官方明确要求 context 必须作为函数首参显式传递,禁止存储。

为何常见:很多初学者希望“全局都能拿到 ctx”,把结构体字段当成便捷通道,忽略了 context 的请求级语义。

误区cancel 函数返回了但我没用,它自己会释放吧?无所谓了。

正解cancel 不调用,context 关联的资源(定时器、监听 goroutine)就永远无法释放。尤其 WithTimeout 每次都会注册一个定时器,忘记 cancel 会导致定时器堆积,内存持续上涨。defer cancel() 是铁律,创建后第一行就写。

为何常见:业务代码一忙就只顾主流程,把“清理动作”当成可选项,忽略了资源回收的确定性。

误区我调用 cancel() 后,那个 goroutine 应该立即被终止,像 kill 命令一样干脆。

正解context 只是“发信号”,不是“开枪”。goroutine 必须自己监听 ctx.Done() 并在 select 中 return。如果 goroutine 里只有一个阻塞的数据库查询而没监听 Done(),cancel 对它毫无影响。取消是协作式的,不是强杀式的。

为何常见:心智模型里“取消 = 终止”,但 Go 的哲学是“让每个任务自己决定如何优雅退出”。

误区context 这么好用,我把用户名、订单号、临时变量全塞进 WithValue,到处都能取到。

正解WithValue 只放请求级元数据(request ID、token、用户 ID),业务参数应该走函数参数。滥用 WithValue 会让函数签名变得隐晦、类型不安全,还可能在并发场景下读到不属于当前请求的值。

为何常见:觉得“塞进去就能在任何地方拿到”很省事,但省事的代价是代码可读性和安全性下降。

误区子任务取消了,调用它的父函数应该也收到信号才对,不然怎么知道子任务失败了?

正解context 的取消信号是单向向下的:父取消 → 所有后代取消;子取消 → 不影响父和兄弟。想向上通知得用别的机制(如 error 返回值、回调)。理解这个方向性,才能真正用好 context 树。

为何常见:凭直觉认为“树的结构是双向通信”,忽略了 context 设计的核心约束是“从根到叶的单向指挥”。

面试题

Q1. context.Context 接口包含哪四个核心方法?

单选题 · easy

  • A.Deadline、Done、Err、Value 四个方法← 正确答案
  • B.Cancel、Timeout、Deadline、Value 四个方法
  • C.Done、Stop、Err、Value 四个方法
  • D.Deadline、Done、Cancel、Value 四个方法

答案:A。Context 接口的四方法是 Deadline()、Done()、Err()、Value(key):分别回答“什么时候截止”“取消门铃在哪”“为什么结束”“工单便签是什么”。B 的 Cancel、C 的 Stop 都不是接口方法,D 把 Cancel 混进来了。

Q2. 创建 context 树的根节点应使用?

单选题 · easy

  • A.context.NewContext() 直接创建
  • B.context.Background() 创建根节点← 正确答案
  • C.context.TODO() 作占位临时使用
  • D.context.Root() 指定根节点

答案:B。根节点只能由 context.Background() 创建,它永不取消、无期限、无值,是整棵指挥树的“总裁办”。TODO() 只是不确定用什么时的占位符,Go 标准库没有 NewContext 和 Root 函数。

Q3. 关于 context 取消信号的传播方向,正确的是?

单选题 · hard

  • A.子 context 取消会反向取消父 context
  • B.父子之间取消信号互不影响
  • C.取消信号只能从父节点传到子节点← 正确答案
  • D.只有根节点才能发出取消信号

答案:C。context 树是“单向指挥链”——父取消传给所有后代,子取消不影响父和兄弟。这是 WithCancel/WithTimeout 基于父派生的实现决定的。B 说互不影响是错的,父子有明显方向性;D 错在任意节点的 cancel() 都能取消“以它为根的子树”。

Q4. WithTimeout(ctx, 5\*time.Second) 内部等价于?

单选题 · mid

  • A.WithDeadline(ctx, 当前时刻)
  • B.WithDeadline(ctx, 当前时刻加 5 秒)← 正确答案
  • C.WithCancel(ctx) 再加 sleep 5 秒
  • D.WithDeadline(ctx, 当前时刻减 5 秒)

答案:B。WithTimeout 内部把相对时长 5 秒换算成绝对时刻 now+5s,再交给 WithDeadline 挂定时器。A 是当前时刻会立即触发,D 是过去时刻必超时,C 用 sleep 无法精确管理定时器且阻塞线程。

Q5. 下列哪种做法会导致 goroutine 泄漏?

单选题 · mid

  • A.goroutine 中用 select 监听 ctx.Done() 和业务结果
  • B.调用 WithCancel 后立即执行 defer cancel()
  • C.goroutine 只等 ctx.Done() 却无人取消← 正确答案
  • D.外部网络请求都挂上 WithTimeout 超时

答案:C。C 中的 goroutine 卡在 Done() 上等不到任何取消信号,任务永不退出,是典型的“有进无出”。A 是健康双通道退出,B 的 defer cancel() 保证函数退出必取消,D 是超时兜底,三者都是防泄漏正确姿势。

Q6. context.WithValue 的正确用法是?

单选题 · mid

  • A.用字符串 key 塞入所有业务参数
  • B.只放请求 ID、token 等元数据← 正确答案
  • C.把可变大对象放 context 共享
  • D.用它替代函数参数传递所有数据

答案:B。WithValue 只放请求级元数据(请求 ID、用户 ID、token),B 正确。业务参数应走函数参数;可变大对象放 context 有并发读写风险;D 会让 API 隐晦且类型不安全。Go 官方也强调 value context 仅用于请求维度信息。

Q7. WithTimeout 超时触发后,ctx.Err() 的返回值是?

单选题 · hard

  • A.主动取消的 Canceled 信号
  • B.表示尚未结束的空值 nil
  • C.超时触发的 DeadlineExceeded← 正确答案
  • D.未定义的自定义 TimeoutErr

答案:C。超时取消后 Err() 返回 DeadlineExceeded;手动取消返回 Canceled;未结束时返回 nil。区分两者对上层错误处理很重要——超时要报 503/504,主动取消可静默忽略。标准库并不存在 TimeoutErr。

Q8. 以下哪些是 context 的正确传播规则?

多选题 · mid

  • A.context 作为函数第一个参数传递← 正确答案
  • B.context 可以存进结构体供全局使用
  • C.跨 goroutine 时要显式传递 context← 正确答案
  • D.context 应该被存储起来复用以提高性能

答案:AC。正确规则是首参传递(A)和跨 goroutine 显式传递(C)。B、D 是典型反模式——context 生命周期随请求,存结构体或全局复用会导致上下文串号、取消信号错乱,官方明确禁止。

Q9. 用 pprof 排查 goroutine 泄漏,哪些做法有效?

多选题 · extreme

  • A.抓取 goroutine 栈看停留状态← 正确答案
  • B.runtime.NumGoroutine 监控数量← 正确答案
  • C.直接重启服务解决泄漏
  • D.检查 chan receive 堆积现象← 正确答案

答案:ABD。A 用 pprof 抓栈看 goroutine 卡在哪;B 用运行时 API 观察数量增长曲线;D 检查大量 goroutine 阻塞在 channel 接收上,都是经典排查手段。C 重启只掩盖问题不根治,且泄漏已造成资源浪费。

Q10. 关于 WithDeadline 和 WithTimeout,正确的说法是?

多选题 · hard

  • A.内部都换算成 deadline 挂计时器← 正确答案
  • B.WithTimeout 等价于 now 加 d 的 Deadlne← 正确答案
  • C.WithDeadline 适合某时刻前完成场景← 正确答案
  • D.WithTimeout 不适合任何定时场景

答案:ABC。A 正确,标准库源码中 WithTimeout 内部调用 WithDeadline;B 是换算关系;C 是语义定位。D 明显错误——WithTimeout 恰恰是服务端超时控制最常用的工具。