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 三定律:
- 谁发送谁关闭,接收方永远不关(接收方不知道还有没有别的发送者);
- 只关一次,重复 close 直接 panic;
- 关闭后不再发送,发送到已关闭的 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。由此推出两个坑:
fpanic 了,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:抓「复制带锁结构体」
观察到的三条规律:
- 无竞争时锁很便宜(13.8ns),争用时贵 6 倍——优化方向是减少共享,不是换更快的锁;
- 同一变量的原子操作同样会被缓存行争用拖慢(8→41.5ns),分片(striped)才是数量级手段,见 34.4 优化手段;
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 保证——「看不见」的问题解决了,但「不变量」的问题并没有。
关联
- 前置:15.1 GMP 调度模型、15.2 goroutine 与栈管理
- 并列:15.7 竞态检测与数据竞争(内存模型与检测)、15.8 并发编程模型 (线程 / 协程 / Actor / CSP)(模式与编排)
- 下游:34.4 优化手段(分片锁)、53.9 竞态检测与并发测试(CI 门禁)、48.3 分布式锁(跨进程的同一组问题)