并发编程与同步原语
#系统 · #并发 · #Mutex · #CAS · #原子操作 · #内存屏障 · #futex
并发编程的核心矛盾:多个线程共享数据时,如何保证正确性?答案是一套从硬件到 Linux 内核再到用户态库的同步机制体系。理解每一层的实现原理,才能真正写出正确且高效的并发代码。
1. 同步原语全景
mermaid
flowchart TB
subgraph Hardware["硬件层"]
H1["原子指令 (LOCK/CAS/LL-SC)"]
H2["内存屏障 (MFENCE) "]
end
subgraph Kernel["内核层"]
K1["futex(快速用户态互斥)"]
K2["spinlock(自旋锁)"]
end
subgraph User["用户态库"]
U1["Mutex"]
U2["RWMutex"]
U3["Semaphore"]
U4["WaitGroup"]
U5["Once"]
end
H1 --> K2
H2 --> K1
K1 --> U1
K1 --> U3
H1 --> U1| 层级 | 机制 | 核心指令 |
|---|---|---|
| 硬件 | 原子操作 | LOCK CMPXCHG (x86), LDREX/STREX (ARM) |
| 硬件 | 内存屏障 | MFENCE/LFENCE/SFENCE (x86), DMB/DSB/ISB (ARM) |
| 内核 | futex | FUTEX_WAIT / FUTEX_WAKE 系统调用 |
| 内核 | spinlock | arch_spin_lock,忙碌等待 |
| 用户态 | Mutex | futex + 原子自旋混合 |
2. 原子操作
2.1 x86 LOCK 前缀
LOCK CMPXCHG [mem], reg // 比较并交换
LOCK XADD [mem], reg // 原子加法
LOCK XCHG [mem], reg // 交换(隐含 LOCK)LOCK 前缀确保指令对内存的读写是原子的:锁住总线或缓存行,阻止其他 CPU 同时访问。
2.2 CAS(Compare-And-Swap)
最基本的原子原语,几乎所有高级同步机制都是基于 CAS 构建的。
go
// Go 中的原子操作:由 runtime 保证,底层调用硬件指令
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1) // 原子自增
atomic.CompareAndSwapInt64(&counter, 10, 20) // CASCAS 的伪代码(硬件原子执行):
func CAS(ptr *int64, old, new int64) bool {
if *ptr == old {
*ptr = new
return true
}
return false
}2.3 CAS 的 ABA 问题
| 时刻 | 线程 1 | 线程 2 | 共享变量 |
|---|---|---|---|
| t0 | 读取 A | - | A |
| t1 | - | CAS A→B | B |
| t2 | - | CAS B→A | A |
| t3 | CAS A→C ✅ | - | C |
线程 1 以为值没变,但实际上已经被修改过了。
解决:加版本号(Go 的 sync.Mutex 中使用的 ticket lock 模式天然避免了 ABA)。
2.4 ARM 的 LL/SC
ARM 使用 Load-Link(LDREX)/ Store-Conditional(STREX)代替 CAS:
LDREX R1, [addr] // 加载 + 标记
// ... 计算新值 ...
STREX R2, R0, [addr] // 如果标记未被破坏,写入成功,R2=0
// 否则写入失败,R2≠0,需重试3. 内存屏障与内存序
3.1 为什么需要内存屏障
编译器优化和 CPU 乱序执行会导致指令重排:
go
// 线程 1:
data = 42 // A
ready = true // B
// 线程 2:
for !ready {} // C
fmt.Println(data) // D
// 直觉:输出 42
// 实际可能输出 0!A 和 B 可能被重排指令重排的三层来源——编译器、CPU、内存系统:
第一层: 编译器重排 (Compiler Reordering)
编译器为了优化性能,可能调整指令顺序:
- 将无依赖的 store 提前/延后
- 将循环不变量外提
- 寄存器分配时改变 load/store 顺序
示例: 编译器可能把 A 和 B 交换(它们之间无数据依赖)
防御: volatile (C/C++), atomic (Go), 编译器屏障 asm volatile("" ::: "memory")
第二层: CPU 乱序执行 (Out-of-Order Execution)
现代 CPU 有 ROB (Reorder Buffer),可以乱序执行指令:
- Store Buffer: store 指令先写入 buffer,稍后才刷到 cache
- Load 可以越过前面的 Store(如果地址不同)
- 多条 Load 可以乱序完成(如果 cache miss 时间不同)
示例: 即使编译器没重排,CPU 也可能让 B 的 store 先于 A 对其他核可见
防御: MFENCE (x86), DMB (ARM), 硬件内存屏障指令
第三层: 内存系统重排 (Memory System Reordering)
多核 CPU 的 cache 一致性协议 (MESI) 有延迟:
- Invalidate Queue: 收到 invalidate 消息后不立即处理,排队等待
- Store Buffer: 写入后不立即广播给其他核
- 不同核看到的 store 顺序可能不同
示例: 核 A 先写 X 再写 Y,核 B 可能先看到 Y 的新值再看到 X 的新值
防御: Store Buffer flush (SFENCE), Invalidate Queue drain (LFENCE)
三层防御的映射关系:
Go atomic.Store → 编译器屏障 + CPU 屏障 + 内存系统屏障(全部覆盖)
Go sync.Mutex → Lock 时 Acquire 屏障, Unlock 时 Release 屏障
普通变量赋值 → 无任何屏障 → 三层都可能重排 → 并发不安全
为什么 x86 上很多 Bug 不暴露?
x86 TSO (Total Store Order) 模型:
- Store-Store 不重排 → A 写完再写 B,其他核也按这个顺序看到
- Load-Load 不重排 → 读 C 再读 D,不会看到 D 的旧值和 C 的新值
- 只有 Store-Load 可能重排 → 写 X 后立即读 Y,可能读到 Y 的旧值
ARM 弱模型:
- 四种重排全部允许 → 必须显式加屏障
- 这就是为什么"在 x86 上测试通过的并发代码,在 ARM (M1 Mac) 上崩溃"3.2 x86 vs ARM 内存模型
| 模型 | x86(TSO 强模型) | ARM(弱模型) |
|---|---|---|
| Load-Load 重排 | ❌ | ✅ |
| Load-Store 重排 | ❌ | ✅ |
| Store-Load 重排 | ✅(唯一允许的) | ✅ |
| Store-Store 重排 | ❌ | ✅ |
x86 是相对"友好"的强一致性模型,只有 Store-Load 重排。这也是为什么很多 x86 上的并发 Bug 到了 ARM 上才暴露。
3.3 C11 / Go 内存序
go
// Go 1.19+ 引入了 atomic 类型,支持内存序
// 但不建议普通用户直接使用,sync 包已经处理好了
// C11 风格(Go runtime 内部使用):
atomic.StoreInt64(&ready, 1) // 等价于 memory_order_release
atomic.LoadInt64(&ready) // 等价于 memory_order_acquire| 内存序 | 含义 | 典型场景 |
|---|---|---|
| Relaxed | 只保证原子性,不保证顺序 | 计数器 |
| Acquire | 之后的读写不能重排到此之前 | 读锁 |
| Release | 之前的读写不能重排到此之后 | 写锁 |
| AcqRel | 兼具 Acquire 和 Release | RWMutex |
| SeqCst | 全局顺序一致(默认,最安全最慢) | 一般情况 |
3.4 Cache Line 与 False Sharing
并发性能问题很多不是“锁太慢”,而是多个核在争同一条 cache line。
mermaid
flowchart LR
CPU1["CPU Core 1\n更新 counterA"] --> CL["同一条 Cache Line\n64B"]
CPU2["CPU Core 2\n更新 counterB"] --> CL
CL --> INV["缓存行失效 / 一致性流量"]
INV --> SLOW["吞吐下降,CPU 飙高"]go
// ❌ 可能 false sharing
type Counters struct {
a int64
b int64
}
// ✅ 用 padding 隔离热点字段
type PaddedCounters struct {
a int64
_ [56]byte
b int64
}| 现象 | 原因 | 典型场景 |
|---|---|---|
| CPU 很高但业务逻辑很简单 | cache line 在多个核之间来回失效 | 多线程更新相邻计数器 |
| 原子操作性能远低于预期 | 原子本身 + 一致性流量叠加 | 热点统计、队列 head/tail |
| 加锁不多但吞吐差 | 真正瓶颈在缓存一致性,不一定在 futex | per-core 计数器设计不合理 |
经验:热点写字段要拆开;读多写少字段可以聚合,写热点字段尽量按 CPU / worker 分片。
3.5 MESI 到代码:为什么“改一个 int”也会很贵
text
一个核心修改共享变量
→ 本地 cache line 变成 Modified
→ 其他核心同一行副本失效
→ 其他线程再次读取时要重新拿最新值
如果多个线程持续改同一行
→ cache ping-pong
→ 实际花费往往比一次 syscall 还明显所以:
- 原子操作不是“零成本锁替代品”
- 无锁结构不是天然更快
- 真正要看竞争粒度、cache line 布局、重试次数
4. Mutex 实现原理
4.1 Go Mutex 状态机
┌───────────────────┐
│ unlocked │
└────────┬──────────┘
│ Lock()
┌────────▼──────────┐
│ locked (无等待) │◄──── Unlock() ────┐
└────────┬──────────┘ │
│ 有竞争者 │
┌────────▼──────────┐ │
│ locked (自旋中) │ │
└────────┬──────────┘ │
│ 自旋超时 │
┌────────▼──────────┐ │
│ locked (睡眠中) │────────────────────┘
└───────────────────┘Go 1.8 之后的 Mutex 使用自旋 + 信号量混合模式:
- 尝试 CAS 获取锁(快速路径)
- 失败 → 自旋等待(多核 + GOMAXPROCS > 1 + 自旋次数有限)
- 仍失败 → 加入等待队列 →
futex_wait进入内核态睡眠
4.2 futex(Fast Userspace muTEX)
c
// Linux futex:只在有竞争时才进入内核态
int futex_wait(int *uaddr, int val); // 如果 *uaddr == val,则睡眠
int futex_wake(int *uaddr, int n); // 唤醒 n 个等待者
// 无竞争快速路径:纯用户态 CAS 操作
// 有竞争慢路径:通过 futex 系统调用让出 CPU为什么 futex 优于传统 semaphore:无竞争时零系统调用,有竞争时才走内核。
4.3 RWMutex 的读优先进化史
Go 1.20 之前:纯写锁饥饿(大量读者可以无限阻塞写者) Go 1.20 之后:写者等待时,新读者也需排队(读者-写者公平)
go
var mu sync.RWMutex
mu.RLock() // 读锁:多个 goroutine 可同时持有
mu.RUnlock()
mu.Lock() // 写锁:互斥
mu.Unlock()4.4 锁竞争不只是“等待”,还有调度和缓存代价
| 成本来源 | 说明 |
|---|---|
| CAS 失败重试 | 多个线程同时抢锁,白白消耗 CPU |
| 自旋 | 临界区短时有效,临界区长时浪费 CPU |
| park/unpark | 进入休眠/唤醒需要调度器参与 |
| cache line 争用 | 锁变量本身就是热点写点 |
| 临界区过大 | 即使锁实现优秀,也会把吞吐锁死 |
实践上最该优化的往往不是“换锁”,而是缩小临界区:
go
// ❌ 锁内做 I/O / RPC / channel send
mu.Lock()
user := users[id]
resp, _ := httpClient.Do(req)
ch <- resp
mu.Unlock()
// ✅ 只保护共享数据,耗时逻辑移到锁外
mu.Lock()
user := users[id]
mu.Unlock()
resp, _ := httpClient.Do(req)
ch <- resp5. Spinlock
go
// 简易自旋锁(不适用于生产环境,仅供理解原理)
type Spinlock struct {
locked int32
}
func (s *Spinlock) Lock() {
for !atomic.CompareAndSwapInt32(&s.locked, 0, 1) {
runtime.Gosched() // 让渡 CPU,避免忙等死循环
}
}
func (s *Spinlock) Unlock() {
atomic.StoreInt32(&s.locked, 0)
}| Mutex | Spinlock |
|---|---|
| 等待时让出 CPU(睡眠) | 等待时忙等(自旋) |
| 适合临界区较长的场景 | 适合临界区极短(几纳秒)的场景 |
| 有上下文切换开销 | 无上下文切换,浪费CPU |
| 用户态应用 | 内核代码、中断上下文 |
6. 无锁数据结构
6.1 无锁队列(Lock-free Queue)
go
// Michael-Scott 无锁队列(链表实现)
// 使用 CAS 实现并发安全的入队/出队
type Node struct {
value interface{}
next atomic.Value // *Node
}
type LockfreeQueue struct {
head atomic.Value // *Node
tail atomic.Value // *Node
}Go 中:chan 已经是最常用的"无锁"同步原语(内部使用 ring buffer + 信号量)。
6.2 无锁栈(Treiber Stack)
go
// 使用 CAS 的 Treiber 栈
type Stack struct {
top atomic.Pointer[Node]
}
func (s *Stack) Push(v int) {
node := &Node{value: v}
for {
oldTop := s.top.Load()
node.next = oldTop
if s.top.CompareAndSwap(oldTop, node) {
return
}
}
}7. Go sync 全家桶
| 原语 | 功能 | 实现基础 |
|---|---|---|
sync.Mutex | 互斥锁 | CAS + futex(runtime.semacquire) |
sync.RWMutex | 读写锁 | Mutex + 原子计数器 |
sync.WaitGroup | 等待组 | 原子操作 + 信号量 |
sync.Once | 单次执行 | 原子操作 + Mutex |
sync.Cond | 条件变量 | runtime.semacquire/semrelease |
sync.Map | 并发安全 Map | 读写分离 + 原子指针 |
sync.Pool | 对象池 | 每 P 私有缓存 + 共享池 |
8. 死锁
8.1 四个必要条件
- 互斥:资源不能共享
- 持有并等待:持有资源的同时等待其他资源
- 不可抢占:不能强行抢走已分配的资源
- 循环等待:形成等待环
破坏任一即可预防死锁。
8.2 实践:统一锁顺序
go
// ❌ 危险:不同顺序加锁
// goroutine 1: mu1.Lock(); mu2.Lock()
// goroutine 2: mu2.Lock(); mu1.Lock()
// ✅ 安全:统一锁顺序
// 总是先 mu1 后 mu28.3 检测
bash
# Go: 检测 goroutine 阻塞状态
curl http://localhost:6060/debug/pprof/goroutine?debug=2
# Linux: 检测线程死锁
gdb -p <pid>
(gdb) info threads
(gdb) thread apply all bt9. Lock-free 编程
当锁成为性能瓶颈时,无锁数据结构用 CAS 原子操作替代互斥锁:
go
// Lock-free Stack(Treiber Stack)— 仅用 CAS 实现并发安全的入栈出栈
type LockFreeStack struct {
head unsafe.Pointer // *node
}
type node struct {
value int
next unsafe.Pointer
}
func (s *LockFreeStack) Push(v int) {
n := &node{value: v}
for {
oldHead := atomic.LoadPointer(&s.head)
n.next = oldHead
// CAS: 如果 head 还是 oldHead,就改为 n;否则重试
if atomic.CompareAndSwapPointer(&s.head, oldHead, unsafe.Pointer(n)) {
return
}
}
}
func (s *LockFreeStack) Pop() (int, bool) {
for {
oldHead := atomic.LoadPointer(&s.head)
if oldHead == nil {
return 0, false
}
newHead := (*node)(oldHead).next
if atomic.CompareAndSwapPointer(&s.head, oldHead, newHead) {
return (*node)(oldHead).value, true
}
}
}ABA 问题
CAS 的经典陷阱:一个值从 A 变成 B 再变回 A,CAS 检测不到变化。在无锁栈中表现为:
线程 1: 读到 head=A, A.next=B,准备 CAS(head, A→B)
线程 2: 弹出 A(head=B),弹出 C,又把 A 压回(head=A)
此时栈:A→D→E(A 的新 next 是 D,不是 B 了!)
线程 1: CAS(head, A, B) 成功!但 B 已经不是栈中的正确节点
→ 栈结构被破坏解决方案:
- 带 tag 的指针:CAS 同时检查指针值 + 版本号(Go 用
atomic.Pointer+ 独立计数器不完美) - Hazard Pointer:延迟释放,保证不会有节点被重用
- Epoch-based Reclamation:RCU 风格,分代回收
- 简单场景:
sync.Pool或用sync.Mutex(如果锁不是瓶颈就别无锁化)
10. Go / Java / C++ 并发原语横向对比
| 原语 | Go | Java | C++ (C++11) |
|---|---|---|---|
| 互斥锁 | sync.Mutex | ReentrantLock / synchronized | std::mutex |
| 读写锁 | sync.RWMutex | ReadWriteLock | std::shared_mutex |
| 条件变量 | sync.Cond | Condition | std::condition_variable |
| 原子操作 | sync/atomic | AtomicInteger / VarHandle | std::atomic<T> |
| 信号量 | runtime.semacquire (内部) | Semaphore | std::counting_semaphore (C++20) |
| 一次执行 | sync.Once | static {} / DCL | std::call_once |
| 线程安全容器 | sync.Map | ConcurrentHashMap | 无标准(需第三方) |
| 对象池 | sync.Pool | ThreadLocal + Pool | 无标准 |
| 通道/队列 | chan (语言内置) | BlockingQueue | 无标准(需手写) |
| 协程/轻量线程 | goroutine (语言内置) | Virtual Thread (Java 21) | std::jthread (重量级) |
锁实现深度对比
mermaid
flowchart TB
subgraph GoMutex["Go sync.Mutex"]
G1["CAS 快速路径(无竞争)"]
G2["自旋等待(短暂竞争)"]
G3["runtime.semacquire(长期等待)"]
G1 --> G2 --> G3
end
subgraph JavaLock["Java ReentrantLock"]
J1["CAS 快速路径"]
J2["自旋(AdaptiveSpinning)"]
J3["LockSupport.park()(OS 挂起)"]
J1 --> J2 --> J3
end
subgraph CppMutex["C++ std::mutex"]
C1["futex CAS 快速路径"]
C2["futex_wait(直接内核态)"]
C1 --> C2
end| 维度 | Go Mutex | Java ReentrantLock | C++ std::mutex |
|---|---|---|---|
| 可重入 | ❌ (死锁) | ✅ | ❌ (UB) |
| 公平性 | 饥饿模式(1ms 后切换) | 可选公平/非公平 | 不保证 |
| 自旋策略 | 有限自旋(4次) | 自适应自旋 | 无自旋 |
| 锁升级 | 无 | 偏向锁→轻量锁→重量锁 | 无 |
| 死锁检测 | go vet + pprof | JStack + JConsole | ThreadSanitizer |
Go Mutex 的饥饿模式:当一个 goroutine 等待锁超过 1ms 时,Mutex 切换到饥饿模式——新来的 goroutine 不再自旋,直接排队。这防止了"后来者总是抢到锁"的不公平现象。
11. Lock-free vs Lock-based 性能分析
性能基准数据
测试环境:8 核 CPU,Go 1.22
操作:并发递增计数器 1000 万次
┌─────────────────────────────────────────────────────┐
│ 并发度 │ sync.Mutex │ atomic.Add │ 无锁队列(CAS) │
├───────────┼────────────┼────────────┼───────────────┤
│ 1 线程 │ 45 ns/op │ 8 ns/op │ 12 ns/op │
│ 2 线程 │ 95 ns/op │ 25 ns/op │ 35 ns/op │
│ 4 线程 │ 180 ns/op │ 55 ns/op │ 80 ns/op │
│ 8 线程 │ 350 ns/op │ 120 ns/op │ 150 ns/op │
│ 16 线程 │ 500 ns/op │ 200 ns/op │ 280 ns/op │
└─────────────────────────────────────────────────────┘
结论:
- 低竞争(1-2 线程):Mutex 开销可接受
- 高竞争(8+ 线程):atomic 比 Mutex 快 3-4x
- CAS 无锁:介于两者之间(重试开销)何时使用无锁
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 简单计数器 | atomic.AddInt64 | 最简单最快 |
| 读多写少的配置 | atomic.Value | 无锁读,写时替换 |
| 高并发队列 | chan 或 lock-free queue | chan 内部已优化 |
| 复杂临界区(>100ns) | sync.Mutex | CAS 重试浪费 CPU |
| 读写比 > 10:1 | sync.RWMutex | 读锁并行 |
经验法则:如果临界区执行时间 < 100ns,考虑无锁;如果 > 1μs,用 Mutex 更好(CAS 失败重试的 CPU 浪费超过了上下文切换开销)。
12. 生产环境死锁排查实战
排查步骤
bash
# 第 1 步:发现问题(服务无响应,但进程还在)
curl http://localhost:8080/health # 超时无响应
# 第 2 步:获取 goroutine dump
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutine.txt
# 第 3 步:分析 dump(关键信息)
grep -A 5 "semacquire" goroutine.txt
# 大量 goroutine 阻塞在同一个锁上 → 死锁或锁竞争
# 第 4 步:找到持锁者
grep -B 10 "sync.Mutex.Lock" goroutine.txt
# 查看哪个 goroutine 持有锁但在等待其他资源典型死锁模式与修复
go
// ❌ 模式 1: 锁顺序不一致
func transfer(from, to *Account, amount int) {
from.mu.Lock()
defer from.mu.Unlock()
to.mu.Lock() // 如果另一个 goroutine 反向转账 → 死锁!
defer to.mu.Unlock()
// ...
}
// ✅ 修复: 统一锁顺序(按 ID 排序)
func transfer(from, to *Account, amount int) {
first, second := from, to
if from.ID > to.ID {
first, second = to, from
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
// ...
}
// ❌ 模式 2: 锁内发送 channel(channel 满时阻塞 → 持锁等待)
func (s *Service) Process() {
s.mu.Lock()
defer s.mu.Unlock()
s.ch <- data // 如果 ch 满了,阻塞在这里,锁不释放!
}
// ✅ 修复: 锁外发送
func (s *Service) Process() {
s.mu.Lock()
data := s.prepareData()
s.mu.Unlock() // 先释放锁
s.ch <- data // 再发送
}Go 死锁检测工具
bash
# 编译时检测(有限)
go vet ./...
# 运行时检测(runtime 内置,只检测全部 goroutine 阻塞的情况)
# "fatal error: all goroutines are asleep - deadlock!"
# 最佳实践:pprof + 超时检测
# 给所有锁操作加超时,超时后打印 goroutine stack
func lockWithTimeout(mu *sync.Mutex, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
mu.Lock()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
// 打印当前所有 goroutine 状态
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
log.Printf("DEADLOCK DETECTED:\n%s", buf[:n])
return false
}
}12.1 锁竞争与假死排查 Runbook
有些线上问题不是严格意义上的死锁,而是锁竞争、goroutine 堆积、事件循环阻塞导致看起来像“服务卡死”。
bash
# 1. 看 goroutine 总数
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=1 | head -20
# 2. 看 CPU 热点,是否大量消耗在锁/原子操作
curl -o cpu.prof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
go tool pprof -top cpu.prof
# 3. 看 mutex profile(Go 1.8+)
curl -o mutex.prof http://127.0.0.1:6060/debug/pprof/mutex
go tool pprof -top mutex.prof
# 4. 看 block profile
curl -o block.prof http://127.0.0.1:6060/debug/pprof/block
go tool pprof -top block.prof| 现象 | 常见根因 | 定位重点 |
|---|---|---|
sync.Mutex.Lock 很高 | 热点全局锁、临界区过大 | 看谁持锁最久 |
runtime.semacquire 很高 | 大量 goroutine 在等锁/WaitGroup/Cond | 看共享资源是不是过于集中 |
sync.(*RWMutex).RLock 很高但写很少 | 读锁热点 + cache line 竞争 | 看是否可以 copy-on-write / atomic.Value |
atomic.* 很高 | 热点原子变量争用 | 按 worker 分片计数 |
chan send/recv 堆积 | channel 当队列用但消费跟不上 | 看 buffer 大小、消费者数、上游限流 |
12.2 从硬件到代码的优化顺序
text
先问 4 个问题:
1. 是锁本身慢,还是临界区太大?
2. 是真正互斥等待,还是 false sharing / cache ping-pong?
3. 是共享状态设计有问题,还是锁类型选错?
4. 是局部热点,还是全局架构把所有请求都汇聚到一个点?| 层次 | 优先动作 | 示例 |
|---|---|---|
| 数据结构 | 降低共享 | 单全局 map → 分片 map |
| 算法 | 减少写热点 | 全局计数器 → per-P / per-worker 局部累加 |
| 锁粒度 | 缩小临界区 | 锁内 I/O 移出 |
| 原语选型 | 合理换工具 | 配置热读用 atomic.Value,不是一把大锁 |
| 架构 | 消除中心点 | 单 worker 串行 → 多分区并行 |
并发优化的核心不是"把 Mutex 换成 CAS",而是减少共享、减少争用、让数据更贴近执行它的 CPU。
13. Memory Order 与 Go sync/atomic 的对应关系
13.1 为什么需要 Memory Order?
现代 CPU 和编译器会对指令进行重排序以提高性能。在单线程中这不可见,但在多线程中可能导致一个线程看到另一个线程的操作顺序与代码顺序不同:
text
// 线程 1 // 线程 2
x = 42 if (ready) {
ready = true print(x) // 可能打印 0!
}
原因: CPU/编译器可能将 "ready = true" 重排到 "x = 42" 之前13.2 C++ Memory Order 六级模型
┌─────────────────────────────────────────────────────────────┐
│ 最弱 最强 │
│ │
│ relaxed → consume → acquire → release → acq_rel → seq_cst │
│ │
│ 无序保证 依赖链 读屏障 写屏障 读写屏障 全序 │
└─────────────────────────────────────────────────────────────┘| Memory Order | 含义 | 典型用途 |
|---|---|---|
relaxed | 只保证原子性,不保证顺序 | 计数器(不关心其他变量的可见性) |
acquire | 本操作之后的读写不能重排到本操作之前 | 读锁、读 flag |
release | 本操作之前的读写不能重排到本操作之后 | 写锁、写 flag |
acq_rel | 同时具有 acquire 和 release 语义 | CAS 操作 |
seq_cst | 全局顺序一致(最强,最慢) | 默认,最安全 |
13.3 Go sync/atomic 的内存模型
Go 的 sync/atomic 包不暴露 memory order 选择——所有原子操作都提供 sequentially consistent(seq_cst) 语义:
go
// Go 的 atomic 操作 = C++ 的 memory_order_seq_cst
atomic.StoreInt64(&x, 42) // 等价于 x.store(42, seq_cst)
v := atomic.LoadInt64(&x) // 等价于 x.load(seq_cst)
atomic.AddInt64(&x, 1) // 等价于 x.fetch_add(1, seq_cst)
atomic.CompareAndSwapInt64(&x, old, new) // 等价于 x.compare_exchange_strong(old, new, seq_cst)Go 为什么不提供 relaxed/acquire/release?
| 原因 | 说明 |
|---|---|
| 简单性 | Go 的设计哲学是"少即是多",避免程序员犯错 |
| 安全性 | relaxed 语义极易用错,即使专家也经常出 bug |
| 性能足够 | x86 上 seq_cst 和 acquire/release 差异很小(x86 天然提供 TSO) |
| 编译器优化 | Go 编译器可以在确认安全时内部降级为更弱的 order |
13.4 跨语言对比
| 语言 | 原子操作 API | Memory Order 选择 | 默认语义 |
|---|---|---|---|
| Go | sync/atomic | ❌ 不可选 | seq_cst |
| C++11 | std::atomic<T> | ✅ 6 种可选 | seq_cst |
| Rust | std::sync::atomic | ✅ 5 种可选 | 必须显式指定 |
| Java | volatile / AtomicXxx | ❌ 不可选 | volatile = acquire/release, Atomic = seq_cst |
| C11 | _Atomic / atomic_* | ✅ 6 种可选 | seq_cst |
13.5 Go 的 Happens-Before 规则
Go Memory Model 定义了哪些操作之间有 happens-before 关系:
| 操作 | Happens-Before 保证 |
|---|---|
go f() | goroutine 创建 happens-before f() 开始执行 |
ch <- v | 发送 happens-before 对应的接收完成 |
close(ch) | close happens-before 从 closed channel 收到零值 |
sync.Mutex.Unlock() | Unlock happens-before 下一次 Lock 返回 |
sync.Once.Do(f) | f() 的执行 happens-before 任何 Do 返回 |
atomic.Store | Store happens-before 后续的 Load 看到该值 |
go
// 正确的 flag 模式(Go 风格)
var data int
var ready atomic.Bool
// goroutine 1
data = 42
ready.Store(true) // seq_cst: data=42 对其他 goroutine 可见
// goroutine 2
if ready.Load() { // seq_cst: 如果看到 true,则 data=42 一定可见
fmt.Println(data) // 保证打印 42
}13.6 x86 vs ARM 的差异
| 架构 | 内存模型 | 对 Go 的影响 |
|---|---|---|
| x86/x64 | TSO (Total Store Order) | 天然提供 acquire/release,seq_cst 几乎免费 |
| ARM/ARM64 | 弱序 | 需要显式 memory barrier 指令(dmb/dsb) |
| RISC-V | 弱序 (RVWMO) | 需要 fence 指令 |
text
x86 上:
atomic.Store → MOV 指令(天然有 release 语义)
atomic.Load → MOV 指令(天然有 acquire 语义)
→ 几乎零额外开销
ARM64 上:
atomic.Store → STLR 指令(store-release)
atomic.Load → LDAR 指令(load-acquire)
→ 有额外的 barrier 开销(~几ns)实际影响:在 ARM 服务器(如 AWS Graviton)上,高频原子操作的开销比 x86 略高,但通常不是瓶颈。
13.7 实战建议
| 场景 | Go 推荐方案 | 说明 |
|---|---|---|
| 简单计数器 | atomic.AddInt64 | 无需锁 |
| 读多写少的配置 | atomic.Value | Store 替换整个值,Load 无锁 |
| flag/状态标记 | atomic.Bool (Go 1.19+) | 替代 atomic.LoadInt32 |
| 复杂状态 | sync.Mutex | 不要用多个 atomic 模拟锁 |
| 发布-订阅模式 | channel | Go 的 channel 自带 happens-before |
核心原则:Go 程序员不需要手动选择 memory order。如果你发现需要 relaxed 语义来优化性能,说明你可能应该重新设计数据结构,而不是降低安全性。
14. 锁粗化与锁消除 — 编译器和 JIT 的魔法
锁的优化不只在代码层面,现代编译器/JIT 在运行时还能做两种关键的优化。
14.1 锁消除(Lock Elimination)
当编译器/JIT 通过逃逸分析确认某个对象不会被多个线程共享时,可以彻底删除加锁/解锁操作。
java
// Java JIT 的锁消除示例
public String concat(String a, String b) {
// StringBuffer 内部用 synchronized 保护
// 但 sb 是局部变量,不会被其他线程访问 → 逃逸分析确认"不逃逸"
StringBuffer sb = new StringBuffer();
sb.append(a);
sb.append(b);
return sb.toString();
}
// JIT 优化后:synchronized 被完全消除 → 等效裸 append
// Go 编译器目前不做锁消除(Go 的逃逸分析主要用于栈/堆分配决策)
// 但在 C++/Rust 中有类似的优化(未使用锁可以 inline 消除掉)| 语言 | 逃逸分析能力 | 锁消除 |
|---|---|---|
| Java (HotSpot) | 对象逃逸分析(Escape Analysis) | ✅ 自动 |
| Go | 变量逃逸分析(决定栈/堆) | ❌ 不做(Go 不鼓励隐式消除行为) |
| C++/Rust | 编译时静态分析 | ✅ 可被死代码消除间接移除 |
| .NET JIT | 对象逃逸分析 | ✅ 自动 |
Go 的设计哲学:如果不需要锁就不要加锁,不依赖编译器来"消除"。所以 Go 代码中看到用
sync.Mutex保护的数据,一定是可能有并发的——这是 Go 的可读性选择。
14.2 锁粗化(Lock Coarsening)
当编译器发现同一个锁在循环内被频繁获取/释放,会把多次加锁合并成一次:
java
// 优化前:每次循环都加锁
for (int i = 0; i < 1000; i++) {
synchronized (lock) {
counter++;
}
}
// → 1000 次 Lock + 1000 次 Unlock
// JIT 锁粗化后:
synchronized (lock) {
for (int i = 0; i < 1000; i++) {
counter++;
}
}
// → 1 次 Lock + 1 次 Unlockmermaid
sequenceDiagram
participant T as 线程
participant L as 锁
Note over T,L: 优化前: 1000 次加锁/解锁
loop 1000 次
T->>L: Lock
T->>T: counter++
T->>L: Unlock
end
Note over T,L: 优化后: 锁粗化 → 1 次
T->>L: Lock(一次性)
T->>T: counter++ × 1000
T->>L: Unlock锁粗化的副作用:
| 问题 | 说明 |
|---|---|
| 降低并发度 | 原本每次循环只锁几个指令,现在锁住 1000 次迭代 → 其他线程等更久 |
| 增加停顿 | GC 安全点(safepoint)在锁外部才能执行 → 长持锁延迟 GC |
| 不适合 I/O 或等待 | 如果循环内有 RPC/DB 调用,锁粗化会让所有线程排队等待 I/O |
| JIT 可能误判 | 编译器很难理解"业务语义",有时会把不该粗化的粗化了 |
所以 JIT 的策略是"试探性粗化":先做锁粗化,运行时监控——如果发现其他线程等待这个锁的时间显著增加,就"反粗化"(去掉合并)。这叫自适应优化。
14.3 对比:编译器锁优化 vs 人工优化
text
编译器能做的 程序员必须自己做的
─────────────────────────────────────────────────────
消除不必要的锁(逃逸分析) 减少临界区范围
合并连续的加锁/解锁(锁粗化) 把 I/O、RPC 移出临界区
自适应调整(反粗化、偏向锁) 选择合适的数据结构减少锁需求
用分片/无锁结构替代全局锁一个常见错误:有些程序员为了"减少锁开销",把大量业务逻辑塞进一个 synchronized 块——这是人工的"锁粗化",但效果适得其反。锁粗化是优化"频繁加锁/解锁"的开销,不是让你把临界区无限扩大。
15. LongAdder — 解决 CAS 总线风暴的分段计数
15.1 问题:高并发 CAS 的 Cache Line 乒乓
text
100 个线程同时对一个 AtomicLong 执行 incrementAndGet():
线程 1: CAS(old=5, new=6) → 成功 → 修改缓存行
→ 其他 99 个核的缓存行全部失效!
线程 2: CAS(old=6, new=7) → 重新加载缓存行 → 成功 → 其他 99 个核再次失效
线程 3: CAS(old=7, new=8) → 重新加载 → 失败(被线程 2 抢先)→ 自旋重试
...
每次 CAS 成功都触发一次"缓存行失效广播"到所有核 → MESI 协议下的缓存一致性流量爆炸
→ 这种"惊群效应"叫 **Bus Storm(总线风暴)** 或 **Cache Line Ping-Pong**
→ 100 个线程 + 1 个原子变量 = 99% 的 CPU 时间花在缓存一致性协议上,而非实际计算15.2 LongAdder 的分段 CAS 设计
LongAdder 的核心思想:把热点变量拆成多个 Cell,线程随机选一个 Cell 累加,读取时对所有 Cell 求和。
java
// LongAdder 内部结构(简化)
class LongAdder {
// 基础值(低竞争时直接用这个)
volatile long base;
// 高竞争时启用的 Cell 数组
volatile Cell[] cells;
static class Cell {
volatile long value; // 每个 Cell 独立累加
// ⚠️ @Contended 注解 → JVM 自动 padding 到独立 cache line
}
}
// add 的核心流程
void add(long x) {
Cell[] cs = cells;
if (cs == null) {
// 低竞争路径:直接 CAS base
if (casBase(base, base + x)) return;
}
// 高竞争路径:hash 到某个 Cell
int m = cs.length - 1;
int idx = getProbe() & m; // 线程私有的随机 probe 值
Cell c = cs[idx];
if (c != null && c.cas(c.value, c.value + x))
return; // ✅ Cell 内 CAS 成功
// 冲突太多 → 扩容 Cell 数组(以 2 倍增长)
longAccumulate(x, null, false);
}
// sum:遍历所有 Cell 求和
long sum() {
long sum = base;
Cell[] cs = cells;
for (Cell c : cs)
sum += c.value;
return sum;
}mermaid
flowchart TB
subgraph AtomicLong["AtomicLong — 单点瓶颈"]
direction LR
A1["线程 1"] --> AV["volatile long value"]
A2["线程 2"] --> AV
A3["线程 N"] --> AV
AV --> AISSUE["❌ CAS 总线风暴<br/>所有核争一个 cache line"]
end
subgraph LongAdder["LongAdder — 分段累加"]
direction LR
B1["线程 1"] --> C1["Cell[0]<br/>@Contended padding"]
B2["线程 2"] --> C2["Cell[1]<br/>@Contended padding"]
B3["线程 3"] --> C3["Cell[2]<br/>@Contended padding"]
B4["线程 4"] --> C1
C1 --> SUM["sum() = Σ Cell[i].value"]
C2 --> SUM
C3 --> SUM
end
style AISSUE fill:#f44336,color:#fff
style SUM fill:#4CAF50,color:#fff15.3 Cache Line Padding — 防止"伪共享"
java
// 不加 padding:两个 Cell 可能在同一个 cache line
class Cell {
volatile long value;
}
// Cell[0].value 和 Cell[1].value 地址相邻 → 同一条 cache line
// → 修改 Cell[0] 会让 Cell[1] 所在的 cache line 也失效 → 退化为全局竞争!
// 加 padding:每个 Cell 独占 cache line
@sun.misc.Contended // JDK 8+ 注解,或手写 padding
class Cell {
volatile long value;
// JVM 自动在前后填充足够的字节,使每个 Cell 独占 64B cache line
}text
不加 padding: 加 padding 后:
┌─────────────┬─────────────┐ ┌──────────────────────┬──────────────────────┐
│ Cell[0] │ Cell[1] │ │ Cell[0] │ Cell[1] │
│ value (8B) │ value (8B) │ │ value (8B) + 56B pad │ value (8B) + 56B pad │
│ 同一条 Cache Line │ │ Cache Line 0 │ Cache Line 1 │
└─────────────┴─────────────┘ └──────────────────────┴──────────────────────┘
修改 Cell[0] → Cell[1] 失效 修改 Cell[0] → Cell[1] 不受影响 ✅15.4 Go 中的等价实现
go
// Go 没有内置 LongAdder,但可以轻松实现
type LongAdder struct {
cells []*cell
base int64
mu sync.Mutex // 仅用于 cells 初始化/扩容
}
type cell struct {
_ [56]byte // padding 到独立 cache line
val int64
_ [56]byte
}
func (a *LongAdder) Add(x int64) {
// 先尝试 CAS base(低竞争路径)
if atomic.CompareAndSwapInt64(&a.base, a.base, a.base+x) {
return
}
// 高竞争路径:找自己的 Cell
a.mu.Lock()
if a.cells == nil {
a.cells = make([]*cell, 4)
for i := range a.cells {
a.cells[i] = &cell{}
}
}
cells := a.cells
a.mu.Unlock()
idx := hashGoroutineID() % len(cells)
atomic.AddInt64(&cells[idx].val, x)
}
func (a *LongAdder) Sum() int64 {
sum := atomic.LoadInt64(&a.base)
a.mu.Lock()
for _, c := range a.cells {
sum += atomic.LoadInt64(&c.val)
}
a.mu.Unlock()
return sum
}15.5 性能对比
测试环境:8 核 CPU
操作:并发累加 1000 万次
┌─────────────┬──────────┬──────────┬──────────┬──────────┐
│ 方案 │ 1 线程 │ 4 线程 │ 8 线程 │ 16 线程 │
├─────────────┼──────────┼──────────┼──────────┼──────────┤
│ AtomicLong │ 8 ns │ 55 ns │ 120 ns │ 200 ns │
│ LongAdder │ 12 ns │ 18 ns │ 22 ns │ 25 ns │
│ sync.Mutex │ 45 ns │ 180 ns │ 350 ns │ 500 ns │
└─────────────┴──────────┴──────────┴──────────┴──────────┘
结论:
- 低并发(< 4 线程):AtomicLong 更好(无 Cell 数组开销)
- 高并发(8+ 线程):LongAdder 比 AtomicLong 快 5-8 倍
- 代价:sum() 不是 O(1) → 读操作稍慢;内存占用更大16. 版本号乐观锁的写放大与降级策略
16.1 问题:高冲突下乐观锁的"重试风暴"
sql
-- 基于 version 的乐观锁
UPDATE stock SET count = count - 1, version = version + 1
WHERE product_id = 123 AND version = 5;
-- 如果受影响行数 = 0 → version 已变 → 回滚重试text
正常情况(低冲突):
10 个并发请求 → 1 个成功,9 个重试 1 次 → 总执行 19 次 SQL
高冲突(秒杀):
1000 个并发请求 → 1 成功,999 重试 → 再 1 成功,998 重试 → ...
→ 总执行次数 ≈ O(N²) → 数据库 CPU 爆满,全是无效重试!
这就是版本号乐观锁的"写放大"——冲突率越高,重试次数指数增长。16.2 滑动窗口乐观锁 — 批量版本号
传统乐观锁:条件 = (version == 5)
滑动窗口:条件 = (version BETWEEN 3 AND 5)
原理:
不是"必须等于我看到的版本",而是"在近几次变更内都接受"
这降低了冲突概率,但引入了"允许后发覆盖先发"的可能
→ 适用于"允许一定程度的重排序"的场景(如点赞数、社交动态)sql
-- 滑动窗口乐观锁(允许 3 个版本的偏差)
UPDATE stock SET count = count - 1, version = version + 1
WHERE product_id = 123 AND version >= ? - 3;
-- 代价:可能覆盖别人的更新(version 5→6 和 4→6 都能成功,后者覆盖前者)
-- 适用:非强一致场景,如缓存更新、非关键计数16.3 乐观锁 + 悲观锁降级 — 动态切换
go
type AdaptiveLock struct {
conflictRate float64 // 滑动窗口内的冲突率
mode string // "optimistic" | "pessimistic"
mu sync.Mutex
}
func (l *AdaptiveLock) Update(key string, fn func() error) error {
l.mu.Lock()
conflictRate := l.conflictRate
l.mu.Unlock()
if conflictRate > 0.3 {
// 冲突率 > 30% → 降级为悲观锁
return l.pessimisticUpdate(key, fn)
}
return l.optimisticUpdate(key, fn)
}
func (l *AdaptiveLock) optimisticUpdate(key string, fn func() error) error {
for retry := 0; retry < 3; retry++ {
version := getVersion(key)
fn()
if casVersion(key, version, version+1) {
l.recordConflict(false)
return nil
}
l.recordConflict(true)
}
// 3 次乐观重试失败 → 降级到悲观锁
return l.pessimisticUpdate(key, fn)
}
func (l *AdaptiveLock) recordConflict(conflict bool) {
l.mu.Lock()
defer l.mu.Unlock()
// 滑动窗口:最近 100 次操作中冲突的比例
if conflict {
l.conflictRate = l.conflictRate*0.99 + 0.01
} else {
l.conflictRate = l.conflictRate*0.99
}
}mermaid
flowchart TB
A["请求到达"] --> B{"冲突率 > 30%?"}
B -->|"否"| C["乐观锁 CAS"]
B -->|"是"| D["悲观锁(分布式锁)"]
C --> E{"CAS 成功?"}
E -->|"是"| F["✅ 返回成功"]
E -->|"否"| G{"重试 < 3 次?"}
G -->|"是"| C
G -->|"否"| D
D --> F16.4 各方案对比
| 方案 | 冲突低时 | 冲突高时 | 一致性 | 复杂度 |
|---|---|---|---|---|
| 纯乐观锁 | ⭐⭐⭐⭐⭐ | ⭐(重试风暴) | ✅ | 低 |
| 纯悲观锁 | ⭐⭐(锁开销) | ⭐⭐⭐(串行化) | ✅ | 低 |
| 滑动窗口乐观锁 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐(允许部分覆盖) | ⚠️ 可能覆盖 | 中 |
| 乐观+悲观降级 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐(自动切换) | ✅ | 高 |
| 分段锁 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | 高 |
17. Epoch-based Reclamation — 无锁结构的安全内存回收
无锁数据结构的最大难点不是 CAS,而是删除后的内存回收。一个线程通过 CAS 删除了节点,但另一个线程可能还持有指向它的指针——此时释放节点会导致 use-after-free。
17.1 问题的本质
go
// 无锁链表删除的经典问题
// 线程 A: 删除节点 B(CAS: A.next → C)
// 线程 B: 正在读取 B → B.next(还没读到 C)
//
// 时间线:
// t1: 线程 B 读到 head=A, 准备读取 A.next
// t2: 线程 A CAS 删除 B(A.next = C)
// t3: 线程 A free(B) ← B 的内存被释放/复用!
// t4: 线程 B 读到 A.next = B(指针),访问 B.value → 💥 use-after-free
// 本质矛盾:
// CAS 只保证"指针的原子修改",
// 不保证"没有任何人还在读旧指针指向的内存"17.2 三种内存回收方案对比
| 方案 | 原理 | 等待开销 | 内存开销 | 适用场景 |
|---|---|---|---|---|
| Hazard Pointer | 读者声明正在使用的指针,删除者检查后安全释放 | 低(声明开销小) | 中(每线程 HP 列表) | 读多写少,无 GC |
| Epoch-based Reclamation | 分三代(当前/前一代/前两代),等所有读者离开当前 epoch 后批量回收 | 极低 | 低 | 读多写少,批量回收 |
| RCU (Read-Copy-Update) | 读无锁,写复制新版本后等待宽限期(grace period) | 零(读侧) | 取决于数据大小 | Linux 内核,大量读极少写 |
17.3 Epoch-based Reclamation 详解
text
核心思想:时间分片。所有线程处于三个 epoch 之一。
只有当所有线程都离开了某个 epoch,该 epoch 的删除列表才能安全回收。
数据结构:
- globalEpoch: 全局 epoch 计数器(0, 1, 2 循环)
- threadsInEpoch[3]: 每个 epoch 当前的活动线程数
- retireLists[3]: 每个 epoch 的待回收对象列表
线程的生命周期:
1. 进入临界区:记录自己的 epoch = globalEpoch
threadsInEpoch[epoch]++
2. 执行操作(读/写/删除)
3. 退出临界区:threadsInEpoch[epoch]--
全局回收:
定期检查:
- 当前 epoch-2(即"最老的 epoch")
- 如果 threadsInEpoch[epoch-2] == 0 → 该 epoch 的 retireList 全部可以安全释放
- 原因:任何线程进入临界区时都只记录当前 epoch 或 epoch-1
不可能有线程还在 epoch-2 中 → 指向这些节点的指针一定已不再使用
为什么用 3 个 epoch?
如果只用 2 个 epoch:
- epoch 0 → epoch 1 时,可能有线程还在 epoch 0 中
- 等到 epoch 0 无人 → 释放 epoch 0 的删除列表
- 但此时又可能有线程刚进入 epoch 1,指向 epoch 0 中刚被删除的节点!
→ 必须等到 epoch 2 才能安全释放 epoch 0 的对象
→ 3 个 epoch 是最小安全数(双重缓冲)mermaid
flowchart TB
subgraph Timeline["时间线"]
direction LR
E0["Epoch 0<br/>(当前)"]
E1["Epoch 1<br/>(上一代)"]
E2["Epoch 2<br/>(上两代)"]
end
subgraph Threads["线程状态"]
T1["线程 1: 在 Epoch 0 中"]
T2["线程 2: 在 Epoch 0 中"]
T3["线程 3: 刚进入 Epoch 1"]
end
subgraph Reclaim["回收判断"]
R1["Epoch 2 无人 → retireList[2] 安全释放 ✅"]
R2["Epoch 1 有人 → retireList[1] 不能释放 ❌"]
R3["Epoch 0 有人 → retireList[0] 不能释放 ❌"]
end
E0 --> T1
E0 --> T2
E1 --> T3
E2 --> R1
E1 --> R2
E0 --> R3go
// Go 版 Epoch-based Reclamation
const epochCount = 3
type EBR struct {
globalEpoch atomic.Uint64
threadsInEpoch [epochCount]atomic.Int64
retireLists [epochCount][]interface{}
mu sync.Mutex
}
// 进入临界区:记录当前 epoch
func (e *EBR) Enter() uint64 {
epoch := e.globalEpoch.Load() % epochCount
e.threadsInEpoch[epoch].Add(1)
return epoch
}
// 退出临界区
func (e *EBR) Exit(epoch uint64) {
e.threadsInEpoch[epoch].Add(-1)
}
// 标记删除:将对象放入当前 epoch 的 retireList
func (e *EBR) Retire(obj interface{}) {
e.mu.Lock()
epoch := e.globalEpoch.Load() % epochCount
e.retireLists[epoch] = append(e.retireLists[epoch], obj)
e.mu.Unlock()
}
// 尝试推进 epoch 并回收
func (e *EBR) TryAdvance() {
// 检查 epoch-2 是否安全
oldest := (e.globalEpoch.Load() + epochCount - 2) % epochCount
if e.threadsInEpoch[oldest].Load() == 0 {
// 安全:回收 epoch-2 的所有对象
e.mu.Lock()
for _, obj := range e.retireLists[oldest] {
// 安全释放 obj(实际项目中调用 free 或放回内存池)
_ = obj
}
e.retireLists[oldest] = nil
e.mu.Unlock()
// 推进全局 epoch
e.globalEpoch.Add(1)
}
}
// 定期调用(或每次 retire 后调用)
func (e *EBR) ReclaimLoop() {
ticker := time.NewTicker(10 * time.Millisecond)
for range ticker.C {
e.TryAdvance()
}
}17.4 三个 epoch 的数学安全性证明
安全条件: 当 retireList[epoch i] 被释放时,
不能有任何线程持有指向这些对象的指针。
证明:
设全局 epoch 当前为 E。
任何线程进入临界区时记录的 epoch ∈ {E, E-1}(取决于进入时机)。
考虑 retireList[E-2]:
- 这些对象在 epoch = E-2 时被标记删除
- 任何线程进入临界区记录的 epoch ∈ {E, E-1}
- E ≥ E-1 > E-2
- 因此没有任何线程记录的 epoch 为 E-2
- → 没有任何线程持有指向这些对象的指针 ✅
考虑 retireList[E-1]:
- 可能有线程在 epoch = E-1 时进入临界区(还未退出)
- 该线程可能持有指向这些对象的指针
- → 不能回收 ❌
因此,最小安全延迟 = 2 个 epoch(三重缓冲)。17.5 Go 中的实践建议
text
对于 Go 开发者:
- Go 有 GC,大多数场景不需要手动管理无锁结构的内存
- 但如果你在写 CGo 互操作的共享内存无锁结构,或极致性能场景
→ 可以使用 EBR 或 Hazard Pointer
- 对于纯 Go 代码,用 sync.Pool 回收无锁节点是最简单的方案
- 或者直接用 chan —— Go 的 chan 内部已经无锁化了(ring buffer)
对于 C++/Rust 开发者:
- 无锁结构的正确实现必须配套内存回收方案
- 推荐:folly::hazptr (Hazard Pointer), crossbeam::epoch (EBR)
- Linux 内核开发者:直接使用 RCU (rcu_read_lock/rcu_assign_pointer)
登录后即可发表评论 👇