vault backup: 2026-08-09 19:06:40

This commit is contained in:
2026-08-09 19:06:40 +08:00
parent e2975eb86b
commit 9d664545c9
46 changed files with 7287 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
---
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 包详解]]
@@ -0,0 +1,147 @@
---
tags: [test/review, go, goroutine, gmp-scheduler, work-stealing]
create time: 2026-08-09 12:00
---
# Goroutine 调度模型_测试题
## 概述
本试卷覆盖 Go GMP 调度模型的七大核心知识点:G/M/P 角色定义、状态转换、Local/Global RunQueue、Work Stealing、栈动态伸缩、GOMAXPROCS 影响及 goroutine 泄漏场景,共 10 道题(6 选择 + 3 填空 + 1 简答)。
---
## 一、选择题(6道,由浅入深)
> **难度阶梯**: Q1-Q2 基础概念 → Q3-Q4 核心原理 → Q5-Q6 深入应用/边界场景
### Q1(基础)— 考察定义层面
在 Go 的 GMP 调度模型中,P(Processor)的核心职责是什么?
A. 真正执行 goroutine 代码的 OS 线程
B. 调度的本地资源,拥有 local runqueue 和执行权限
C. 用户级协程,包含栈和指令指针
D. 负责与操作系统内核交互的系统调用封装
### Q2(基础)— 考察行为判断
一个 goroutine 进入 `Gwaiting` 状态的典型场景是?
A. 刚被 runtime 创建完成,尚未放入任何队列
B. 正在等待 channel 读写或 mutex 锁
C. 绑定了某个 P,正在执行用户代码
D. 从 P 的 local queue 取出,准备开始运行
### Q3(进阶)— 考察原理理解
关于 Work Stealing 机制,为什么空闲 P 从忙碌 P 的队列头部偷取 goroutine,而不是尾部?
A. 头部的 goroutine 数量更多,一次能偷到更多
B. 头部元素需要先分配更大的栈空间
C. 头部是最近加入的 goroutine,时间局部性好,减少 cache 干扰
D. 尾部元素已经被其他 M 绑定了,不允许从尾部偷
### Q4(进阶)— 比较辨析
Go 的 goroutine 与操作系统线程相比,以下哪个对比描述是正确的?
A. goroutine 栈大小固定为 2KB,不可增长
B. goroutine 创建需要陷入内核态,成本与 OS 线程相当
C. goroutine 并发规模可达百万级,而 OS 线程通常在数千级别
D. goroutine 由内核调度器负责调度切换
### Q5(深入)— 场景推理
某服务程序中观察到 goroutine 数量随时间持续增长且不会回落,最可能的原因是?
A. GOMAXPROCS 设置过小导致 CPU 竞争激烈
B. 存在 goroutine leak:如 channel 无人接收、context 从未取消等
C. goroutine 栈自动扩容超过了 maxStackSize
D. Work Stealing 机制导致 goroutine 在不同 P 之间频繁迁移
### Q6(深入)— 源码级/边界场景
关于 Go goroutine 栈的动态伸缩,以下说法哪一个是**错误**的?
A. minStackSize 在 64 位平台为 2KB
B. maxStackSize 可达 ~1GB
C. Goroutine 启动时就预先申请完整的 2KB 栈空间
D. 扩容时采用 old * 2 的策略,缩容采用 old / 2
---
## 二、填空题(3道)
### F1 — GMP 数量约束
在一个 Go 程序中,P 的最大数量上限为 `[填空1]`;每个 P 维护的 local runqueue 长度为 `[填空2]`。
> **提示**: P 的数量由 GOMAXPROCS 控制,local queue 的长度是一个固定的常数。
### F2 — 栈伸缩计算
假设一个 goroutine 当前栈大小为 8KB,在执行过程中连续发生两次 GrowStack(未超过 max),第一次扩容后栈大小为 `[填空1]` KB;若此时没有继续增长改为 ShrinkStack,则缩容后栈大小为 `[填空2]` KB。
> **提示**: 注意先扩容再缩容的顺序,不要搞反。
### F3 — G 状态流转
一个 goroutine 的正常生命周期路径是:`Gidle` → `Grunnable` → `[填空1]` → `Grunning` → 如果发生系统调用会变为 `[填空2]`,系统调用完成后回到 `[填空3]`。
> **提示**: 按实际执行顺序填写缺少的三个状态名。
---
## 三、简答题(1道)
### S1
考虑如下场景:一个 HTTP 服务端使用了大量 goroutine 处理请求,每个 handler 内部又派生了子 goroutine 做数据库查询和缓存读取。一段时间后服务变得极其缓慢,通过 pprof 发现 goroutine 数量从几百涨到了几万。
请分析可能导致 goroutine 暴增的三方面原因,并给出对应的修复策略。
> **答题框架提示**:
> 1. 分别从 goroutine 泄漏的三类常见场景入手思考
> 2. 结合 Context 的生命周期管理分析
> 3. 结合 Channel 的收发动作分析
> 4. 最后提出监控建议
---
## 参考答案与解析
### 选择题答案
| 题号 | 正确答案 | 解析 |
|------|---------|------|
| Q1 | B | A 描述的是 M(Machine Thread),C 描述的是 G(Goroutine),D 不是 P 的职责。P 本质是调度的本地资源,持有 local runqueue。 |
| Q2 | B | A 是 Gidle,C 是 Grunning,D 是 Grunnable 到 Grunning 的转换结果。Gwaiting 表示阻塞等待外部事件(channel IO、mutex、timer)。 |
| Q3 | C | 工作窃取设计中,头部是最近加入的元素,具有更好的时间局部性。原 P 自己从尾部取旧元素,从头部偷可以最小化对原 P 的 cache 干扰。A/B/D 均不符合实际实现。 |
| Q4 | C | A 错:goroutine 初始 2KB 但可动态伸缩到 1GB;B 错:goroutine 在内核外调度,创建成本极低;D 错:由 Go runtime 调度而非内核。只有 C 正确描述了并发规模的差异。 |
| Q5 | B | goroutine 持续增多是典型的 goroutine leak 信号——如 channel 永远无法完成收发、context 不取消导致 goroutine 无法退出等。A 导致性能低但不引起数量增长;C 不可能(不会超 max);D 只是正常现象。 |
| Q6 | C | Go 1.4+ 采用段式内存分配,goroutine 首次需要时才申请小段按需增长,不是一开始就预占 2KB。A/B/D 的描述均符合文档。这是常见的认知误区。 |
### 填空题答案
| 题号 | 答案 | 解析 |
|------|------|------|
| F1 | `1024`,`256` | P 最大数量为 1024(GOMAXPROCS 限制),每个 P 的 local runqueue 固定长度为 256。 |
| F2 | `16`,`8` | GrowStack:8KB * 2 = 16KB;ShrinkStack:16KB / 2 = 8KB。按顺序先扩后缩,回到原值。 |
| F3 | `Grunnable`,`Gsyscall`,`Grunnable` | 标准流程:Gidle(空闲) → 放入 runqueue → Grunnable(就绪) → 被 M 取出 → Grunning(运行) → 发生系统调用 → Gsyscall → 返回后重新变为 Grunnable。 |
### 简答题参考答案
S1:**参考答案要点**:
1. **Context 未传播取消信号**:handler 内的子 goroutine 监听了 ctx.Done(),但如果父 context 设置了 timeout 但未在所有层级传递 cancel(),或者某些 goroutine 没有检查 ctx.Done(),它们就会永远运行下去。修复:确保所有层级的 goroutine 都监听 ctx.Done(),并在 handler 结束时 defer cancel()。
2. **Channel 死锁/无人接收**:向无缓冲或被无限写入的 channel 发送数据,或 goroutine 等待一个永远不会收到数据的 channel recv,都会使 goroutine 永久阻塞。修复:使用 select + ctx.Done() 模式,或在合适的时机关闭 channel。
3. **定时器未清理**:使用 time.After() 或 time.NewTimer() 时未调用 Stop(),导致定时器回调 goroutine 泄漏。修复:使用 defer timer.Stop(),或在不再需要时主动停止。
4. **pprof 监控**:使用 `go tool pprof -inuse_goroutines` 定期检查活跃 goroutine 数量趋势,异常增长应立即告警。
**评分标准**:答出任意 2 个要点即可得满分;完全正确需覆盖全部要点。
## 关联笔记
- [[00.Go/concurrency/Goroutine 调度模型]]
@@ -0,0 +1,226 @@
---
tags: [test/review, go, select, poll-multiplexing, fairness]
create time: 2026-08-09 12:00
---
# Select 多路复用机制_测试题
## 概述
本试卷覆盖 Go select 关键字的底层实现、随机公平性保证、nil channel 行为、default 分支语义、goroutine leak 场景及与 context 的组合用法,共 10 道题(6 选择 + 3 填空 + 1 简答)。
---
## 一、选择题(6道,由浅入深)
> **难度阶梯**: Q1-Q2 基础概念 → Q3-Q4 核心原理 → Q5-Q6 深入应用/边界场景
### Q1(基础)— 考察定义层面
select 关键字在 Go 中能处理以下哪种操作?
A. 任意两个变量的比较运算
B. 多个 channel 的发送或接收操作
C. 多个 goroutine 的并行启动
D. 对 sync.Mutex 的加锁和解锁
### Q2(基础)— 考察行为判断
当 select 中有多个 case 同时满足条件时,Go 会如何处理?
A. 从上到下按顺序匹配第一个符合条件的 case
B. 从所有可匹配的 case 中随机选择一个
C. 优先选择 buffer 较大的 channel 对应的 case
D. 抛出一个 ambiguity error 让开发者修改代码
### Q3(进阶)— 考察原理理解
关于 nil channel 在 select 中的行为,以下描述正确的是?
A. 会导致 panic,程序崩溃
B. 该 case 永远不会被选中,类似于被永久禁用
C. 该 case 会被选中一次然后报错退出
D. 编译阶段就会报错,不允许写出这样的代码
### Q4(进阶)— 比较辨析
以下哪个选项**等价于**给 select 添加 default 分支的效果?
A. `case <-time.After(1 * time.Hour)` — 设置一个很长的超时
B. `case <-time.After(0)` — 设置超时时间为零
C. 将所有的 channel 改为有缓冲 channel
D. 使用 `for` 循环包裹 select 反复执行
### Q5(深入)— 场景推理
阅读下面代码,运行结果是什么?
```go
var ch chan int = nil
select {
case <-ch:
fmt.Println("received")
default:
fmt.Println("default")
}
```
A. 打印 "received"
B. 打印 "default"
C. 永久阻塞
D. 触发 panic
### Q6(深入)— 源码级/边界场景
以下哪种写法**不会**导致 goroutine leak?
```go
// A
func a() {
for {
select {
case v := <-ch:
process(v)
}
}
}
// B
func b(ctx context.Context, ch <-chan int) {
for {
select {
case v, ok := <-ch:
if !ok { return }
process(v)
case <-ctx.Done():
return
}
}
}
// C
func c() {
timer := time.NewTimer(5 * time.Minute)
// 没有调用 timer.Stop(),timer goroutine 持续运行
}
// D
func d() {
for {
select {
case <-time.After(1 * time.Second):
doSomething()
}
}
}
```
A. A
B. B
C. C
D. D
---
## 二、填空题(3道)
### F1 — select 内部结构
每个 select 语句在编译时被转换为一个 `selstruct` 结构体,其中 `[填空1]` 字段存储 case 关联的 channel 指针数组,`order` 字段存储随机化后的访问顺序。
> **提示**: 这个字段名是英文复数形式,对应 "channels" 的缩写。
### F2 — 随机性函数
Go 调度器中用于处理 select 多路复用的关键 netpoll 函数包括:`pollSurprise`(已注册 goroutine 突然可唤醒)、`pollRandom`(多选一时随机选择保证 `[填空1]`),以及 `pollDelay`(无 case 就绪时进入睡眠)。
> **提示**: pollRandom 的核心设计目的是防止某些 case 被系统性忽略。
### F3 — 性能对比
在有延迟的场景下,应当优先使用 `default` 分支而非 `time.After(0)`,因为 time.After() 背后会创建一个 `[填空1]`,即使是 0 延迟也有额外开销。
> **提示**: time.After 内部使用了定时器相关的系统资源。
---
## 三、简答题(1道)
### S1
某微服务中使用如下 Worker Pool 模式处理任务:
```go
func startWorkers(ctx context.Context, jobs <-chan Job) {
for i := 0; i < 10; i++ {
go func(id int) {
for job := range jobs {
process(job)
}
}(i)
}
}
```
该函数的调用者负责发送 job 到 jobs channel,但从未关闭过 channel。现在需要为这个 worker pool 增加优雅的停止能力。
请结合 select、context 和 channel 的知识,给出改进方案并说明至少三个关键点。
> **答题框架提示**:
> 1. 如何让每个 worker 感知到停止信号?
> 2. context 和 channels 如何配合使用?
> 3. stopper 通道的角色是什么?
---
## 参考答案与解析
### 选择题答案
| 题号 | 正确答案 | 解析 |
|------|---------|------|
| Q1 | B | select 只能用于 channel 相关操作(发送或接收)。A 应该用 if/else;C 用 `go` 关键字;D 用 Lock/Unlock。 |
| Q2 | B | select 的核心设计之一就是随机选择——当多个 case 同时就绪时,通过 pollRandom 随机选一个,避免优先级饥饿问题。如果没有随机性,总是固定从某个 case 开始匹配会导致系统性忽略其他 case。 |
| Q3 | B | nil channel 的 send/receive 本身会导致死锁(不是 panic),但在 select 中只是简单地不被选中,相当于永久禁用该 case。这不是编译错误,常被用于动态启用/禁用 case。 |
| Q4 | B | `time.After(0)` 会在 0 秒后立即触发定时器,等效于 default 的非阻塞语义。但 A 太长时间无效;C 不改变 select 的行为特性;D 反而会导致无限循环的空转。 |
| Q5 | B | nil channel 在 select 中永不匹配,所以 `<-ch` 不会被选中。由于存在 default 分支,立即执行 default,打印 "default"。这展示了 nil channel 作为 case 开关的用法。 |
| Q6 | B | A 无限循环且无退出路径,会 leak;C 未 Stop timer,Timer goroutine leak;D 每次迭代创建新的 Timer goroutine,永不停止则无限泄漏。只有 B 同时检查了 channel close(ok == false)和 ctx.Done(),有明确退出路径。 |
### 填空题答案
| 题号 | 答案 | 解析 |
|------|------|------|
| F1 | `chans` | selstruct 中包含 chans([]*hchan,case 关联的 channel 数组)、recv/send(收发地址数组)、order(随机化顺序)、ncases(case 数量)。 |
| F2 | `公平性` | pollRandom 的设计目的是保证多个可匹配 case 之间的公平性,防止某些 case 被系统性忽略(starvation)。面试中经常考"如果 select 按固定顺序匹配会发生什么"。 |
| F3 | `timer goroutine` | time.After() 内部创建了一个定时器 goroutine 来等待指定时长后发送值。即使超时时间为 0,这个 goroutine 也会被创建,产生不必要的开销。无延迟场景应直接 prefer default。 |
### 简答题参考答案
S1:**参考答案要点**:
1. **使用 context 传递取消信号**:worker 内部不应只用 `range`,而应改用 `for-select` 模式,同时在 case 中监听 `ctx.Done()`,这样 context 被取消时每个 worker 都能收到信号并返回退出。
2. **增加一个 stopper channel**:可以在 main/goroutine 中创建一个单独的 done channel,与 jobs channel 分开管理。当需要停止时,关闭 stopper channel,workers 检测到关闭后即退出。或者直接使用 ctx 的 Done channel。
3. **正确组合 select 与 ctx.Done()**:每个 worker 的 for 循环改写为:
```go
for {
select {
case job, ok := <-jobs:
if !ok {
return // jobs channel 被关闭
}
process(job)
case <-ctx.Done():
return // context 被取消
}
}
```
4. **调用方确保 cancel 被调用**:startWorkers 的调用者应在合适的时机(如 HTTP handler 返回时)调用 `cancel()`,并通过 defer 保证一定会执行。
**评分标准**:答出任意 2 个要点即可得满分;完全正确需覆盖全部要点。
## 关联笔记
- [[00.Go/concurrency/Select 多路复用机制]]
@@ -0,0 +1,160 @@
---
tags: [test/review, go, sync-primitives, futex, rwmutex-starvation]
create time: 2026-08-09 12:00
---
# Sync 包核心源码_测试题
## 概述
本试卷覆盖 Go sync 包的六大并发原语:Mutex(futex)、RWMutex(写饥饿模式)、WaitGroup(反 overflow 设计)、Once(双检锁)、Map(读写分离架构)和 Pool,共 10 道题(6 选择 + 3 填空 + 1 简答)。
---
## 一、选择题(6道,由浅入深)
> **难度阶梯**: Q1-Q2 基础概念 → Q3-Q4 核心原理 → Q5-Q6 深入应用/边界场景
### Q1(基础)— 考察定义层面
sync.Mutex 的 state 字段中,bit[31](最高位)表示什么含义?
A. 当前等待者的数量
B. 是否有 waiter 在排队
C. locked 位(1 表示锁被持有)
D. 是否处于饥饿模式
### Q2(基础)— 考察行为判断
以下关于 sync.Once 的描述,哪一个是**正确**的?
A. Do() 中的函数会被所有调用者并行执行一次
B. 如果 Do() 传入的函数 panic,后续再次调用 Do() 会重新执行该函数
C. Do() 确保传入的函数只被执行一次,即使在高竞争下也是如此
D. Once 可安全地被复制使用,副本与原 Once 共享状态
### Q3(进阶)— 考察原理理解
sync.Mutex 的快路径是如何实现加锁的?
A. 通过 gopark 进入睡眠等待其他 goroutine 唤醒
B. 通过 atomic CAS 操作尝试将 state 设为 locked
C. 通过 spin-loop 自旋直到 state 变为 0
D. 通过读取系统调用获取内核态互斥锁
### Q4(进阶)— 比较辨析
关于 sync.RWMutex 的写饥饿(starving)模式,以下说法哪个是正确的?
A. 饥饿模式下锁直接移交给队列末尾的 writer
B. 触发饥饿模式的条件是等待时间超过 1ms 且已有等待者
C. 饥饿模式下读者和 writer 公平竞争,与普通模式无区别
D. RWMutex 的 ReadLock 是可重入的,与 Java 的 ReentrantReadWriteLock 一样
### Q5(深入)— 场景推理
阅读下面代码,运行时会发生什么?
```go
func main() {
var wg sync.WaitGroup
go func() {
wg.Done() // counter 初始为 0,Done 会使 counter 变 -1
}()
wg.Wait()
fmt.Println("done")
}
```
A. 正常打印 "done"
B. 触发 panic:negative counter in WaitGroup
C. 永久阻塞
D. 编译报错
### Q6(深入)— 源码级/边界场景
关于 sync.Map 的设计,以下哪个描述是**错误**的?
A. readOnly 用于读多场景,完全不需要持锁即可查询
B. amended=true 时,读取 miss 会先去 dirty map 查找并将 key 预热到 readOnly
C. readOnly 和 dirty map 同时包含相同的 key 时,优先返回 dirty map 的值
D. 当 missed 积累到一定程度时,dirty map 会被提升为新的 readOnly
---
## 二、填空题(3道)
### F1 — Mutex state 位布局
sync.Mutex 的 state 字段用三个区域编码:`state[31]` 是 `[填空1]` 位,`state[30]` 是 `[填空2]` 位,`state[0-29]` 存储等待者数量。
> **提示**: bit[31] 表示锁是否被持有,bit[30] 表示是否有等待者在排队。
### F2 — WaitGroup 反溢出设计
WaitGroup 内部使用 int64 分高低两段:高 32 位表示 `[填空1]`(goroutine 数量),低 32 位表示 `[填空2]`(阻塞在 Wait() 上的数量)。分开设计的目的是防止 counter 绕回 0 导致 Wait() 误判。
> **提示**: Add 减少高位计数,Wait 增加低位计数,两者互不干扰。
### F3 — Once 的双检锁
sync.Once 使用两层检查机制:第一层 `atomic.LoadUint32(&o.done)` 是无锁快速路径——绝大多数情况下 done 已经是 1,直接 return,零锁开销;第二层在 `doSlow()` 中加锁后再次检查 `if o.done == 0`,这是 `[填空1]` 防止多个同时通过第一层检查的 goroutine 重复执行同一个函数的保护机制。
> **提示**: 这被称为 Double-Check Locking 模式,两层检查缺一不可。
---
## 三、简答题(1道)
### S1
某电商服务需要实现一个商品库存计数器,要求:
- 多线程环境下安全递增/递减
- 读操作极其频繁(每秒数万 QPS),写操作相对较少
- 初始化逻辑只需执行一次
请说明你会分别选用 sync 包中的哪些原语来实现上述三种需求,并给出每种选择的理由和注意事项。
> **答题框架提示**:
> 1. 高频读 + 低频写场景用什么锁?为什么不选普通 Mutex?
> 2. 单次初始化用什么原语?它为什么比直接用 mutex 更高效?
> 3. 如果有批量操作或任务协调,如何结合 WaitGroup?
---
## 参考答案与解析
| 题号 | 答案 | 解析 |
|------|------|------|
| S1 | **参考答案要点**: | |
1. **sync.RWMutex**:读多写少场景应选 RWMutex 而非 Mutex。RWMutex 允许多个 reader 同时持有读锁,在高读低写时性能显著优于独占式的 Mutex。但如果读写比例接近或临界区极短,Mutex 反而更好(省去额外的位运算开销)。此外,RWMutex 的 ReadLock 不是可重入的,已持有读锁的 goroutine 再次请求会死锁。
2. **sync.Once**:单次初始化使用 Once 比手动配合 mutex 更简洁高效。Once 采用双检锁模式——第一次调用时才真正执行初始化函数,之后所有调用的 cost 几乎为零(只是一次原子 load)。注意:如果初始化函数 panic,Once 不会重试,done 仍为 1。
3. **sync.WaitGroup 的注意点**:如果涉及批量任务的等待和协调,可用 WaitGroup。但需注意:WaitGroup 不可复制(copy 后行为不可预测),应在启动 goroutine 前调用 Add(),且不能用作信号量(那是 buffered channel 的用途)。
**评分标准**:答出任意 2 个要点即可得满分;完全正确需覆盖全部要点。
### 选择题答案
| 题号 | 正确答案 | 解析 |
|------|---------|------|
| Q1 | C | A 对应 state[0-29](等待者数量),B 对应 state[30](waiter 位),D 是 RWMutex 的概念。sync.Mutex 只用三个位区域,最高位 bit[31] 是 locked 标志。 |
| Q2 | C | A 错:只会执行一次,不是并行执行多次;B 错:panic 后 done=1,后续直接跳过不再执行;D 错:Once 不可复制,Copy 后的行为不可预测。 |
| Q3 | B | Mutex 快路径通过 atomic CAS 尝试将 state 设为 locked,竞争激烈时几乎零开销。A 是慢路径(slowLock);C 不完全准确——Go 1.9+ 实现了自适应自旋(低竞争短暂自旋,高竞争立即休眠),但核心的加锁原语仍是 CAS;D 错误,Go Mutex 纯用户态实现。 |
| Q4 | B | A 错:饥饿模式下锁移交给队首 writer,不是末尾;C 错:饥饿模式下后续 reader 会被挡在外面,直接向队首 writer 移交;D 错:RWMutex 的 ReadLock 不可重入,这与 Java 不同。只有 B 描述了正确的触发条件。 |
| Q5 | B | WaitGroup 的 counter 初始为 0,Done() 会将 counter 减 1 变成负数。随后 Wait() 检测到 counter 不为 0 而阻塞,但由于没有其他 goroutine 再做 Add(positive),它会一直等到 panic(runtime 检测到 negative counter 会报 panic)。 |
| Q6 | C | sync.Map 的 Load 优先级:先查 readOnly → miss 且 amended=true 时查 dirty → 返回 not-found。**优先返回 readOnly 中的值**,而非 dirty。A/B/D 的描述均正确。这是 sync.Map 读写分离架构的核心设计之一。 |
### 填空题答案
| 题号 | 答案 | 解析 |
|------|------|------|
| F1 | `locked`,`waiter` | Mutex 的 state[31] = locked(1 表示持有),state[30] = waiter(1 表示有等待者),state[0-29] = 等待者数量。 |
| F2 | `counter`,`waiters` | WaitGroup 用 int64 分两段:高 32 位存 counter(Add 减少),低 32 位存 waiters(Wait 增加)。这样防止 counter 从 1 减到 0 时被另一个 Add(1) 立刻绕回 0 导致误判。 |
| F3 | `二次检查` | 双检锁的第二层检查在持有 mu.Lock() 后进行,确保只有一个 goroutine 能真正执行 f()。第一层无锁快速路径 + 第二层防重复 = 高性能且正确。 |
## 关联笔记
- [[00.Go/concurrency/Sync 包核心源码]]