15.6 并发原语 (channel / context / sync)

一句话

三组原语只回答四个问题——谁持有、谁释放、谁关闭、谁取消;选型错了只是慢,这四个问题答错会炸,线上并发事故几乎都能归到「锁没成对 / 锁被复制 / 等待点不可取消 / 关闭与发送撞车」。

怎么选

你的需求 用什么 每次操作代价(本机实测) 失败方式
数据在 goroutine 间交接所有权 channel(有缓冲优先) ~66ns(cap=1024) 死锁、泄漏、关闭竞态
保护共享状态(计数器、缓存) sync.Mutex 13.8ns 无竞争 / 88ns 8 核争用 死锁、漏解锁、复制
读多写少、临界区有实际工作 sync.RWMutex ≈80ns(8 核只读) 写饥饿、递归读死锁
单字读改写(计数、标志、指针) atomic 8ns → 41ns(8 核) 误以为能保护复合逻辑
等一组任务结束 sync.WaitGroup / errgroup 归零唤醒 Add 晚了、复用早了
只初始化一次 sync.Once 一次原子读 初始化 panic 后永远不重试
等待某个条件成立 sync.Cond 挂起/唤醒 丢信号、不复查条件
键集合稳定、读多写少 sync.Map 18ns(9:1 混合) 当通用 map 用、频繁增删
复用临时对象 sync.Pool 442ns → 25ns(4KB 缓冲) 当缓存用,GC 后被清空
传递取消与超时 context 一次 channel 关闭 忘 cancel、Value 塞业务参数

判据只有一句:这是「状态」还是「消息」——状态用锁/原子,消息用 channel;把状态当消息传,代码会退化成一套自制的消息协议。

channel:四条语义 + close 三定律

写法 行为
无缓冲 make(chan T) 发送与接收必须同时就绪,否则阻塞
有缓冲 make(chan T, n) 缓冲未满可发、未空可收
close(ch) 后接收 立即返回零值 + ok=false;已入缓冲的数据仍可读完
nil channel 收发永久阻塞(常用于 select 里禁用分支)

close 三定律:

  1. 谁发送谁关闭,接收方永远不关(接收方不知道还有没有别的发送者);
  2. 只关一次,重复 close 直接 panic;
  3. 关闭后不再发送,发送到已关闭的 channel 直接 panic——这是运行时主动暴露错误,不是未定义行为。

底层结构一句话:环形缓冲 buf + 两个等待队列 sendq/recvq + 一把保护所有字段的锁。所以 channel 不是「无锁队列」,它是一条带调度的同步通道;len(ch) 读的是缓冲里元素个数,不是「还有多少没处理」。

缓冲容量怎么定:按「生产者突发量 ÷ 消费者处理速率 × 可接受的排队延迟」估。太小退化成同步(阻塞生产者),太大则掩盖下游故障、把故障期变成内存增长。常见做法是设成「一批次的大小」,并对 len(ch) 做监控——持续接近容量就是背压信号,而不是「加大缓冲」。

⚠️ select 里 time.After 用在循环中会持续创建定时器(Go 1.23 前不回收),长跑循环要用 time.NewTimer + Reset。

sync 家族:一张表看全

原语 零值可用 一句话语义 典型误用 版本注意
Mutex 是 互斥进入临界区 复制、重入、漏解锁 TryLock 自 Go 1.18;实现已下沉到 internal/sync,sync.Mutex 现在只是包装
RWMutex 是 多读单写,写优先 递归读锁、读锁里申请写锁 TryLock/TryRLock 自 Go 1.18
WaitGroup 是 计数归零才放行 Add 晚于 Wait、提前复用 Go 1.25 增加 Go(f)
Cond 否(要 NewCond) 条件不满足就挂起 用 if 不复查、丢 Signal API 二十年未变
Once 是 只执行一次 递归 Do、f 里 panic Go 1.21 增加 OnceFunc/OnceValue
Map 是 并发安全 map 当通用 map、频繁增删 Go 1.24 换了实现(见下)
Pool 是 临时对象复用 当缓存、当连接池 结构自 Go 1.13 未变
atomic.* 是 单字原子读改写 保护多字段不变量 Go 1.19 起类型化 API + 正式内存序保证

Mutex:两种模式,牺牲的东西不同

Mutex 的实现已经移到 internal/sync,结构只有两个字:state 与 sema。state 按位复用:

位 含义
bit0 mutexLocked 已加锁
bit1 mutexWoken 已有唤醒者,Unlock 不必再唤醒别人
bit2 mutexStarving 饥饿模式
bit3 起 等待者数量(state >> 3)
模式 谁能拿到锁 触发/退出 牺牲了什么
正常模式 被唤醒的等待者与新来的 goroutine 重新竞争,新来的通常赢(它正在 CPU 上跑) 默认 尾部延迟:等待者可能被反复插队
饥饿模式 锁直接交接给队首,新来的不自旋、不抢,直接排队尾 等待超过 starvationThresholdNs = 1e6(1ms) 吞吐:交接要求严格排队
退出饥饿 — 队首等到锁后发现「队里只剩自己」或「等待 < 1ms」 —

🔑 正常模式优化吞吐,饥饿模式保护 P99;1ms 是 Go 认定「你被饿着了」的阈值,不是调度精度。

这也解释了为什么 Mutex 不记录持有者:它的语义只有「进入/离开临界区」,没有「谁进来了」。所以它天生不可重入,也校验不了「你是不是持有者」。

Mutex 的四类事故

事故 触发方式 后果
Lock/Unlock 不成对 多分支提前 return 漏写;重构删掉 Unlock;把 Unlock 误写成 Lock 锁永不释放 → 死锁;或 Unlock 未加锁的锁 → fatal error
复制已使用的 Mutex 值接收者、按值传参、写进 struct 字面量 副本带着「已加锁」状态各锁各的,保护完全失效
重入 持锁函数调用同样要加锁的函数 永久阻塞(自己等自己)
环路死锁 两个 goroutine 各持一把再请求对方的锁 双方永久等待

事故一:用 defer 把「成对」变成结构约束

func (s *Store) Add(k string, v int) {
	s.mu.Lock()
	defer s.mu.Unlock() // 成对、紧凑;分支再多也不会漏
	if s.closed {
		return // 关键:提前返回也有 defer 兜底
	}
	s.m[k] = v
}

⚠️ Go 1.27 实测:mu.Unlock() 未加锁时输出 fatal error: sync: unlock of unlocked mutex,不是可 recover 的 panic。早期资料里写的「panic」已经过时——它无法被 recover 接住,进程直接退出。

事故二:值接收者把锁复制了

type counter struct {
	mu sync.Mutex
	n  int
}

func (c counter) Inc() { // 值接收者:每次调用复制一份 counter,包括锁
	c.mu.Lock()
	c.n++
	c.mu.Unlock()
}

本机实测:1000 个 goroutine 各调一次 Inc,c.n 仍是 0(期望 1000)——每次都在副本上自增。go vet 能直接抓住:

main.go:60:9: Inc passes lock by value: demo.counter contains sync.Mutex

同一个包里复制 WaitGroup 也会被同一条检查抓住(copies lock value ... contains sync.noCopy)。

事故三与四:重入与环路,本质都是「等待一个不可能到来的释放」。公开事故清单里 Docker #34881(swarm 初始化提前 return 漏解锁)、Docker #36114(持 svm 锁再调加锁的 hotRemoveVHDsAtStart,重入死锁)、etcd #10419(Store 持锁调 Compact,而 Compact 内部又加锁)都是这两类的实例。

破坏死锁四条件(互斥、持有并等待、不可剥夺、环路等待)最便宜的一条:全局锁序——任何路径都按固定顺序加锁;其次是「持锁不调用外部代码」。

RWMutex:一个字段两用

字段 取值 含义
readerCount 正 当前活跃读者数
readerCount 负 有写者在排队或持有:值 = 活跃读者数 − rwmutexMaxReaders(常量为 1<<30)
readerWait ≥ 0 写者还需等多少个活跃读者退出

加写锁时把 readerCount 减去 1<<30 变负,此后新读者一律阻塞;最后一个 RUnlock 把 readerWait 减到 0 才唤醒写者。

三条重入死锁,一条比一条隐蔽:

形态 场景 结果
写锁重入 持 Lock 的函数再调 Lock 死锁(内部是 Mutex)
读锁升级 持 RLock 时调 Lock 读者等写者、写者等读者 → 死锁
递归读锁 RLock 里再 RLock,中间有写者插队 第二个 RLock 被排队的写者挡住,而第一个 RLock 等它返回 → 死锁

第三条是 Go 文档明写禁止的:RWMutex 不允许递归读锁,也不允许升降级。它有写优先(write-preferring)语义,所以「写饥饿」被避免,代价是「读饥饿」可能反转——高写频率下读者会被持续挡在门外。

🔑 RWMutex 只在「临界区里有实际工作」时才划算:本机 8 核实测,空临界区的只读场景 RWMutex ≈80ns、Mutex ≈85ns(差距在噪声内,因为 readerCount 的原子加同样争用同一缓存行);临界区里放 64 次访存后,84ns vs 95ns;9:1 读写混合才拉开到 37.8ns vs 86.3ns。

WaitGroup / Cond / Once:三个等待原语

WaitGroup:Add 必须发生在 Wait 之前

var wg sync.WaitGroup
for _, u := range urls {
	wg.Add(1) // 在启动 goroutine 之前
	go func(u string) {
		defer wg.Done()
		fetch(u)
	}(u)
}
wg.Wait()

把 Add(1) 写进 goroutine 里,Wait 可能先看到计数 0 而直接返回。本机实测:100 个任务,Wait 返回时只完成了 87 个(每次运行的数字不同);go vet 会报 WaitGroup.Add called from inside new goroutine。Go 1.25 增加 wg.Go(f),把「Add + 启动 + Done」收进一个方法,从 API 上消灭这个错误。

复用规则:上一轮 Wait 返回之后才能开始下一轮 Add;交叉复用会 panic(sync: WaitGroup is reused before previous Wait has returned)。官方建议是干脆不重用——新建一个 WaitGroup 只有 16 字节,开销可以忽略。

Cond:唤醒只是「一次复查机会」

mu.Lock()
for len(queue) == 0 { // 必须 for,不能 if
	cond.Wait() // 内部先 Unlock,被唤醒后再 Lock
}
v := queue[0]
queue = queue[1:]
mu.Unlock()

Signal/Broadcast 不排队:没有等待者时通知直接丢弃。所以「先 Signal 后 Wait」必然丢信号,等的人永久挂起。Wait 返回时仍持锁,条件可能已被别人改掉——因此必须用 for 反复检查。需要通知全部等待者(关闭队列、配置变更)用 Broadcast,且关闭路径上必须广播一次,否则所有等待者饿死。

Once:只保证「执行一次」,不保证「成功一次」

var once sync.Once
once.Do(func() { initDB() }) // 并发调用时,其余调用者阻塞到 f 返回

实现是「一次原子读 + 慢路径加锁 + 双检查」:done 只有 0/1 两态,defer 在 f 返回后置 1。由此推出两个坑:

  • f panic 了,done 照样置 1:后续调用直接返回,资源永远是空的(Go 1.21 的 OnceValue 同样如此,只是把 panic 值转发给每个调用者);
  • f 里递归调用自己的 Do:第二次 Do 在等第一次释放内部锁 → 死锁,不是无限递归。

需要「失败可重试」就别用 Once:OnceValue/OnceValues 也会把返回的 error 一起缓存,同样不重试。要么让 f 只做不会失败的事,要么自己用 Mutex + 状态机(记录 done 与 error,失败时允许重来)。

sync.Map 与 sync.Pool:两个被误解的类型

sync.Map:2020 年的经典讲解已经作废

2020 年前后的资料会花一整讲讲 read + dirty 双 map、amended 标志、misses 提升——这套实现从 Go 1.24 起已被替换为哈希 trie(internal/sync.HashTrieMap),release notes 当时给的开关是 GOEXPERIMENT=nosynchashtriemap。今天 sync.Map 的类型定义只剩:

type Map struct {
	_ noCopy
	m isync.HashTrieMap[any, any] // isync = internal/sync
}

结论层面的变化:

维度 旧实现(≤1.23) 新实现(≥1.24)
读路径 命中 read 无锁 哈希 trie,无锁读
新 key 写入 可能要 O(n) 复制 read 到 dirty 无全量复制,写不相交键几乎不争用
删除 延迟删除(nil/expunged 三态) 直接删
适用面 只在「只增不删」的缓存里划算 读多写少、写不相交键都划算

本机实测(8 核,1024 个 key):9:1 读写混合 sync.Map 18.4ns vs map + RWMutex 108ns;每个 goroutine 只写自己的 key 时 46.6ns vs 165ns。

🔑 但选型建议没变:sync.Map 仍是「特化容器」,没有 Len,键值都是 any,丢了类型安全;能用普通 map + 锁就别用它,用它就要说得出「为什么这里的锁争用是瓶颈」。

sync.Pool:它是池,不是缓存

三层结构:每个 P 一个 private(无锁,只有本 P 能取)+ 一个 shared(本地 pushHead/popHead,别的 P 从尾部 popTail 偷)+ victim(上一轮的副本)。

var bufPool = sync.Pool{New: func() any { b := make([]byte, 0, 4096); return &b }}

func encode(w io.Writer, v any) error {
	bp := bufPool.Get().(*[]byte)
	defer bufPool.Put(bp) // 用完放回;放回的不是「保留」,只是「下次可能还在」
	*bp = append((*bp)[:0], v.(fmt.Stringer).String()...)
	_, err := w.Write(*bp)
	return err
}

每轮 GC 会先丢弃旧 victim,再把 local 整体降级为 victim——所以一个对象最多跨两轮 GC 存活,之后无声消失。这就是「不能当缓存」的机制原因:不保证命中、没有容量上限、没有淘汰策略。连接池、会话缓存、LRU 都不能用它实现。

本机实测(4KB 缓冲,逃逸到堆):每次 make 442ns / 4096B,用 Pool 25ns / 24B(24B 是 &buf 让切片头逃逸到堆的固定开销)——快 18 倍、内存少 170 倍,这是它在热点路径上的真实收益。

context:三件事 + 两条纪律

能力 API 纪律
取消 WithCancel 必须调用 cancel,正常完成也要调,否则子 Context 不从父节点摘除、定时器不释放
超时/截止 WithTimeout / WithDeadline 超时值从外层传,不在每层重设(内层只会更短)
传值 WithValue 只放请求域元数据(trace_id、租户、身份),不放业务参数

两条容易忘的纪律:

  • 取消不向上传播:子 Context 被 cancel,父 Context 不受影响;反过来父 cancel 会连坐所有子节点。
  • 取消不等于终止远端工作:它只是关掉 Done() 并让 Err() 有值;服务端还在跑,除非它自己监听 ctx 或你把连接关掉。Done() 可能为 nil(Background/TODO),直接 <-ctx.Done() 会永久阻塞。

Go 1.20 起有 WithCancelCause/Cause(带上取消原因),Go 1.21 起有 AfterFunc、WithoutCancel(脱离父级取消但保留值)、WithTimeoutCause。这些是补丁,不改变上面的纪律。

atomic:能做什么、不能做什么

能:单字的 Add/CompareAndSwap/Swap/Load/Store,以及 Go 1.19 起的类型化写法 atomic.Int64、atomic.Bool、atomic.Pointer[T](And/Or 自 Go 1.23)。

不能:保护多个字段之间的不变量。atomic 的保证是「这一次读或写不可分割」,不是「这一段代码不可分割」——用 atomic.Load 判断后再改别的变量,仍然是数据竞争。

// 正确:一次 CAS 决定状态迁移
for {
	old := state.Load()
	if old >= closed {
		return
	}
	if state.CompareAndSwap(old, old+1) {
		break
	}
}

关于「atomic 能不能保证顺序」:2020 年前后的资料说「atomic 没有规范保证,别用它传递多变量顺序」——这条在 Go 1.19 修订内存模型后已经改变:原子操作现在有正式的顺序一致语义与 synchronizes-before 关系,详见 15.7 竞态检测与数据竞争。但原子变量只能全部用原子操作访问,混入普通读写仍是 data race。

落到数字:本机实测

环境:Go 1.27.1、Apple M1(8 核)、macOS 15.6;go test -bench . -benchmem -benchtime=300ms -count=3,取三次中位数。

基准 结果 说明
Mutex 无竞争 13.8 ns/op 单 goroutine
Mutex 并行自增(P=1→8) 13.7 → 87.9 ns/op 同一缓存行争用,6.4×
atomic 并行自增(P=1→8) 8.0 → 41.5 ns/op 快约 2×,同样退化
有缓冲 channel 交接(cap=1024) 66 ns/op 单消费者持有计数
RWMutex 只读 vs Mutex 只读 ≈80 vs ≈85 ns/op 空临界区,差距在噪声内
RWMutex 9:1 混合 vs Mutex 37.8 vs 86.3 ns/op 读多写少才划算
sync.Map vs map+RWMutex(9:1) 18.4 vs 108 ns/op 1024 个 key
sync.Map vs map+RWMutex(各写各 key) 46.6 vs 165 ns/op 无全量复制
4KB 缓冲 make vs sync.Pool 442 vs 25 ns/op;4096B vs 24B 对象逃逸到堆
go test -bench . -benchmem -benchtime=300ms -count=3
go test -bench CounterMutex -cpu=1,2,4,8     # 看争用如何随核数退化
go test -race ./...                          # 竞态检测,见 15.7
go vet ./...                                 # copylocks:抓「复制带锁结构体」

观察到的三条规律:

  1. 无竞争时锁很便宜(13.8ns),争用时贵 6 倍——优化方向是减少共享,不是换更快的锁;
  2. 同一变量的原子操作同样会被缓存行争用拖慢(8→41.5ns),分片(striped)才是数量级手段,见 34.4 优化手段;
  3. sync.Map 的优势来自「读不加锁、写不相交」,键集合频繁变化或需要遍历时优势消失。

反模式

反模式 后果
多分支函数里手写 Unlock 某条路径漏写即死锁;用 defer
值接收者 / 按值传参带锁结构体 锁被复制,保护失效;go vet 能抓
持锁调用会再加锁的函数 重入死锁,压测不长跑发现不了
两把锁在不同路径上乱序获取 环路死锁,必现于高并发
wg.Add(1) 写在 goroutine 里 Wait 提前返回,任务漏跑
Cond 用 if 复查条件 被唤醒后条件已变,继续执行错逻辑
Once 里递归 Do / 在 Do 里 defer once.Do 自我等待,死锁
用 sync.Pool 当缓存/连接池 GC 后对象无声消失,拿到失效连接
context.Value 传业务参数 依赖隐式、类型不安全、无法静态检查
用 atomic 保护多字段不变量 单字原子 ≠ 代码块原子,竞争仍在
用 channel 当锁(令牌) 比 Mutex 慢且死锁难查;除非需要超时/TryLock

四条铁律

🔑 谁持有谁释放,成对紧凑写;能用 defer 就别手写,能封装成方法就别把锁暴露给调用方。

🔑 每个等待点都必须可取消:channel 收发、Lock、Wait、Acquire 都可能永久阻塞——没有超时/取消的等待点,会把「慢」升级成「挂」。

🔑 有状态的原语不可复制:Mutex、RWMutex、WaitGroup、Cond、Once、Pool 都是指针语义;go vet 进 CI 是底线,不是可选项。

🔑 锁保护的是不变量,不是代码行:先写下「哪个不变量被保护」,再决定临界区边界;说不出来,就说明这把锁只是安慰剂。

自检

Q1 Mutex 为什么需要「正常/饥饿」两种模式?1ms 这个数字在防什么?

▸ 答案:正常模式下被唤醒的等待者要和新来的 goroutine 重新竞争,而新来的正在 CPU 上跑、通常赢,于是等待者被反复插队——吞吐高但尾延迟无界。等待超过 1ms(starvationThresholdNs = 1e6)就切到饥饿模式:锁直接交接给队首,新来的不许抢、不许自旋,只排队尾。1ms 是「你已经被饿着了」的判定阈值,用来在吞吐和 P99 之间做取舍。

Q2 复制一个含 sync.Mutex 的结构体,为什么是「保护失效」而不是「变成两把锁各自保护」?

▸ 答案:复制发生时锁可能正处于「已加锁」状态,副本继承了这个状态;更根本的是两个副本此后保护的是两份不同的数据(字段也一起被复制了)。于是原对象的写入没有任何互斥、副本上的修改丢失(本机实测 1000 次自增结果是 0)。go vet 的 copylocks 检查会报 passes lock by value,CI 里必须开。

Q3 RWMutex 为什么禁止「读锁升级成写锁」和「递归读锁」?写优先又带来什么代价?

▸ 答案:升级时读者持有 RLock 等写者释放,而写者要等所有读者(包括它)退出——互等死锁。递归读锁更隐蔽:RLock 里再 RLock,若中间有写者排队,readerCount 已为负,第二个 RLock 阻塞,而第一个还在等第二个返回。写优先(阻塞的写者挡住新读者)消除了写饥饿,代价是高频写时读者被持续挡住,可能反转为读饥饿。

Q4 WaitGroup.Add 为什么不能放在 goroutine 里?Go 1.25 的 wg.Go 解决了什么?

▸ 答案:Wait 只判断「计数是否为 0」,Add 还没执行时计数为 0,Wait 立即返回(本机实测 100 个任务只等到 87 个)。wg.Go(f) 把 Add(1)、go、Done 收进一个方法,调用点就不可能把顺序写反;f panic 时它故意不调用 Done 而直接重新 panic——让 panic 先杀死进程,避免 Wait 与致命 panic 竞争;代价是计数不会归零。

Q5 Cond.Signal 为什么会「丢」?为什么 Wait 必须放在 for 里?

▸ 答案:通知不排队——没有等待者时 Signal 什么也不做,所以「先 Signal 后 Wait」的等待者永远等不到下一次通知。而 Wait 返回只代表「被叫醒了一次」,条件可能已被别的 goroutine 改回不成立,所以必须用 for 重新检查;用 if 就会在条件不满足时继续往下跑。

Q6 2020 年那套「sync.Map 用 read/dirty 双 map」的讲解,今天还能照搬吗?选型结论变了吗?

▸ 答案:实现已经变了:Go 1.24 起 sync.Map 是 internal/sync.HashTrieMap,read/dirty/amended/misses/expunged 全部不存在,「新 key 写入要 O(n) 复制」这个缺点也随之消失(本机实测「各写各 key」46.6ns vs map+RWMutex 165ns)。但选型结论没变:它仍是特化容器、没有 Len、键值是 any,只有在「读多写少 / 写不相交键」且锁争用确实是瓶颈时才值得用。

Q7 sync.Pool 为什么不能当缓存用?「对象最多活两轮 GC」是什么意思?

▸ 答案:每轮 GC 的 poolCleanup 先丢弃旧 victim,再把 local 降级为 victim——所以对象经历「local → victim → 被丢弃」两轮后必然消失,且全程没有通知。它没有容量上限、没有淘汰策略、不保证命中率;连接、会话这类「必须还在」的对象一旦被清掉就是故障。它的定位是「降低分配次数与 GC 压力」,不是「保留对象」。

Q8 atomic 能替代 Mutex 保护「余额 + 版本号」这种多字段状态吗?Go 1.19 之后它的保证有什么变化?

▸ 答案:不能。原子操作保护的是「这一次读或写」不可分割,多字段之间的不变量仍会被打破(atomic 没有事务语义)。要整体替换就改成「不可变结构体 + atomic.Pointer 一次换指针」。Go 1.19 修订内存模型后,原子操作有了正式的顺序一致语义与 synchronizes-before 保证——「看不见」的问题解决了,但「不变量」的问题并没有。

关联