Files

203 lines
9.6 KiB
Markdown
Raw Permalink Normal View History

2026-08-09 19:06:40 +08:00
---
tags: [test/review, go, context, cancellation, goroutine-lifecycle]
create time: 2026-08-09 12:00
---
# Context 包详解_测试题
## 概述
本试卷覆盖 Context 接口定义、WithCancel/Timeout/Deadline/WithValue 四种 WithXXX 函数、链式传播机制、WithValue 性能陷阱及最佳实践,共 10 道题(6 选择 + 3 填空 + 1 简答)。
---
## 一、选择题(6道,由浅入深)
> **难度阶梯**: Q1-Q2 基础概念 → Q3-Q4 核心原理 → Q5-Q6 深入应用/边界场景
### Q1(基础)— 考察定义层面
Go Context 接口中,哪个方法用于获取取消信号?
A. `Deadline()` — 返回取消截止时间
B. `Done()` — 返回一个只读 channel,ctx 被取消时关闭
C. `Err()` — 返回取消原因错误
D. `Value(key)` — 键值对查询
### Q2(基础)— 考察行为判断
以下关于 `context.WithTimeout` 的描述,哪一个是**正确**的?
A. WithTimeout 与 WithCancel 功能完全相同,没有任何区别
B. WithTimeout 内部组合了 WithCancel 和一个 timer,会额外启动一个 goroutine
C. WithTimeout 不会创建新的 goroutine,所有等待都在用户态完成
D. WithTimeout 设置的超时时间到达后,子 ctx 自动 cancel 但父 ctx 不会受影响
### Q3(进阶)— 考察原理理解
关于 Context 的链式传播机制,当父 ctx 被 cancel 时会发生什么?
A. 只有直接监听该 ctx.Done() 的 goroutine 收到信号
B. cancel 信号从父到子递归传播,所有后代 ctx 都会被取消
C. 只有设置了 deadline 的子 ctx 会被取消,没有 deadline 的不受影响
D. 需要手动在代码中遍历 children map 逐个取消
### Q4(进阶)— 比较辨析
以下哪个场景**最适合**使用 `context.WithValue`?
A. 在深层嵌套的业务逻辑中传递用户偏好设置
B. 在 HTTP handler 中间件中携带 trace ID 供下游日志使用
C. 将数据库连接对象存入 context 跨层传递
D. 用 context 替代函数参数传递频繁的计数器和状态变量
### Q5(深入)— 场景推理
阅读下面代码,请问 main 函数的输出结果是什么?
```go
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 注意这行注释掉了的情况
select {
case <-time.After(1 * time.Second):
fmt.Println("done")
case <-ctx.Done():
fmt.Println(ctx.Err())
}
}
```
A. 打印 "done"
B. 打印 "<nil>"
C. 打印 "context deadline exceeded"
D. 永久阻塞
### Q6(深入)— 源码级/边界场景
关于 context.Value 的性能特征,以下说法哪个是**错误**的?
A. Value() 查找沿链路逐层查找匹配的 key,时间复杂度为 O(depth)
B. 每次调用 WithValue 都会分配一个新的 context 对象
C. 如果 key 是 slice/map/function 类型,会导致运行时 panic
D. WithValue 会修改已有节点并原地更新 key-value 对以节省内存
---
## 二、填空题(3道)
### F1 — WithTimeout 底层实现
`context.WithTimeout(parent, timeout)` 的内部实现等价于 `context.WithDeadline(parent, time.Now().Add(timeout))`。两者底层使用同一个 `[填空1]` 结构体,它嵌入了 `*cancelCtx` 并持有一个 `time.Timer` 来触发定时取消。这意味着 WithTimeout 比 WithCancel 多了一个 `[填空2]` 开销。
> **提示**: 结构体名称是 Timer + Context 的组合词;额外的开销来源于定时器的调度机制。
### F2 — cancelCtx 数据结构
cancelCtx 是 Context 链式结构中最重要的节点类型,其关键字段包括:`mu sync.Mutex` 保护并发安全、`done chan struct{}` 用于发送取消信号、`children map[canceler]*cancelCtx` 追踪直接子节点集合、`err error` 存储取消原因、以及 `[填空1]` 布尔标志位表示是否已清理完子节点。
> **提示**: 这个字段名暗示了操作状态,防止重复清理子节点导致的问题。
### F3 — WithValue 的 key 最佳实践
使用 context.WithValue 时,key 必须是可比较的类型(支持 == 比较)。最佳实践是使用自定义不可导出的类型作为 key,例如定义 `type userIDKey struct{}`,这样不仅可以避免与其他包的 key 冲突,还可以利用 Go 的类型系统防止外部代码误用相同的 key。取值时需要配合类型断言:`id, ok := ctx.Value(userIDKey{}).([填空1])`。
> **提示**: 注意 Value 返回的是 any 类型,取出来需要 type assertion。
---
## 三、简答题(1道)
### S1
一个 Web API 处理流程如下:
1. HTTP handler 接收到请求
2. 先做鉴权,获取用户信息
3. 根据用户信息从数据库查订单列表
4. 同时从 Redis 查缓存,取最新商品推荐
5. 将以上三个结果合并后返回
每个步骤都需要有超时控制和完整的取消链路。请设计一套基于 Context 的方案,说明如何管理这个三层级的 goroutine 树(handler → worker goroutine → sub-task goroutine),确保任意一个环节失败或超时时整个请求能正确退出。
> **答题框架提示**:
> 1. 顶层 ctx 从哪里来?如何加超时?
> 2. 如何优雅地并行执行 DB 查询和缓存读取?
> 3. cancel 的释放时机和位置在哪里?
---
## 参考答案与解析
### 选择题答案
| 题号 | 正确答案 | 解析 |
|------|---------|------|
| Q1 | B | Done() 返回只读 channel,ctx 被取消时该 channel 关闭,所有监听者同时收到信号。A 只返回时间不产生信号;C 只返回取消原因字符串;D 用于附加值传递。 |
| Q2 | B | WithTimeout 内部调用 WithDeadline(now + timeout),创建一个 timerCtx 包含 timer 和一个后台 goroutine 等待超时触发 cancel。所以比 WithCancel 多了一个 goroutine 开销。D 的描述部分正确但不够完整——超时后是当前 ctx 被取消,不是父 ctx。 |
| Q3 | B | Context 采用树形嵌套结构,每个 cancelCtx 维护 children map。当父 ctx 被取消时,遍历 children 逐个取消,递归地向后代传播。这是一个自顶向下的 cascade 过程。 |
| Q4 | B | A 太深的嵌套不适合用 WithValue(性能差且 GC 压力大);C 明确违反"不用 context 传持久化资源"的原则;D 也是滥用 context。B 是标准用法——在中间件层加入跨层元数据(如 trace ID),通过 r.WithContext(ctx) 传入 handler 链。 |
| Q5 | A | time.After(1s) 比 WithTimeout(2s) 更快触发,所以 1 秒后 select 匹配到 time.After 的 case,打印 "done"。虽然 ctx 最终也会在 2 秒后被自动取消,但 select 已经在前一步完成了匹配。如果注释掉 defer cancel(),这里的行为不变——cancel 只是释放资源,不影响已完成的 select。 |
| Q6 | D | WithValue 不会修改已有节点,而是创建整个链路的新副本路径(每条边可能都要新建节点),N 次调用产生 N 个对象。A/B/C 均正确描述了 WithValue 的特性。D 恰恰相反——它不原地修改,而是分配新对象。 |
### 填空题答案
| 题号 | 答案 | 解析 |
|------|------|------|
| F1 | `timerCtx`,`goroutine` | timerCtx 结构体包含 embed 的 *cancelCtx 和一个 time.Timer。底层由一个单独的 goroutine 等待 timer 触发后调用 cancel,因此比纯 WithCancel 多了一个 goroutine 开销。 |
| F2 | `childCleared` | childCleared 标记防止重复清理子节点。一旦子节点被清理完毕,该标志设为 true,后续即使再次触发取消也可以跳过遍历 children map 的步骤。 |
| F3 | `string` (或更通用的 `[类型]`) | ctx.Value() 返回 any 类型,取出时必须进行类型断言。示例中使用 string 是因为 value 本身是字符串类型。关键在于 key 用自定义私有类型而非公开类型,避免命名冲突。 |
### 简答题参考答案
S1:**参考答案要点**:
1. **顶层 ctx 继承 r.Context 并加超时**:
```go
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel() // handler 返回前必须释放
// ...
}
```
继承 `r.Context()` 保证上游 middleware(如 request-ID 注入)不被打断。
2. **并行执行 DB 查询和缓存读取**:用两个独立的 goroutine + WaitGroup,每个 sub-task 使用衍生自顶层 ctx 的子 ctx(各自可能有不同的超时):
```go
var wg sync.WaitGroup
ordersCh := make(chan []Order, 1)
recommendationsCh := make(chan []Product, 1)
wg.Add(2)
go func() {
defer wg.Done()
subCtx, sc := context.WithTimeout(ctx, 500*time.Millisecond)
defer sc()
orders, _ := db.QueryOrders(subCtx, userID)
ordersCh <- orders
}()
go func() {
defer wg.Done()
subCtx, sc := context.WithTimeout(ctx, 200*time.Millisecond)
defer sc()
recs, _ := redis.GetRecommendations(subCtx, userID)
recommendationsCh <- recs
}()
wg.Wait() // 等两个并行任务完成
```
每个 sub-task 用独立 Timeout 控制不同 SLA,互不影响。
3. **cancel 的释放时机**:
- handler 层的 `defer cancel()` 确保响应返回或发生 panic 时都能释放顶层 ctx 的资源(包括关联的 timer goroutine)
- 各 sub-task 内部的 `defer sc()` 确保各自的定时器也会被停止
- 任何环节检测到 `ctx.Err() != nil` 应立即终止,不再继续后面的操作
4. **关键原则回顾**:always pass ctx as first param、always defer cancel、never store ctx in structs、use context for cancellation not data transfer。
**评分标准**:答出任意 2 个要点即可得满分;完全正确需覆盖全部要点。
## 关联笔记
- [[00.Go/concurrency/Context 包详解]]