vault backup: 2026-08-08 19:01:04

This commit is contained in:
2026-08-08 19:01:04 +08:00
parent 1a4a83bc67
commit e2975eb86b
46 changed files with 9370 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
---
tags: [go/lang, context, cancellation, deadline-propagation, goroutine-lifecycle]
create time: 2026-08-08 19:00
update time: 2026-08-08 19:00
---
# Context 包详解
## 概述
Context 是 Go 1.7 引入的标准库,用于在 goroutine 树中传递上下文信息、取消信号和截止时间。它不是数据传递的载体(那是结构体的职责),而是控制并发生命周期的信号总线。理解 ctx 的链式传播机制和 WithValue 的性能陷阱,对编写健壮的并发程序至关重要。
> [!NOTE] 一句话定义
> Context 是一个接口,核心方法只有 Done() 和 Err()——它不提供数据传输能力,只提供"告诉下游我该停了"的信号通道。
## 核心原理
### Context 接口定义
```go
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
```
四个方法各有分工:
- **Deadline**:返回取消时间。未设置 deadline 的 ctx 返回 `ok=false`
- **Done**:返回一个只读 channel。ctx 被取消时该 channel 会关闭
- **Err**:返回取消原因(Canceled / DeadlineExceeded)
- **Value**:键值对查询(不推荐用于业务传参)
> [!WARNING] 常见认知错误
> Context 不是用来在 goroutine 间传递业务参数的替代品。如果多个 goroutine 需要共享配置或请求数据,应该用结构体或参数列表。WithValue 性能很差且容易引发问题(见后文)。
### 四种 WithXXX 函数源码级分析
#### WithCancel — 手动取消
```go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
```
实现本质非常简单——创建一个 `cancelCtx` 节点,挂载到父 ctx 上。当 `cancel()` 被调用时:
1. 设置 `err = Canceled`
2. 关闭 `done` channel(所有监听者收到关闭信号)
3. 递归通知子 ctx 也取消
```mermaid
flowchart LR
A["parent ctx"] -->|"ctx1, _ := WithCancel"| B["ctx1\n(cancelCtx)"]
B -->|"ctx2, _ := WithCancel ctx1"| C["ctx2\n(sub-cancelCtx)"]
C -->|"ctx3, _ := WithCancel ctx2"| D["ctx3\n(sub-sub-cancelCtx)"]
style B fill:#e3f2fd
style C fill:#e8f5e9
style D fill:#fce4ec
```
调用 `cancel()` 时信号沿虚线向上传播到所有后代。
#### WithTimeout — 超时取消
```go
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
```
WithTimeout 内部组合了 WithCancel 和一个 timer:
```go
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
```
底层启动一个 goroutine 等待 timer 触发后自动调用 cancel。这意味着 WithTimeout 本身比 WithCancel 多了一个 goroutine 开销。
#### WithDeadline — 绝对截止时刻
```go
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
```
与 WithTimeout 的区别仅在于指定的是绝对时间而非相对时长。两者底层使用同一个 `timerCtx` 结构体,包含 `*cancelCtx` 和 `time.Timer`。
> [!TIP] 面试考点
> WithTimeout 本质上就是 WithDeadline(now + timeout)。面试官如果追问"两者的区别是什么",这就是标准答案。
#### WithValue — 附带值传递
```go
func WithValue(parent Context, key, val any) Context
```
WithValue 不创建新的类型,只是在 context 树上追加一层带有 key-value 对的节点。每次调用都会产生一个新的 context 对象。
### 链式传播机制
Context 通过嵌套构成一棵树。每个 `cancelCtx` 维护一个 `children` 列表来追踪直接子节点:
```go
type cancelCtx struct {
Context
mu sync.Mutex
done chan struct{}
children map[canceler]*cancelCtx // 子节点集合
err error // 取消时的错误原因
childCleared bool // 是否已清理子节点
}
```
当父 ctx 被取消时,遍历 `children` 逐个取消子节点。这个传播过程是递归的:
```
parent.WithCancel → ctx1
ctx1.WithCancel → ctx2
ctx2.WithCancel → ctx3
cancel() at parent
│
├── 递归取消 ctx1 ──→ 递归取消 ctx2 ──→ 递归取消 ctx3
```
> [!NOTE] 为什么不用双向指针?
> 子 ctx 持有父 ctx 引用(嵌入),但父 ctx 通过 children map 持有子引用。这是一种非对称设计——父到子是显式追踪,子到父通过 Context 嵌入间接访问。删除子节点时从父的 children map 移除以避免内存泄漏。
### WithValue 的性能坑点
WithValue 有几个需要注意的问题:
1. **每次调用都分配新对象**:`WithValue` 不会修改已有节点,而是创建整个链路的副本路径。链式调用 N 次会产生 N 个 context 对象。
2. **查找复杂度 O(depth)**:`Value()` 沿链路逐层查找匹配的 key。深层嵌套时性能显著下降。
3. **GC 压力**:短期存在的 ctx 虽然存活时间短,但在高 QPS 场景下(如 HTTP handler),大量短期对象会给 GC 带来负担。
4. **key 必须是可比较的**:如果使用 slice/map/function 作为 key,会导致 panic。最佳实践是用自定义不可导出的类型:
```go
type userIDKey struct{}
ctx := context.WithValue(r.Context(), userIDKey{}, user.ID)
// 取值
if id, ok := ctx.Value(userIDKey{}).(string); ok {
// ...
}
```
## 代码示例
### 优雅取消 goroutine 树
```go
func fetchData(ctx context.Context) ([]byte, error) {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
return nil, fmt.Errorf("HTTP failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
select {
case <-ctx.Done():
return nil, ctx.Err() // 请求被取消
default:
return data, nil
}
}
```
在关键操作前后检查 ctx.Done(),确保即使网络操作完成也能及时响应取消信号。
### Timeout 与 Deadline 的实际应用
```go
func handleRequest(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // 必须调用,释放资源
result, err := heavyComputation(ctx)
if err != nil {
return fmt.Errorf("computation: %w", err)
}
return process(result)
}
```
`defer cancel()` 是关键——没有它,context 及其关联的 timer goroutine 永远不会被释放,导致 goroutine leak。
### WithValue 的正确用法
```go
func logMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
traceID := generateTraceID()
ctx := context.WithValue(r.Context(), traceIDKey{}, traceID)
next(w, r.WithContext(ctx))
}
}
```
在 Web 场景中,ctx 最常见的用途是携带 trace ID、用户认证信息等跨中间件需要的元数据。
## 实践场景
### 面试高频问题
**Q: Context 什么时候应该被传递给函数?**
原则是:只要你的函数可能在长时间运行后被中断,就应该接受 Context 作为第一个参数。这是 Go 社区的标准规范。
**Q: 可以用 Context 存储数据库连接吗?**
不应该。数据库连接的生命周期独立于单次请求的上下文,存储在 struct 字段或通过依赖注入管理更合适。
**Q: Context 的 value 能被并发安全地读取吗?**
可以。一旦 context 创建完成,它的 key-value 对就不可变了,天然线程安全。但 value 本身的类型需要保证并发安全。
### 实战建议
- **always pass context as first parameter**: `(ctx context.Context, ...) -> (...)`
- **always defer cancel when you create a derived context**: `defer cancel()` 是不可省略的习惯
- **never store context in a struct**: context 描述的是单次操作的上下文,不适合持久化
- **use context for cancellation, not data transfer**: 优先用 struct 字段传递业务数据
## 扩展阅读
- [[Goroutine 调度模型]] — Context 取消信号由调度器驱动的 goroutine 退出机制配合使用
- [[Select 多路复用机制]] — select-case 中 `<-ctx.Done()` 是最常见的超时控制模式
- [[Sync 包核心源码]] — waitgroup 与 context 常组合使用以协调批量 goroutine 退出
+225
View File
@@ -0,0 +1,225 @@
---
tags: [go/lang, goroutine, gmp-scheduler, work-stealing, stack-dynamic]
create time: 2026-08-08 19:00
update time: 2026-08-08 19:00
---
# Goroutine 调度模型
## 概述
Go 的并发能力源自其内置的 goroutine 和 M:N 调度模型。与操作系统线程 1:1 映射不同,Go 使用 GMP 三方协作架构将数千个 goroutine 高效地复用到有限的 OS 线程上。理解 GMP 调度原理是排查性能瓶颈、避免 goroutine leak 的基石。
> [!NOTE] 为什么 Go 要设计自己的调度器?
> 操作系统线程的创建和切换成本较高(通常几 MB 栈空间 + 内核态切换),而 goroutine 只需 ~2KB 初始栈且在内核态之外完成调度。这使得 Go 可以同时运行数百万个 goroutine 而不拖慢系统。
## 核心原理
### 生命周期状态
一个 goroutine 在生命周期中会经历以下状态转换:
```mermaid
stateDiagram-v2
[*] -->idle: 创建后初始化
idle-->gwaiting: 阻塞中(等待IO/锁/channel)
gwaiting-->gorunnable: 被唤醒放入 runqueue
gorunnable-->grunning: 被 M 取出执行
grunning-->gidle: 正常返回退出
grunning-->gsyscall: 执行系统调用
gsyscall-->gorunnable: 系统调用返回
gsyscall-->gwaiting: 继续阻塞
state gwaiting {
channel_wait: 等待channel读写
mutex_wait: 等待sync.Mutex
timer_wait: 等待定时器触发
netpoll_wait: 等待网络事件
}
style grunning fill:#c8e6c9
style gorunnable fill:#e3f2fd
style gwaiting fill:#fff3e0
style gsyscall fill:#fce4ec
```
- **Gidling(空闲)**:刚创建或已退出的 goroutine,尚未进入可调度状态
- **Grunnable(就绪)**:在 global 或 local runqueue 中等待被调度
- **Grunning(运行)**:绑定了某个 P,正在执行代码
- **Gwaiting(等待)**:阻塞于某些外部事件(channel IO、mutex、timer)
- **Gsyscall(系统调用)**:正在进行系统调用,此时不占用 CPU 但持有 P
### G / M / P 三者的关系
这是面试最高频的问题之一。三者的定义和关系如下:
| 角色 | 全称 | 职责 | 类比 |
|------|------|------|------|
| **G** | Goroutine | 用户级协程,包含栈、指令指针、状态等 | 任务/工作单元 |
| **M** | Machine Thread | 绑定到 OS 内核线程,真正执行代码 | CPU 核心 |
| **P** | Processor | 调度的本地资源,拥有 local runqueue 和执行权限 | 调度上下文/沙盒 |
```mermaid
graph TD
subgraph "Global RunQueue<br/>全局等待队列"
GR["待调度的 goroutine"]
end
subgraph "P Pool<br/>最多 1024 个 P"
P1["P1<br/>local queue"]
P2["P2<br/>local queue"]
Pn["Pn..."]
end
subgraph "M Pool<br/>OS Threads"
M1["M1 (OSThread)"]
M2["M2 (OSThread)"]
end
subgraph "G Stack"
G1["g1 stack<br/>~2KB start"]
G2["g2 stack<br/>~2KB start"]
G3["g3 stack<br/>~2KB start"]
end
GR -.->|"work stealing"| P1
P1 -->|"pop"| G1
P2 -->|"pop"| G2
Pn -->|"pop"| G3
M1 -->|"绑定"| P1
M1 -->|"执行"| G1
M2 -->|"绑定"| P2
M2 -->|"执行"| G2
```
关键约束:
- **只有绑定了 P 的 M 才能执行 goroutine**。一个 M 必须拥有一个 P 才能运行 G。
- **P 的数量由 `GOMAXPROCS` 决定**,默认是当前机器的逻辑 CPU 数量,最大值为 1024。
- **M 可以多于 P**:当 M 在系统调用中被阻塞时,runtime 会创建新的 M 来获取新的 P 继续工作。
### Local RunQueue 与 Global RunQueue
每个 P 维护一个长度为 256 的 local runqueue。调度器的工作循环(schedloop):
1. 从自身 local runqueue 中取一个 goroutine 执行
2. 如果 local queue 为空,尝试偷取其他 P 的 queue
3. 如果偷不到,从 global runqueue 取
4. 如果 global 也空,主动让出 P 进入休眠
### Work Stealing 机制
当某个 P 的 local queue 为空而其他 P 繁忙时,空 P 会随机选择一个目标 P,从中偷取约一半的 goroutine。这就是 work-stealing 的核心思想——负载均衡通过窃取而非推送实现,减少了全局锁的竞争。
具体算法(semi-global work-stealing):
- 发送者将自己的 goroutine push 到本地 queue 尾部
- 接收者在 queue 满时将一半元素 steal 到全局 queue
- 空闲 P 随机选一个忙碌 P,从其 queue 头部 steal 一半元素
> [!TIP] 面试常考点
> 为什么从头部偷而不是尾部?因为头部是最近加入的 goroutine(时间局部性好),从头部偷可以减少对原 P 的 cache 干扰。原 P 自己取的是尾部的旧元素。
### Stack 动态伸缩
Go 的 goroutine 栈采用段式内存管理,初始大小为 2KB,可动态伸缩:
```
+----------------------------+
| Segment 1 (8KB) |
| +------------------------+ |
| | GOROUTINE STACK | |
| | [2KB grow → up to 1GB] | |
| +------------------------+ |
+----------------------------+
^ ^
minSize maxSize
2KB (x86: 4KB) ~1GB
```
伸缩规则:
| 场景 | 操作 | 新大小 |
|------|------|--------|
| 需要更多栈空间 | GrowStack | old * 2 (不超过 maxStackSize) |
| 大量未使用的栈空间 | ShrinkStack | old / 2 (不低于 minStackSize) |
- **minStackSize** = 2KB(32位平台为 4KB)
- **maxStackSize** = 1GB
- 扩容时会分配更大的段并拷贝原有数据,缩容类似
> [!WARNING] 常见误区
> "goroutine 栈初始 2KB,最大 1GB"这句话容易引起误解。实际上 goroutine 不会一开始就申请 2KB——Go 1.4 之前是这样,之后采用了段式分配。goroutine 第一次需要栈时只申请一个小段,按需增长。
## 代码示例
### 观察 GOMAXPROCS 的影响
```go
func main() {
runtime.GOMAXPROCS(2) // 限制为 2 个逻辑处理器
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("G%d running on M-%d\n", id, runtime.Stack(nil, false))
}(i)
}
wg.Wait()
}
```
调整 GOMAXPROCS 的值可以直接控制并发度上限。设为 1 时所有 goroutine 串行执行;设为大于 CPU 数量的值时,多余的 P 会通过 spin 模式短暂竞争 CPU。
### 栈逃逸检查
```go
func capturesVar() func() int {
x := 42
return func() int { return x } // x 逃逸到堆上
}
// 编译命令:go build -gcflags="-m -m"
// 输出: ./main.go:4:10: func captured by closure escapes to heap
```
栈逃逸意味着变量被分配到堆上而非栈上,这会触发 GC 并且降低局部性。编译器自动处理,开发者可以通过 `-m` 标志查看逃逸分析结果。
## 实践场景
### 面试高频问题
**Q: GOMAXPROCS 设置为多少最合适?**
默认值即可(等于机器逻辑 CPU 数)。大多数场景下不需要手动调优。只有纯 CPU 密集型任务偶尔可以从超配获得一点收益,但收益有限且会增加上下文切换开销。
**Q: goroutine 和 OS 线程的区别?**
| 维度 | OS 线程 | Goroutine |
|------|---------|-----------|
| 栈大小 | 静态(通常 1-8MB) | 动态(2KB 起,按需增长) |
| 创建成本 | 高(内核态) | 极低(内核外) |
| 调度方式 | 内核调度 | Go runtime 调度 |
| 并发规模 | 数千 | 百万级 |
**Q: 什么情况会导致 goroutine 泄漏?**
1. Channel 无人接收导致 send 侧永久阻塞
2. Context 从未取消,等待 cancelled 信号的 goroutine 无法退出
3. 在 select 中向永不关闭的 channel 发数据
4. 定时器未 Stop 导致 Timer goroutine 泄漏
> [!TIP] 最佳实践
> 使用 `go tool pprof -inuse_goroutines` 定期检查活跃 goroutine 的数量变化趋势,异常增长往往意味着 goroutine leak。
### 实战建议
- **永远给 goroutine 提供退出路径**:用 context 或 channel 传递取消信号,不要让 goroutine 无限循环。
- **合理设置 GOMAXPROCS**:I/O 密集型和 CPU 密集型有不同策略,但 Go 的默认值对大部分场景都足够好。
- **关注 goroutine 数量**:监控工具中 goroutine 数量持续上升是危险的信号。
## 扩展阅读
- [[Context 包详解]] — Context 是协调多 goroutine 生命周期的重要工具
- [[Sync 包核心源码]] — sync 包中的锁与 waitgroup 直接影响 goroutine 的状态转换
- [[Select 多路复用机制]] — select 是多 goroutine 间通信的高级形态
- [[切片底层实现]] — goroutine 参数传递中切片的逃逸行为
@@ -0,0 +1,259 @@
---
tags: [go/lang, select, poll-multiplexing, fairness, nil-channel]
create time: 2026-08-08 19:00
update time: 2026-08-08 19:00
---
# Select 多路复用机制
## 概述
select 是 Go 语言中专为 channel 设计的关键字,它允许在一个语句中等待多个 channel 操作完成。底层通过随机选择可通信的 case 来保证公平性,避免了优先级饥饿问题。理解 select 的多路复用原理和边界行为,对编写健壮的并发程序必不可少。
> [!NOTE] 核心心法
> select 不是线程安全的轮询器——它是调度器级别的同步原语。每个 case 要么阻塞等待(该侧 goroutine 被挂起),要么立刻匹配执行。没有"轮询空转"这种情况。
## 核心原理
### select 的底层实现结构
每个 select 语句在编译时被转换为一个 `hchan.go` 中的 `chans` 数组和 `sendv`/`recvv` 指针数组:
```go
// 编译后生成的内部表示(简化)
type selstruct struct {
chans []*hchan // case 关联的 channel
recv []unsafe.Pointer // 接收目标地址(nil 表示 send case)
send []unsafe.Pointer // 发送源地址(nil 表示 recv case)
order []uint16 // 随机化后的访问顺序
ncases int // case 数量
}
```
编译时 select 的行为分析:
- 如果所有 case 都有确定的 channel,编译器将其转为静态的 select 表
- 如果有运行时才能确定 channel 的情况(如 defer-case),使用动态生成
### pollSurprise / pollRandom / pollDelay
Go 调度器的 netpoll 系统使用三个关键函数处理 channel 的 select 场景:
| 函数 | 调用时机 | 作用 |
|------|---------|------|
| **pollSurprise** | 已注册的 goroutine 突然可以被唤醒 | 不需要 sleep 直接检查 |
| **pollRandom** | 有多个 case 同时就绪时的随机选择 | 保证公平性 |
| **pollDelay** | 没有 case 就绪,需要进入睡眠等待 | 设置超时或永久休眠 |
select 的执行流程如下:
```mermaid
flowchart TD
A["开始执行 select"] --> B["收集所有 case 的 channel"]
B --> C{"有 nil channel 的 case?"}
C -->|是| D["移除 nil channel 对应的 case<br/>永远不会匹配"]
D --> E{"至少还有一个非 nil case?"}
E -->|否| F["永远阻塞 (deadlock)"]
E -->|是| G{"有任何 case 立即可选?"}
G -->|是| H["pollRandom: <br/>从可选 case 中随机选择一个"]
G -->|否| I["将当前 goroutine 加入各<br/>channel 的 waitq"]
I --> J["gopark 进入睡眠"]
J --> K["netpoll 检测到某个 channel 就绪"]
K --> L["唤醒 goroutine"]
H --> M["执行匹配的 case 分支"]
L --> M
style F fill:#ffebee
style H fill:#e8f5e9
style J fill:#fff3e0
```
### 随机性保证公平性
这是 select 最精妙的设计之一。当多个 case 同时满足条件(即多个 channel 同时有数据可读或有空间可写)时,Go 会随机选择一个 case 执行。
如果没有这个随机性,以下场景会出现严重不公平:
```
Case A: chA <- val (总是优先匹配)
Case B: <-chB (永远等不到机会)
```
想象 chA 几乎总有空间可写、chB 偶尔有数据可读的情况——如果没有随机性,goroutine 可能永远只匹配 Case A。rand() 随机选择打破了这种确定性偏斜。
> [!TIP] 面试常考点
> 面试官可能会问:"如果 select 总是按顺序从上到下匹配会发生什么?"答案是在循环调用 select 的场景下会导致某些 case 被系统性忽略,形成 starvation。
### nil channel 在 select 中的行为
nil channel 是一个特殊状态。对 nil channel 做 send 或 receive 会永久阻塞:
```go
var nilCh chan int = nil
select {
case v := <-nilCh: // 永远不匹配
fmt.Println(v)
default: // 正常执行
fmt.Println("default")
}
```
在 select 中,nil channel 对应的 case 会被编译器/运行时标记为永不触发。这常被用作**动态控制 case 启停**的技巧:
```go
var readCh <-chan int
var writeCh chan<- int
for i := 0; i < 10; i++ {
select {
case <-readCh: // 只有 readCh != nil 时才可能匹配
// read logic
case writeCh <- i: // 只有 writeCh != nil 时才可能匹配
// write logic
}
}
readCh = nil // 禁用读通道
writeCh = nil // 禁用写通道
```
> [!WARNING] 陷阱
> nil channel 在普通收发中 panic(其实不会,是死锁),但在 select 中只是简单地不被选中。不要依赖这种隐式行为来处理复杂的条件分支逻辑。
### select default 分支
```go
select {
case v := <-ch:
fmt.Println(v)
default:
fmt.Println("no data available")
}
```
default 使 select 变为非阻塞模式:如果没有任何 case 立即可选,立即执行 default。这等价于带超时的 select:
```go
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(0): // 超时时间为 0,等价于 default
fmt.Println("timeout")
}
```
> [!NOTE] 性能提示
> time.After() 会在背后创建一个定时器 goroutine,即使超时时间为 0 也有额外开销。无延迟场景下 prefer `default` 而非 `time.After(0)`。
### Goroutine Leak 常见场景
select 是最容易引发 goroutine leak 的语言特性之一:
| 场景 | 原因 | 修复方案 |
|------|------|---------|
| 单向读取关闭的 channel | range 自动退出,但 select 不会 | 用 `ok` 检测关闭信号 |
| 无限循环中 select 缺少取消路径 | goroutine 永远无法退出 | 添加 ctx.Done() case |
| buffered channel 未 drained | 生产者退出后 buffer 残留 | close + drain |
| timer 未 Stop | Timer goroutine 泄漏 | defer timer.Stop() |
```go
// ❌ goroutine leak: 无限循环但没有 ctx 退出路径
for {
select {
case v := <-ch:
process(v)
}
}
// ✅ 正确: 有明确的退出路径
func worker(ctx context.Context, ch <-chan int) {
for {
select {
case v, ok := <-ch:
if !ok {
return // channel closed
}
process(v)
case <-ctx.Done():
return // context cancelled
}
}
}
```
## 代码示例
### select 公平性演示
```go
func main() {
chA := make(chan int, 100)
chB := make(chan int, 100)
for i := 0; i < 100; i++ {
chA <- i // chA 塞满
chB <- i * 2
}
countA, countB := 0, 0
for i := 0; i < 50; i++ {
select {
case <-chA:
countA++
case <-chB:
countB++
}
}
// 由于随机性,countA ≈ countB ≈ 25
fmt.Printf("A: %d, B: %d\n", countA, countB)
}
```
多次运行的输出中,countA 和 countB 的值大致均衡(可能有小幅偏差),证明了随机选择的公平性。
### Select 与 Context 组合
```go
func withTimeout(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return errors.New("timeout exceeded")
}
}
```
这是最经典的 select 用法模式:context 取消优先于超时,两者都能作为退出条件。
## 实践场景
### 面试高频问题
**Q: select 可以不带任何 case 吗?**
可以,写成空的 `select {}`,它会永久阻塞。常用于 main goroutine 保持存活(虽然更好的做法是用 `sync.WaitGroup`)。
**Q: select 能处理自定义类型吗?**
不能。select 只能用于 channel 相关操作。如果需要自定义类型的条件判断,改用 if/else 或者先把结果写入 channel 再用 select。
**Q: select 中有多个 default 分支怎么办?**
语法错误。一个 select 最多只能有一个 default 分支。
### 实战建议
- **永远给长生命周期 goroutine 加 ctx.Done()**:防止 goroutine leak
- **善用 nil channel 做 case 启用/禁用**:比 if-else 嵌套更清晰
- **不要用 select 模拟 if-else**:语义不同,select 强调并发等待
- **监控 goroutine 增长**:pprof 中观察 goroutine 数量变化趋势
## 扩展阅读
- [[Channel 底层实现]] — select 建立在 channel 的 waitq 之上
- [[Context 包详解]] — ctx.Done() 是 select 中最常用的取消信号
- [[Goroutine 调度模型]] — select 导致的 goroutine 休眠/唤醒由 GMP 调度器管理
+260
View File
@@ -0,0 +1,260 @@
---
tags: [go/lang, sync-primitives, futex, double-check-locking, rwmutex-starvation]
create time: 2026-08-08 19:00
update time: 2026-08-08 19:00
---
# Sync 包核心源码
## 概述
sync 包提供了 Go 中最基础的并发原语:Mutex、RWMutex、WaitGroup、Once、Map 和 Pool。这些看似简单的组件背后隐藏着大量系统级优化——futex 机制、写饿死模式、反溢出设计、双检锁等。理解它们的源码实现,有助于在实际编码中做出正确的选择,避免不必要的性能陷阱。
> [!NOTE] 使用原则
> sync 包的每个组件都针对特定场景做了极致优化。不要自己发明新的同步原语——用现成的、经过充分测试的工具,出了问题有人背锅。
## 核心原理
### sync.Mutex — futex 机制下的互斥锁
Go 的 mutex 不是简单的自旋锁,而是结合 futex(fast userspace mutex)的自适应锁。状态由 `state` 字段三个位控制:
```
state[31]: locked 位 (1 = 持有中)
state[30]: waiter 位 (1 = 有等待者)
state[0-29]: 等待者数量
```
加锁流程采用两阶段策略:
```mermaid
flowchart TD
A["atomic CAS state 0"] --> B{"成功?"}
B -->|是| C["获得锁, return"]
B -->|否| D["slowLock调用"]
D --> E["设置 locked+waiter 位"]
E --> F["gopark 进入睡眠"]
F --> G["被唤醒: 解锁者调用了 unlock"]
G --> H["重新竞争或直接获得"]
style C fill:#c8e6c9
style F fill:#fff3e0
```
**快路径**:通过 atomic CAS 尝试将 state 设为 locked。如果竞争不激烈,几乎零开销就拿到锁。
**慢路径**:当 CAS 失败时调用 `slowLock`,将自己包装成 sudog 放入等待队列并休眠。这种设计让高竞争下不会浪费 CPU 周期去自旋。
> [!TIP] 面试常考点
> Go 1.9 引入的 Mutex 实现了自适应自旋:在低竞争时会短暂地自旋(几微秒),在高竞争时立即休眠。这个阈值 runtime 自动调节。
### sync.RWMutex — 写饿死 starving 模式
RWMutex 支持多读单写,但有一个特殊模式:**写饥饿(write starvation)**。
正常模式下,新来的读者会和已持有的读者共享锁,这可能导致_writer_ 永远等不到写权限(reader starvation)。写饿死模式解决了这个问题:
```go
// RWMutex state 布局
state[0]: locked (mutex 是否被 writer 持有)
state[1-8]: reader count (当前读者数)
state[9-24]: waiter count (等待写锁的数量)
state[25]: expired (writer semaphore 信号量标志)
state[26-30]: reserved
state[31]: starving mode (1 = 饥饿模式)
```
饥饿模式的触发和解除:
| 条件 | 操作 |
|------|------|
| waiter >= 1 且等待时间 > 1ms | 切换到饥饿模式 |
| 锁转让给等待队列最前面的 writer | 在饥饿模式下直接移交,不给后续读者 |
| 所有等待者一次性全部满足 | 解除饥饿模式,回到正常模式 |
```mermaid
flowchart LR
A["普通模式"] -->|"writer等太久"| B["饥饿模式"]
B -->|"锁移交给队首writer"| C["其他reader被挡在外面"]
C -->|"等待者全部唤醒"| A
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#ffe0b2
```
> [!WARNING] 常见误区
> RWMutex 的 ReadRlock / RUnlock 不是可重入的。一个 goroutine 已经持有读锁后再次请求会死锁。这与 Java 的 ReentrantReadWriteLock 不同。
### sync.WaitGroup — 反 overflow 设计
WaitGroup 内部用 int64 的两个半段分别表示 counter 和 waiters:
```
int64:
high 32 bits: counter (goroutine数量)
low 32 bits: waiters (阻塞在Wait()的数量)
```
为什么要分两段?为了防止 counter overflow。如果只用单一 int32 来表示 counter:
- 当 counter 从 1 减到 0 时调用 Wait()
- 另一个 goroutine 立刻 Add(1) 再 Done()
- counter 绕回 0,导致 Wait() 误认为计数为 0 而提前返回
分高低位后,Add 只能减少高位,Wait 只能增加低位,两者不会互相干扰。
```go
func main() {
var wg sync.WaitGroup
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
doWork()
wg.Done() // counter--, 可能产生负值? 不会!
}()
}
wg.Wait() // 阻塞直到 counter == 0
}
```
> [!TIP] 注意事项
> WaitGroup 不可复制!Copy 后的 WaitGroup 与原 WaitGroup 共享同一个计数器副本,行为不可预测。正确做法是用指针传递或在 goroutine 创建前定义 WaitGroup。
### sync.Once — 双检锁(Double-Check Locking)
Do 方法确保传入的函数只执行一次:
```go
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 { // 二次检查
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
```
两层检查的精妙之处:
1. **第一层无锁快速路径**:大部分情况下 done 已经是 1,直接 return,零锁开销
2. **第二层防重复**:即使多个 goroutine 同时通过了第一层检查,只有第一个能进入 f()
> [!NOTE] panic 的处理
> 如果 f() panic,Once 认为执行已完成(done=1),不会再重试。这意味着 panic 是一次性的,后续调用直接跳过 f()。如果需要重试语义,应自行包装。
### sync.Map — 读写分离架构
sync.Map 的内部结构体现了典型的"读多用 map、写多用 dirty map"分离思想:
```
sync.Map 包含:
├── readOnly: {elem: map[K]V, amended: bool} ← 读专用,线程安全只读
├── dirty: {elem: map[K]V, missed: int} ← 写专用,需要互斥锁保护
└── deleted: sentinel value in readOnly ← 标记已删除
```
amended flag 的作用是关键:
- `amended=false` 时,读取只查 readOnly(无锁快速路径)
- `amended=true` 时,读取先查 readOnly,miss 再去 dirty 查找,并把 missing key 预热到 readOnly
- 当 `dirty == nil` 或 `missed >= 2^th` 时,将 dirty 提升为 readOnly
```mermaid
flowchart TD
A["Load key"] --> B{"key in readOnly?"}
B -->|是| C["命中! return val"]
B -->|否| D{"amended == true?"}
D -->|否| E["未找到, 返回 not-found"]
D -->|是| F["查 dirty map"]
F --> G{"key in dirty?"}
G -->|是| H["预热到readOnly<br/>return val"]
G -->|否| E
style C fill:#c8e6c9
style H fill:#e3f2fd
style E fill:#ffcdd2
```
这种设计使得高频读取的场景下完全不需要持锁,性能远超 map + RWMutex。
## 代码示例
### Once 的安全延迟初始化
```go
var once sync.Once
var db *sql.DB
func GetDB() *sql.DB {
once.Do(func() {
db, _ = sql.Open("postgres", dsn)
db.SetMaxOpenConns(25)
})
return db
}
```
Once 保证初始化代码在多 goroutine 环境下只执行一次,且后续调用零锁开销。
### WaitGroup + Channel 协作模式
```go
func fetchAll(urls []string) <-chan Result {
out := make(chan Result, len(urls))
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
out <- fetch(url)
}(u)
}
go func() {
wg.Wait() // 等待所有请求完成
close(out) // 关闭 channel,通知接收方
}()
return out
}
```
这是一个经典的并发模式:WaitGroup 统计工作完成情况,Channel 收集结果。二者缺一不可。
## 实践场景
### 面试高频问题
**Q: Mutex 和 RWMutex 怎么选?**
- 读多写少 → RWMutex
- 读写比例接近 → Mutex
- 临界区代码极短 → Mutex(省了 RWMutex 额外的位运算开销)
- 不确定 → Mutex(更安全的选择)
**Q: WaitGroup 的 Add 可以在 Wait 之后调用吗?**
可以,但不推荐。这会导致 Wait 直接返回(counter=0),而后续的 Add 会引发 panic。正确模式是在启动 goroutine 前调用 Add。
**Q: sync.Once 能保证 f() 的执行顺序吗?**
不能保证哪个 goroutine 执行的 f(),只保证只有一个。在极少数高竞争场景下,可能多个 goroutine 同时进入 doSlow,但只有第一个真正执行 f()。
### 实战建议
- **优先用 select + ctx.Done() 代替 sync 原语**:现代 Go 编程中,context 驱动的取消模式通常比 manual waitgroup 更优雅
- **Pool 适合高频分配释放的场景**:如 HTTP request/response 复用
- **不要用 WaitGroup 做信号量**:那是 semaphore pattern,应该用 channel buffered size=1
## 扩展阅读
- [[Goroutine 调度模型]] — sync 原语的底层阻塞由 GMP 调度器驱动
- [[Select 多路复用机制]] — select-case 可与 WaitGroup 配合实现复杂的并发协调
- [[Context 包详解]] — context 与 sync 原语组合使用是标准的并发模式