Go Context 原理

目录

Go context 面试要点整理。覆盖:基本用法、取消与传播、超时与截止时间、传值、底层实现、内存模型、常见模式与陷阱。

基本用法

context(上下文)跨 API 边界传递三类信息:取消信号、截止时间、请求域数据。官方定义:a Context carries a deadline, a cancellation signal, and other values across API boundaries。

Context 接口只有四个方法:

  • Deadline():返回截止时间。没设置时 ok 为 false。
  • Done():返回一个 channel,context 被取消时关闭。永不可能取消的 context 返回 nil。
  • Err():Done 还没关闭时返回 nil;关闭后返回原因:context.Canceled(主动取消)或 context.DeadlineExceeded(超时)。
  • Value(key):按 key 取值,取不到返回 nil。

两个根 context,都不可取消:

  • context.Background():正式用的根,main 函数、初始化代码里创建。
  • context.TODO():还没想好用哪个 context 时占位,比如函数还没接上调用方的 context。
ctx, cancel := context.WithCancel(context.Background())
go func() {
	time.Sleep(10 * time.Millisecond)
	cancel()
}()
select {
case <-ctx.Done():
	fmt.Println(ctx.Err()) // context canceled
case <-time.After(time.Second):
	fmt.Println("timeout")
}

实际运行输出 context canceled。

三条传递规则(官方文档原文要求):

  • context 作为函数第一个参数,通常命名 ctx,不存进 struct。
  • 不要传 nil context。拿不准传什么时传 context.TODO()。
  • 同一个 context 可以安全地同时传给多个 goroutine。

取消

WithCancel(parent) 返回一个派生的 context 和 cancel 函数:

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 函数返回时释放资源

取消机制的核心性质:

  • 调用 cancel,ctx 的 Done channel 关闭,之后 Err() 返回 context.Canceled。
  • cancel 幂等:多次调用只生效一次,第二次起什么都不做。
  • 取消会传播:父 context 取消,所有派生的子 context 一起取消。
  • 子 context 取消不影响父,也不影响兄弟。
  • 不调用 cancel 的代价:资源不释放。WithTimeout 的定时器、propagateCancel 里挂的 goroutine 都留着(见底层实现一节)。

正确姿势是拿到 cancel 立刻 defer cancel(),不是等用完了才想起。

超时与截止时间

ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
<-ctx.Done()
fmt.Println(ctx.Err()) // context deadline exceeded

实际运行输出 context deadline exceeded。

  • WithTimeout(parent, d) 就是 WithDeadline(parent, time.Now().Add(d)),一层包装。
  • WithDeadline(parent, t) 到点自动取消,Err() 返回 context.DeadlineExceeded。
  • 手动 cancel 提前生效,Err() 返回 context.Canceled,不是 DeadlineExceeded。
  • 父 context 的 deadline 比新 deadline 更早时,WithDeadline 直接退化成 WithCancel(parent):父先到点,子跟着取消,语义等价,省一个定时器。
  • deadline 已经过了(dur <= 0),立即取消,不启动定时器。

区别 Canceled 和 DeadlineExceeded:主动调用 cancel 是前者,到点自动触发是后者。

传值

type key int

var userKey key

ctx := context.WithValue(context.Background(), userKey, "nite")
fmt.Println(ctx.Value(userKey)) // nite
fmt.Println(ctx.Value(key(99))) // <nil>

实际运行输出 nite 和 <nil>。

规则:

  • 只放请求域数据(request-scoped),不放函数参数。官方原话:not for passing optional parameters to functions。
  • key 必须可比较(comparable)。key 不能用内置类型(string、int 等),要自定义类型,避免不同包之间的 key 冲突。
  • key 为 nil 或不可比较,WithValue 直接 panic。
  • 值查找沿 context 链向上:本层没有就找父,找到返回,找不到返回 nil。链越长查找越慢。

底层实现

源码在 src/context/context.go,Go 1.7 从 x/net/context 搬进标准库。核心是三个结构。

cancelCtx

type cancelCtx struct {
	Context
	mu       sync.Mutex            // 保护以下字段
	done     atomic.Value          // chan struct{},惰性创建,第一次 cancel 时关闭
	children map[canceler]struct{} // 子 context 集合,第一次 cancel 后置 nil
	err      atomic.Value          // 第一次 cancel 时写入
	cause    error                 // 第一次 cancel 时写入
}

四个要点:

  • done 惰性创建:没人调用 Done() 就不分配 channel。第一次调用 Done() 时才创建。
  • Done() 返回值固定:多次调用返回同一个 channel。
  • Value(&cancelCtxKey) 返回自身,这是取消传播能找到内层 cancelCtx 的关键(见 propagateCancel)。
  • children 是 map,cancel 时遍历所有子 context 逐个取消。

done 为什么是 atomic.Value 而不是 bool?done 的职责是通知,不是状态。状态位由 err 字段承担:非 nil 即已取消,cancel() 靠它判断幂等。bool 给不了通知:

  • select { case <-ctx.Done(): } 要一个能阻塞等待的对象,bool 只能轮询,组合不进 select。
  • channel close 唤醒所有等待者,是广播。一个 context 可能被任意多个 goroutine 同时监听,bool 是单值,没有通知能力。
  • channel 关闭自带 happens-before 语义(见内存模型一节),bool 没有。

atomic.Value 是 channel 的容器,用来惰性创建和无锁读。Done() 的实现:

func (c *cancelCtx) Done() <-chan struct{} {
	d := c.done.Load()
	if d != nil {
		return d.(chan struct{})
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	d = c.done.Load()
	if d == nil {
		d = make(chan struct{})
		c.done.Store(d)
	}
	return d.(chan struct{})
}

double-checked locking:先无锁 Load,命中直接返回,miss 才拿锁创建。channel 是重对象,多数 context 从没人监听 Done(),惰性创建省下这些分配;热路径上每次 Done() 都是无锁读,用普通 channel 字段就得次次加锁。

历史上 done 最初是构造时直接 make(chan struct{}),Done() 无锁直读,代价是每个 cancelCtx 都白分配一个 channel(x/net/context 时代和 Go 1.7 刚进标准库时都是这样)。Go 1.9 改成惰性创建,用 mutex 保护,Done() 每次都要拿锁;Go 1.20 换成 atomic.Value,读路径无锁。

cancel() 的流程:

  1. err 已非 nil 直接返回,这就是幂等的实现。
  2. 写入 err 和 cause。
  3. 关闭 done channel。没人调用过 Done() 时,done 为 nil,直接存一个包级共享的已关闭 channel(closedchan),省一次分配。
  4. 遍历 children,逐个调用子 context 的 cancel。
  5. children 置 nil。
  6. 需要时把自己从父的 children 里移除。

propagateCancel:取消怎么传播

WithCancel 创建子 context 时调用 propagateCancel(parent, child),把子挂到父上。按顺序四条分支:

  • 父的 Done() 返回 nil(父不可取消):什么都不做,子永远不会被父取消。
  • 父的 Done 已关闭(父已经取消):子立即取消,err 和 cause 继承父的。
  • 父是标准库 cancelCtx(通过 parentCancelCtx 找到):直接把子加进父的 children map。父取消时遍历取消所有子。
  • 父是自定义 context 实现:起一个 goroutine,select 父的 Done 和子的 Done,谁先关闭谁退出,父先关闭就取消子。

parentCancelCtx 靠 parent.Value(&cancelCtxKey) 找内层 cancelCtx:valueCtx 的 Value 委托给父,cancelCtx 的 Value 对自己返回自身,所以从任意派生链上都能找到最内层的 cancelCtx。找不到(比如父是自定义实现包了别的 Done channel)才走 goroutine 路径。goroutine 在子或父任一侧 Done 关闭后退出;两侧都不关闭,goroutine 就泄漏——这就是「必须调用 cancel」的底层原因之一。

父子关系一共就这几条:

场景 行为
父取消 遍历 children,逐个取消子,err/cause 继承父
子取消 只把自己从父的 children 摘除,父和兄弟不受影响
创建时父已取消 子立即取消,不挂进 children
创建时父不可取消 不建立任何关系,子独立
父取消后新挂的子 走「父已取消」分支立即取消,children 已置 nil,不会再挂上

机制上三点:

  • children 只挂在 cancelCtx 上(timerCtx 内嵌 cancelCtx,也算)。valueCtx 不持有 children,作为父时被 parentCancelCtx 穿透,子实际挂在最内层 cancelCtx 上。
  • 取消向下传播时子调用 cancel(false, ...),不做摘除动作;父遍历完把 children 置 nil,整棵子树的关系一次清空。主动 cancel() 才走 cancel(true, ...),removeChild 把自己从父摘除。
  • 子被父取消时用的是父的 err 和 cause(child.cancel(false, parent.Err(), Cause(parent))),所以父子 Err() 一致,Cause() 也沿链一致。

timerCtx:WithDeadline 的载体

type timerCtx struct {
	cancelCtx
	timer    *time.Timer // 受 cancelCtx.mu 保护
	deadline time.Time
}

内嵌 cancelCtx,加一个定时器和一个 deadline:

  • WithDeadline 用 time.AfterFunc(dur, cancel) 启动定时器,到点自动取消。
  • deadline 已过(dur <= 0)不启动定时器,直接取消。
  • 手动 cancel 时先 timer.Stop() 停掉定时器再走 cancelCtx 的取消流程。所以 defer cancel() 不只是取消信号,还负责停定时器。

WithDeadline 的完整流程:

func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		return WithCancel(parent) // 父的 deadline 更早,退化成普通取消
	}
	c := &timerCtx{deadline: d}
	c.cancelCtx.propagateCancel(parent, c) // 先挂到父上
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded, cause) // deadline 已过,立即取消
		return c, func() { c.cancel(false, Canceled, nil) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err.Load() == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded, cause)
		})
	}
	return c, func() { c.cancel(true, Canceled, nil) }
}

定时器在锁内、确认 err 为 nil 后才启动:propagateCancel 之后父可能已经取消,这时再启动定时器就是白分配,回调 fire 时也只会撞上「已取消」的幂等分支。

timerCtx.cancel 覆盖了内嵌 cancelCtx 的 cancel:

func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
	c.cancelCtx.cancel(false, err, cause) // 先广播取消(关 done、逐个取消 children)
	if removeFromParent {
		removeChild(c.cancelCtx.Context, c) // 再从父的 children 摘除
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop() // 最后停定时器,释放资源
		c.timer = nil
	}
	c.mu.Unlock()
}

手动 cancel 传 Canceled,超时回调传 DeadlineExceeded,同一个取消流程,两种错误来源。timerCtx 只重写 Deadline()(直接返回存的 deadline)和 cancel(),Done()/Err() 全部复用内嵌的 cancelCtx。

valueCtx:WithValue 的载体

type valueCtx struct {
	Context
	key, val any
}

只存一对 key-value,其他方法全部委托给内嵌的父 context。Value() 本层 key 不匹配就往上找,这就是查找沿链向上的实现。

WithValue 的三个 panic 检查:parent 为 nil、key 为 nil、key 不可比较(reflectlite.TypeOf(key).Comparable())。key 的不可比较检查是运行时才做的,所以传 slice、map 这类 key 会在调用 WithValue 时直接 panic,而不是等到 Value() 查找时。

Value() 的查找不是递归,是循环 + 类型断言(value() 函数):

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:
			if key == &cancelCtxKey {
				return c
			}
			c = ctx.Context
		case *timerCtx:
			if key == &cancelCtxKey {
				return &ctx.cancelCtx
			}
			c = ctx.Context
		case backgroundCtx, todoCtx:
			return nil
		default:
			return c.Value(key)
		}
	}
}

几个细节:

  • 循环代替递归,链再长也不会有栈增长。
  • 对 cancelCtxKey 的命中是特判:cancelCtx 返回自身、timerCtx 返回内嵌的 cancelCtx,这是 parentCancelCtx 能穿透 valueCtx 找到内层 cancelCtx 的机制(见 propagateCancel 一节)。
  • 走到 backgroundCtx/todoCtx 返回 nil,链到头了。
  • 遇到不认识的自定义 context 类型,退回调用它的 Value(key),保证自定义实现的行为不被破坏。

Go 1.21 起的补充

  • WithCancelCause / Cause:cancel 时带一个错误作为取消原因,Cause(ctx) 取回。父先取消时子继承父的 cause。
  • AfterFunc:context 取消时调度一个函数执行,返回的 stop 函数可以在取消前撤销注册。
  • WithoutCancel:派生出不受父取消影响的 context,Done 返回 nil,Err 永远返回 nil,常用于日志、后台清理这类「父死了也要干完」的场景。

内存模型

Done channel 的关闭遵守 channel 关闭规则:关闭 happens-before 任何观察到关闭的接收。所以 cancel 里对共享数据的写入,对 <-ctx.Done() 返回之后的 goroutine 可见——取消信号本身就携带同步语义,不需要额外加锁。

两个细节:

  • 文档注明 Done 的关闭可能异步发生:cancel 函数返回后,channel 才被关闭。
  • Err() 返回非 nil 前会先等 Done 关闭(源码里 <-c.Done()),保证「Err 非 nil」和「Done 已关闭」严格一致。

常见模式

请求级 context

HTTP 服务里,每个请求的 context 从入口一路传下去,下游函数都收 ctx 参数。客户端断开、超时、服务端主动取消,整条调用链一起停。

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	result, err := doWork(ctx)
	// ...
}

func doWork(ctx context.Context) (Result, error) {
	select {
	case <-ctx.Done():
		return Result{}, ctx.Err()
	case result := <-workCh:
		return result, nil
	}
}

慢操作包一层超时

调用方无法控制的下游操作,用 WithTimeout 包一层,防止无限等待:

ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
result, err := slowService(ctx)

注意 defer cancel():慢操作提前返回时立刻停掉定时器,释放资源。

陷阱清单

  • 忘记调用 cancel:定时器、传播 goroutine 不释放。拿到 cancel 立刻 defer。
  • 把 context 存进 struct 字段:官方明确禁止,应该作为参数传递。
  • 用 context 传函数参数:WithValue 的 key 不是类型安全的,取出来要断言,参数应当走正常入参。
  • 传 nil context:WithCancel(nil)、WithValue(nil, ...) 直接 panic。拿不准传 context.TODO()。
  • goroutine 里用父 context 而不派生:goroutine 应该用 context.WithCancel(parent) 派生的 context,父取消时 goroutine 里的 select 才能响应退出。
  • 把 cancel 函数传出去用:cancel 应该由创建它的函数调用,defer cancel() 是标准写法,不要传给别的 goroutine 乱调。

面试题

Context 是什么,解决了什么问题?

答:跨 API 边界传递截止时间、取消信号和请求域数据的标准机制。解决的问题是并发程序的取消和超时:多个 goroutine 协作时,一个请求失败或超时,需要有一条链把「停下来」的信号传到所有相关 goroutine,而不是靠手动维护一堆 channel 或共享标志位。

Context 接口有哪几个方法?

答:四个。Deadline() 返回截止时间;Done() 返回取消信号 channel;Err() 返回取消原因;Value(key) 取请求域数据。

Background 和 TODO 的区别?

答:功能上完全一样,都是不可取消的空 context,区别在语义。Background 是正式使用的根;TODO 是还没决定用哪个 context 时的占位符,提醒后面要替换。

父 context 取消后,子 context 会怎样?

答:子 context 一起取消,Done 关闭,Err 返回父的取消原因。反过来,子取消不影响父。这是取消传播:从根到叶单向传导。

不调用 cancel 会有什么后果?

答:资源泄漏。WithTimeout/WithDeadline 的定时器不停,等不到超时那一刻不释放;父是不可取消的自定义 context 时,传播用的 goroutine 也留着。所以规范是拿到 cancel 立即 defer cancel(),保证任何返回路径都释放。

WithTimeout 和 WithDeadline 是什么关系?

答:WithTimeout(parent, d) 是 WithDeadline(parent, time.Now().Add(d)) 的包装。底层都是 timerCtx:一个定时器到点触发取消。区别只是参数形式:时长还是具体时刻。

Err() 什么时候返回 Canceled,什么时候返回 DeadlineExceeded?

答:主动调用 cancel 返回 context.Canceled;超时或 deadline 到点自动取消返回 context.DeadlineExceeded。两者都是 error 类型,可以用 errors.Is 判断。

context 的值查找是怎么实现的?

答:valueCtx 只存一对 key-value,Value() 本层找不到就委托父 context 继续找,沿链向上直到根或命中。cancelCtx 对内部 key(cancelCtxKey)返回自身,用于取消传播定位。key 必须可比较且不能是内置类型,避免冲突。

如何让 goroutine 响应取消?

答:goroutine 里用派生的 context,在 select 里监听 <-ctx.Done(),收到信号后做清理并退出(写法见上文 doWork)。配合 WithTimeout,还能给 goroutine 加超时上限。

CPU 密集的循环任务没有阻塞点,每步之间轮询检查:

for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}
	step()
}

阻塞调用优先把 ctx 传给库,http、database/sql、net 这些库内部已经监听 Done,取消时立即返回错误:

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
db.QueryContext(ctx, ...)

旧库不接受 ctx 时,用 goroutine 包装加 select 兜底,但底层调用不返回的话 goroutine 会泄漏,能换支持 ctx 的库就换。

context 能存进 struct 吗?

答:不能。官方规定 context 必须作为参数显式传递,第一个参数,通常命名 ctx。存 struct 会破坏取消信号的传播路径,静态分析工具也没法检查传递是否完整。

编辑此页

目录