跳转到正文
zeno's blog
返回

Go 并发(四):无锁编程层次-从 CAS 到消除共享

专题: Go 并发

Table of contents

Open Table of contents

TL;DR

CAS 既是乐观锁也是无锁编程,不矛盾——“乐观锁”描述并发策略,“无锁”描述实现方式(不用 mutex)。比 CAS 更激进的方向是 FAA(一次成功)→ 分片(消除共享)→ RCU(读者零开销)。


CAS 是乐观锁也是无锁编程

这两个标签看的角度不同:

术语描述的维度含义
乐观锁并发策略假设不冲突,冲突了重试
无锁编程(lock-free)实现方式不用 mutex,不阻塞等待
CAS具体原语Compare-And-Swap,CPU 提供的原子指令
// CAS 循环:乐观锁的行为 + 无锁的实现
for {
    old := atomic.LoadInt64(&balance)
    new := old - 50
    if atomic.CompareAndSwapInt64(&balance, old, new) {
        break  // 成功
    }
    // 被别人改了,重试 ← 乐观锁行为
    // 但没有 mutex,没有阻塞等待 ← 无锁实现
}

“无锁”不是”没有并发控制”,而是没有 mutex、没有阻塞等待。CAS 失败了是 spin(重试),不是挂起等唤醒。

激进程度层次

mutex(悲观锁,阻塞等待)
  ↓ 不阻塞
CAS 循环(lock-free,可能重试)
  ↓ 不重试
FAA / atomic.Add(wait-free,一次成功)
  ↓ 不竞争
分片 / per-CPU(消除共享)
  ↓ 读者零开销
RCU(读者完全无感知)

1. CAS(Lock-free)— 可能重试

for {
    old := atomic.LoadInt64(&counter)
    if atomic.CompareAndSwapInt64(&counter, old, old+1) {
        break
    }
}

保证:至少一个 goroutine 在有限步内成功(lock-free)。但个别 goroutine 可能一直被抢占,理论上无限重试。

高竞争下 CAS 自旋浪费 CPU,这是它的天花板。

2. FAA / atomic.Add(Wait-free)— 一次成功

atomic.AddInt64(&counter, 1)  // 一条硬件指令,没有失败的可能

保证:每个 goroutine 都在有限步内完成(wait-free),比 lock-free 更强。

局限:只能做加减,不能做”先判断再修改”的条件操作。

3. 分片(消除共享)— 根本不竞争

type Counter struct {
    shards [8]atomic.Int64  // 按 goroutine 分片
}

func (c *Counter) Inc(id int) {
    c.shards[id%8].Add(1)  // 各写各的,零竞争
}

func (c *Counter) Total() int64 {
    var sum int64
    for i := range c.shards {
        sum += c.shards[i].Load()
    }
    return sum
}

Linux 内核的 per-CPU 计数器、Go sync.Pool 内部的 per-P 分片都是这个思路。

代价:读取总值需要聚合所有分片,有一致性延迟。适合写多读少的计数场景。

4. RCU(Read-Copy-Update)— 读者零开销

Linux 内核大量使用。读者直接读,不加锁,不 CAS,不重试,零原子操作。写者复制一份改好后原子替换指针,等所有老读者退出后回收旧数据。

Go 中的近似实现:

var config atomic.Pointer[Config]

// 读者:一次 Load,零开销
cfg := config.Load()

// 写者:复制 → 修改 → 原子替换,不影响正在读的人
newCfg := *config.Load()
newCfg.Timeout = 30
config.Store(&newCfg)

读路径只有一次指针 Load,是最极致的读优化。适合配置热更新、路由表等读远多于写的场景。

选型

场景方案
通用互斥sync.Mutex,简单可靠
简单计数器atomic.AddInt64(wait-free)
条件更新(check-then-act)CAS 循环
高竞争计数分片计数器
读多写极少的共享状态atomic.Pointer(RCU 思路)

大部分场景 sync.Mutex 就够了。无锁技术只在 热点路径 + 性能瓶颈已确认 时才值得引入,否则是用复杂度换不存在的性能问题。


分享这篇文章:

上一篇
Go 并发(五):并发模式与最佳实践
下一篇
Go 并发(三):内存模型、happens-before 与同步语义