# 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。

```go
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 函数：

```go
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()`，不是等用完了才想起。

## 超时与截止时间

```go
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 是前者，到点自动触发是后者。

## 传值

```go
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

```go
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()` 的实现：

```go
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 的载体

```go
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` 的完整流程：

```go
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：

```go
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 的载体

```go
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()` 函数）：

```go
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 参数。客户端断开、超时、服务端主动取消，整条调用链一起停。

```go
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 包一层，防止无限等待：

```go
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 密集的循环任务没有阻塞点，每步之间轮询检查：

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

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

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

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

### context 能存进 struct 吗？

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


---

> 作者: Nite  
> URL: https://www.nite07.com/zh-cn/posts/go-context/  

