跳转到正文
zeno's blog
返回

Go 并发(二):sync 包与并发原语

专题: Go 并发

Table of contents

Open Table of contents

TL;DR

sync 包的每个原语都围绕同一个核心模式:fast path 用 atomic 无锁操作,slow path 用 runtime semaphore 休眠/唤醒。Mutex 通过 normal/starvation 双模式平衡吞吐与尾延迟;sync.Map 在 Go 1.24 重写为 HashTrieMap(并发哈希字典树);sync.Pool 利用 per-P 本地存储 + victim cache 实现两轮 GC 缓冲。理解这些内部结构,才能在正确的场景选择正确的原语。


1. sync.Mutex 内部机制

1.1 state 字段位布局

Mutex 的核心是一个 int32 类型的 state 字段,用位运算打包了四种信息:

┌─────────────────────────────────────────────────┬───────────┬────────┬────────┐
│           waiter count (29 bits)                │ starvation│ woken  │ locked │
│           bits [31:3]                           │  bit [2]  │ bit [1]│ bit [0]│
└─────────────────────────────────────────────────┴───────────┴────────┴────────┘

源码常量定义(sync/mutex.go):

const (
    mutexLocked      = 1 << iota  // 1  — bit 0: 是否被锁定
    mutexWoken                     // 2  — bit 1: 是否有 goroutine 被唤醒
    mutexStarving                  // 4  — bit 2: 是否处于饥饿模式
    mutexWaiterShift = iota        // 3  — waiter 计数从 bit 3 开始
)

1.2 Normal 模式 vs Starvation 模式

这是 Mutex 最关键的设计决策——双模式切换算法

Normal 模式(高吞吐,不公平)

  1. 新到达的 goroutine 在 CPU 上自旋(spin),同时与等待队列中被唤醒的 goroutine 竞争锁
  2. 新 goroutine 有天然优势:它们已经在 CPU 上运行,且可能有很多个
  3. 被唤醒的 waiter 竞争失败后,被放回等待队列的队首(不是队尾)
  4. 这种设计在低竞争场景下吞吐量很高——大部分锁操作不需要经过 runtime semaphore

Starvation 模式(公平,防尾延迟)

触发条件:某个 waiter 等待超过 1ms

  1. 锁的所有权直接从 unlock 的 goroutine FIFO 移交给队首 waiter
  2. 新到达的 goroutine 不自旋,直接排到队尾
  3. 新到达的 goroutine 不尝试获取锁,即使看到 unlocked 状态

退出条件(满足任一):

为什么需要双模式

维度仅 Normal仅 Starvation双模式
吞吐量低(每次都走 semaphore)高(大部分走 Normal)
尾延迟差(waiter 可能被饿死)好(FIFO 保证)好(1ms 切换兜底)
典型延迟

来源:sync/mutex.go 源码注释,Go 1.9 引入 starvation mode(CL 34310

1.3 自旋逻辑

goroutine 尝试自旋而非立即 park 的条件(runtime_canSpin 检查):

  1. 多核机器:GOMAXPROCS > 1 且至少有一个其他正在运行的 P
  2. 自旋次数 ≤ 4(active_spin = 4
  3. 不在 starvation 模式
  4. 当前 P 的本地 runq 为空(不阻塞其他 goroutine)

自旋操作本身是执行 PAUSE 指令(x86)若干次(active_spin_cnt = 30),让 CPU 在 busy-wait 的同时暗示超线程让出资源。

1.4 Lock/Unlock 完整流程

Lock fast path(一次 CAS 成功即返回):

if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return  // 无竞争,直接获得锁
}

Lock slow pathlockSlow):

  1. 判断是否可以自旋 → 自旋等待
  2. 计算 new state(根据当前模式设置 locked/woken/starving 位 + waiter count)
  3. CAS 尝试更新 state
  4. 失败则回到 1;成功则:
    • 如果获得了锁 → 返回
    • 否则 → runtime_SemacquireMutex 睡眠在信号量上(starvation 模式下排队首,normal 模式下排队尾)

Unlock fast path(一次 atomic add):

new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
    m.unlockSlow(new)
}

Unlock slow pathunlockSlow):


2. sync.RWMutex 内部机制

2.1 结构体字段

type RWMutex struct {
    w           Mutex        // 写锁(互斥写者之间的竞争)
    writerSem   uint32       // writer 等待的信号量
    readerSem   uint32       // reader 等待的信号量
    readerCount atomic.Int32 // 当前 reader 数量(可为负数)
    readerWait  atomic.Int32 // writer 等待的未完成 reader 数量
}

2.2 readerCount 的符号翻转技巧

核心常量:rwmutexMaxReaders = 1 << 30(约 10 亿)

关键设计:readerCount 的正负号用来标记”是否有 writer 在等待”。

readerCount > 0  →  没有 writer 等待,值 = 当前活跃 reader 数量
readerCount < 0  →  有 writer 等待,实际 reader 数 = readerCount + rwmutexMaxReaders

2.3 四个操作的完整流程

RLock(加读锁)

  1. readerCount.Add(1) — 原子加 1
  2. 如果结果 < 0 → 有 writer 在等待 → 在 readerSem 上睡眠
  3. 如果结果 ≥ 0 → 直接返回(fast path,无锁)

RUnlock(解读锁)

  1. readerCount.Add(-1) — 原子减 1
  2. 如果结果 < 0 → 有 writer 在等待 → readerWait.Add(-1),如果 readerWait 变为 0 → 释放 writerSem 唤醒 writer
  3. 如果结果 ≥ 0 → 直接返回

Lock(加写锁)

  1. 先获取内部的 w Mutex(排斥其他 writer)
  2. readerCount.Add(-rwmutexMaxReaders) — 翻转为负数,向所有后续 RLock 宣告”writer 来了”
  3. 读取当前活跃 reader 数量,存入 readerWait
  4. 如果 readerWait > 0 → 在 writerSem 上睡眠,等待最后一个 reader 唤醒自己
  5. 如果 readerWait == 0 → 直接返回(没有活跃 reader)

Unlock(解写锁)

  1. readerCount.Add(rwmutexMaxReaders) — 翻转回正数
  2. 逐个释放 readerSem,唤醒所有阻塞的 reader
  3. 释放内部的 w Mutex

2.4 Writer 饥饿防护

一旦 writer 调用 Lock(readerCount 变为负数),所有后续的 RLock 都会阻塞。这意味着 writer 不会被无限延迟的新 reader 饿死。只有在 writer Lock 之前已经持有读锁的 reader 需要完成。


3. sync.WaitGroup 内部机制

3.1 状态打包

type WaitGroup struct {
    noCopy noCopy       // go vet 检查防复制
    state  atomic.Uint64 // 高 32 位 = counter, 低 32 位 = waiter count
    sema   uint32        // 信号量
}
┌─────────────────────────────┬─────────────────────────────┐
│   counter (高 32 bits)      │   waiter count (低 32 bits) │
│   goroutine 计数            │   Wait() 调用者数量         │
└─────────────────────────────┴─────────────────────────────┘
                    state (uint64)

为什么用一个 uint64 打包两个值?因为 Add/Done 需要在修改 counter 的同时读取 waiter count,打包后一次 atomic 操作即可完成,不需要额外的锁。

3.2 Add/Done/Wait 流程

Add(delta)

state := wg.state.Add(uint64(delta) << 32) // delta 左移 32 位加到高 32 位
counter := int32(state >> 32)
waiters := uint32(state)
if counter == 0 && waiters > 0 {
    // counter 归零,唤醒所有 waiter
    wg.state.Store(0)
    for ; waiters > 0; waiters-- {
        runtime_Semrelease(&wg.sema)
    }
}

Done() = Add(-1)

Wait()

for {
    state := wg.state.Load()
    counter := int32(state >> 32)
    if counter == 0 { return }  // fast path: 没有需要等待的
    // CAS 增加 waiter count(低 32 位 +1)
    if wg.state.CompareAndSwap(state, state+1) {
        runtime_Semacquire(&wg.sema) // 睡眠
        return
    }
}

3.3 Go 1.25 新增:WaitGroup.Go

func (wg *WaitGroup) Go(f func()) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        f()
    }()
}

这是个语法糖,但消除了 Add/Done 不匹配的 bug 来源。Go 1.25 的 go vet 同时新增了 waitgroup 分析器,能检测到 Add 调用位置不正确的情况。

3.4 关键约束

Add 必须在对应的 goroutine 启动之前调用(或在 Wait 之前的同一 goroutine 中调用)。如果 AddWait 之后并发调用,存在 race condition:counter 可能先归零触发唤醒,然后又被 Add 拉起来。


4. sync.Once 内部机制

4.1 结构与双路径

type Once struct {
    done atomic.Uint32  // fast path 用
    m    Mutex           // slow path 用
}

Fast path(热路径,绝大多数调用走这里):

func (o *Once) Do(f func()) {
    if o.done.Load() == 0 {  // 单次 atomic read
        o.doSlow(f)
    }
}

Slow path(仅第一次调用):

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 {  // double-check
        defer o.done.Store(1)
        f()  // 执行完成后才标记 done
    }
}

4.2 为什么不用 CAS

一个常见的疑问:为什么不用 CompareAndSwap(0, 1) 来实现 Once?

// 错误实现!
func (o *Once) Do(f func()) {
    if o.done.CompareAndSwap(0, 1) {
        f()  // 问题:f() 还没执行完,其他 goroutine 就看到 done=1 直接返回了
    }
}

CAS 的问题:其他 goroutine 会在 f() 完成之前就观察到 done=1,导致它们在 f() 还没初始化完成时就认为”已完成”而继续执行。Mutex + double-check 保证所有 goroutine 要么执行 f(),要么等待 f() 执行完毕。

4.3 Go 1.21+ 新增的便捷包装

// OnceFunc: 将 func() 包装为只执行一次的函数
initOnce := sync.OnceFunc(func() {
    // 只执行一次的初始化逻辑
})
initOnce() // 第一次:执行
initOnce() // 后续:no-op

// OnceValue: 缓存返回值
getConfig := sync.OnceValue(func() *Config {
    return loadConfig()
})
cfg := getConfig() // 第一次:执行并缓存
cfg = getConfig()  // 后续:直接返回缓存

// OnceValues: 缓存多返回值(含 error)
getDB := sync.OnceValues(func() (*sql.DB, error) {
    return sql.Open("postgres", dsn)
})
db, err := getDB()

这些包装函数的内部实现也做了优化:inner closure 只构建一次,后续调用的 fast path 开销约 1-2ns(仅一次 atomic load)。

如果 f() panic,OnceFunc/OnceValue/OnceValues 会在后续调用时 re-panic 同一个值,而不是静默吞掉。


5. sync.Pool 内部机制

5.1 Per-P 结构设计

sync.Pool
├── local [GOMAXPROCS]poolLocal    // 每个 P 一个本地池
│   ├── private  any               // 当前 P 独占(无锁)
│   └── shared   poolChain         // lock-free 双端队列链
└── victim [GOMAXPROCS]poolLocal   // 上一轮 GC 的缓存

Get 流程

  1. Pin 当前 goroutine 到 P(runtime_procPin,防止被抢占)
  2. private → 命中则返回
  3. 从自己的 shared 队列尾部 pop → 命中则返回
  4. 其他 Pshared 队列头部 steal → 命中则返回
  5. victim cache 重复上述步骤 → 命中则返回
  6. 调用 New() 创建新对象

Put 流程

  1. Pin 到 P
  2. 如果 private 为空 → 直接放入 private
  3. 否则 push 到 shared 队列尾部

5.2 GC 交互与 victim cache

每次 GC 时,runtime 调用 poolCleanup

GC 第 N 轮:
  victim = nil        // 清除上上轮的
  victim = local      // 当前本地池降级为 victim
  local  = empty      // 清空当前本地池

GC 第 N+1 轮:
  victim = nil        // 清除第 N 轮的 victim
  victim = local      // ...

对象最多存活两个 GC 周期。这是设计上的权衡:直接清空会导致 GC 后瞬间大量分配(性能抖动),victim cache 提供了一轮缓冲。

来源:Go issue #22950,victim cache 于 Go 1.13 引入

5.3 适用场景

场景是否适合原因
高频短生命周期临时对象(bytes.Buffer、编解码 buffer)适合减少 GC 压力,对象生命周期与 Pool 语义匹配
连接池(DB/HTTP)不适合连接是长生命周期资源,GC 清除会导致连接丢失
大对象(>32KB)看情况分配代价高时值得 pool,但注意 Pool 中积累大对象占内存
创建代价极低的对象不适合Pool 本身的 pin/unpin 开销可能超过直接 new

5.4 noCopy 机制

Pool 内嵌了 noCopy 字段。这不是运行时检查,而是 go vet 的静态分析约定——任何包含 noCopy 字段的 struct 被复制时,go vet 会报错。Mutex、WaitGroup、Cond 也都用了这个技巧。


6. sync.Map 内部机制

6.1 Go 1.24 之前:双 map 设计

Go 1.24 之前的经典实现使用 read map + dirty map 的双层结构:

sync.Map (Go < 1.24)
├── mu       Mutex
├── read     atomic.Pointer[readOnly]  // 无锁读取
│   └── m   map[any]*entry             // read map
│   └── amended bool                   // dirty 中是否有 read 没有的 key
├── dirty    map[any]*entry            // 需要 mu 保护
└── misses   int                       // read miss 计数

entry:
├── p unsafe.Pointer  // 指向实际值
│   ├── 正常值:指向存储的值
│   ├── nil:已删除,dirty 中也有此 key
│   └── expunged:已删除,dirty 中没有此 key
读操作 fast path:
  ┌──────────┐    atomic Load    ┌──────────┐
  │  caller  │ ──────────────── │ read map │ ── 命中 → 返回
  └──────────┘                  └──────────┘
       │                              │
       │ miss                         │ miss
       ▼                              ▼
  ┌──────────┐    Lock + lookup  ┌──────────┐
  │  caller  │ ──────────────── │ dirty map│ ── misses++
  └──────────┘                  └──────────┘

                          misses >= len(dirty)?
                                      │ yes

                              dirty 提升为 read
                              dirty = nil
                              misses = 0

entry 的三种状态

dirty 提升时机:当 read miss 计数 misses >= len(dirty) 时,整个 dirty map 原子替换为 read map。这是 O(1) 的指针替换,不是复制。

6.2 Go 1.24+:HashTrieMap 重写

Go 1.24 彻底重写了 sync.Map,底层使用 HashTrieMap(并发哈希字典树,16-way branching):

关键改进:

如遇兼容性问题,可通过 GOEXPERIMENT=nosynchashtriemap 回退。

6.3 Go 1.23:新增 Map.Clear()

Map.Clear() 删除所有条目,等价于内置 clear() 函数。

6.4 sync.Map vs map+RWMutex 选型

维度sync.Mapmap + RWMutex
读多写少优势明显(lock-free 读)尚可(读锁开销低但非零)
读写均衡Go 1.24+ 改善很多通常更好
key 集合稳定(写一次读多次)最佳场景可以但不必要
key 频繁增删Go 1.24 前较差,1.24+ 可接受通常更好
不相交 key 并发写Go 1.24+ 优秀(per-node lock)差(全局锁)
类型安全差(any 接口)好(泛型 map)
遍历Range 回调,不能 break [需验证]直接 for-range
内存占用较高较低

经验法则:如果不确定,先用 map + sync.RWMutex。只在 profiling 证明锁是瓶颈,且访问模式匹配 sync.Map 优势场景时才切换。


7. sync.Cond

7.1 基本语义

cond := sync.NewCond(&sync.Mutex{})

// 等待方(必须在循环中调用 Wait)
cond.L.Lock()
for !condition() {
    cond.Wait()  // 释放锁 → 睡眠 → 被唤醒后重新获取锁
}
// condition 为 true,继续执行
cond.L.Unlock()

// 通知方
cond.L.Lock()
updateCondition()
cond.Signal()    // 唤醒一个 waiter
// 或
cond.Broadcast() // 唤醒所有 waiter
cond.L.Unlock()

7.2 为什么 Cond 很少被使用

channel 在大多数场景下更好

但 channel 的 close 只能用一次,而 Broadcast 可以反复调用

7.3 Cond 的独特价值场景

Cond 在以下场景比 channel 更合适:

  1. 多消费者等待同一个可重复变化的条件:比如”buffer 非空”这种条件会反复变化,每次变化都需要 Broadcast 唤醒所有 waiter。channel 做不到”多次 close”。

  2. 等待复杂条件表达式:多个条件的组合(len(queue) > 0 && !shutdown),用 channel 很难表达。

  3. 需要与现有 Mutex 配合:条件变量天然绑定 Mutex,检查条件和修改状态在同一把锁的保护下。

实践中,如果你发现自己在用 Cond,先想想能不能用 channel 或 context 替代。如果涉及”多次广播唤醒所有等待者”的模式,Cond 是正确选择。


8. sync/atomic 包

8.1 类型化原子操作(Go 1.19+)

Go 1.19 引入了类型安全的原子包装:

var counter atomic.Int64
counter.Add(1)
v := counter.Load()

var flag atomic.Bool
flag.Store(true)

var ptr atomic.Pointer[Config]
ptr.Store(&Config{...})
cfg := ptr.Load()
类型方法用途
atomic.Int32 / Int64Load, Store, Add, Swap, CompareAndSwap计数器、状态码
atomic.Uint32 / Uint64同上无符号计数
atomic.BoolLoad, Store, Swap, CompareAndSwap标志位
atomic.Pointer[T]Load, Store, Swap, CompareAndSwaplock-free 数据结构
atomic.ValueLoad, Store存储任意类型(但不能混合类型)

8.2 Go 1.23 新增:And/Or 原子位操作

atomic.And(&flags, ^maskBit)  // 原子清除位,返回旧值
atomic.Or(&flags, maskBit)    // 原子设置位,返回旧值

用于并发 flag 操作,避免 CAS 循环。

8.3 atomic.Value 约束

var v atomic.Value
v.Store(42)        // 第一次 Store 确定类型
v.Store("hello")   // panic: sync/atomic: store of inconsistently typed value

atomic.Value 要求所有 Store 的值必须是同一具体类型。第一次 Store 的类型会被记录,后续不一致则 panic。

8.4 CAS 模式

// Lock-free 计数器
var counter atomic.Int64
for {
    old := counter.Load()
    if counter.CompareAndSwap(old, old+1) {
        break
    }
    // CAS 失败说明有竞争,重试
}
// 注意:上面的例子用 Add 更好,CAS 循环适用于更复杂的状态转换

CAS 循环的核心模式:read → compute → CAS → retry if failed。适用于无法用单一 Add/And/Or 表达的状态转换。

8.5 Atomic vs Mutex 选型

维度atomicMutex
保护范围单个变量任意代码块
操作复杂度单步操作(load/store/add/cas)任意多步操作
性能~1-10ns~20-50ns(无竞争),竞争时更高
适用场景计数器、标志位、指针替换多字段一致性、复杂状态转换
可组合性差(多个 atomic 操作之间没有原子性保证)好(锁内所有操作原子)
认知负载高(需理解 memory ordering)低(锁内即安全)

原则:能用 Mutex 就用 Mutex,只在 profiling 证明 Mutex 是瓶颈时才考虑 atomic。Atomic 用错了产生的 bug 极其难调试。


9. Go Memory Model(实践要点)

9.1 Happens-before 关系

Go memory model 定义了以下 happens-before 保证:

操作 AHappens-before操作 B
go f() 语句f() 开始执行
ch <- v<-ch 完成
close(ch)<-ch 收到零值
mu.Unlock() 第 N 次mu.Lock() 第 N+1 次
once.Do(f) 中 f 返回其他 once.Do 返回
atomic.Store后续 atomic.Load(同一变量)

Go 1.19 明确规定:atomic 操作具有顺序一致性(sequentially consistent),语义等同于 C++ 的 memory_order_seq_cst 和 Java 的 volatile

9.2 Data Race = Undefined Behavior

Go 中存在 data race 的程序行为是未定义的——不是”结果不确定”,而是程序可能做任何事(编译器可以基于”无 data race”的假设做优化)。

这比大多数人直觉的”最多读到旧值”严重得多。Go race detector 基于 ThreadSanitizer (TSan),通过编译时插桩检测运行时实际发生的 race(不能检测未执行的路径)。

go test -race ./...    # 测试时启用
go run -race main.go   # 运行时启用
go build -race          # 构建带检测的二进制(性能开销 2-10x,内存 5-10x)

生产环境:不建议常开 -race(开销太大),但建议在 CI 中始终开启。


10. Go 1.22-1.26 sync 相关变更汇总

版本变更类型
Go 1.22mutex profile 计入 runtime 内部锁竞争;按阻塞 goroutine 数缩放竞争指标可观测性增强
Go 1.22for 循环每次迭代创建新变量(消除常见并发 bug)语言变更
Go 1.23sync.Map.Clear()新 API
Go 1.23atomic.And() / atomic.Or() 原子位操作新 API
Go 1.24sync.Map 重写为 HashTrieMap实现重写
Go 1.24runtime 内部 mutex 优化(spinbit mutex),整体 CPU 开销降低 2-3%性能优化
Go 1.24testing/synctest 实验性包新包(实验)
Go 1.25WaitGroup.Go(f func())新 API
Go 1.25go vet 新增 waitgroup 分析器(检测 Add 位置不正确)工具增强
Go 1.25testing/synctest 正式毕业(Run → Test)API 稳定
Go 1.25GOMAXPROCS 自动感知容器 cgroup CPU 限制运行时改进
Go 1.26实验性 goroutine 泄漏检测 profile(GOEXPERIMENT=goroutineleakprofile可观测性增强

11. Pitfalls(至少知道这些坑)

Pitfall 1: 复制 Mutex

type Service struct {
    mu sync.Mutex
    // ...
}

func process(s Service) { // 值传递!Mutex 被复制!
    s.mu.Lock()           // 这把锁和原来的不是同一个
    defer s.mu.Unlock()
}

go vet 能检测到大部分情况(通过 noCopy 机制),但如果通过 unsafe 或反射复制则检测不到。WaitGroup、Cond、Pool 同理。

Pitfall 2: 锁顺序不一致 → 死锁

// Goroutine 1          // Goroutine 2
mu1.Lock()              mu2.Lock()
mu2.Lock()  // 等 G2    mu1.Lock()  // 等 G1 → 死锁

解决方案:所有 goroutine 始终以相同顺序获取多把锁。可以给锁分配 ID,总是先获取 ID 小的。

Pitfall 3: 误以为 RWMutex writer 会被饿死

有人担心”读多写少场景下 writer 永远拿不到锁”——实际上 RWMutex 已经处理了这个问题:一旦 writer 调用 Lock,后续所有新的 RLock 都会阻塞。writer 只需等待已有的 reader 完成。

真正的问题反而是:reader 持有锁时间太长会阻塞 writer,而被阻塞的 writer 又会阻塞后续所有 reader,造成级联延迟。

Pitfall 4: WaitGroup.Add 在 Wait 之后并发调用

var wg sync.WaitGroup
go func() {
    wg.Add(1) // 可能在 Wait 之后执行!Race condition!
    defer wg.Done()
    work()
}()
wg.Wait()

Add 必须在 Wait 之前的确定性代码路径中调用,或者用 Go 1.25 的 wg.Go(f) 彻底规避。

Pitfall 5: 假设 sync.Pool 对象持久存在

pool := &sync.Pool{New: func() any { return &Buffer{} }}
buf := pool.Get().(*Buffer)
pool.Put(buf) // 放回
// ... GC 发生 ...
buf2 := pool.Get().(*Buffer) // 可能是新对象,旧的已被清除

Pool 不是缓存。对象在 GC 时会被清除(最多存活两个 GC 周期)。如果需要持久的对象池,自己实现一个基于 channel 或 ring buffer 的池。

Pitfall 6: sync.Map 用于频繁更新的 key

sync.Map 的优势场景是写一次读多次不相交 key 并发写。如果同一组 key 被频繁 Store,在 Go 1.24 之前性能可能不如 map + RWMutex(Go 1.24 的 HashTrieMap 改善了这个问题,但在高竞争热点 key 场景下 map + Mutex 仍可能更优)。

Pitfall 7: 忘记 defer Unlock → panic 导致永久死锁

func process() {
    mu.Lock()
    // mu.Unlock() 放在函数末尾,没用 defer
    result := riskyOperation() // 如果 panic,Unlock 永远不会执行
    mu.Unlock()
}

始终用 defer mu.Unlock()。defer 的性能开销在 Go 1.14+ 几乎为零(open-coded defer)。

Pitfall 8: atomic.Value 存储不同具体类型

var v atomic.Value
v.Store((*Config)(nil)) // 存 *Config 类型的 nil
v.Store((*Server)(nil)) // panic! 类型不一致

即使都是 nil,具体类型不同也会 panic。而且 不能 Store(nil)(interface nil),必须存具体类型。

Pitfall 9: 在 Cond.Wait 外不加循环

// 错误!
cond.L.Lock()
if !ready {      // 应该用 for,不是 if
    cond.Wait()   // 唤醒后 ready 可能仍为 false(spurious wakeup 或其他 goroutine 先消费)
}

Wait 返回后条件不一定为真——可能是 Broadcast 唤醒了所有人但只有一个能消费,或者(理论上)存在虚假唤醒。必须用 for 循环重新检查。


12. 生产环境 Checklist

代码阶段

选型阶段

调试阶段

性能观测


分享这篇文章:

上一篇
Go 并发(三):内存模型、happens-before 与同步语义
下一篇
Go 并发(一):Channel 内部机制与使用模式