Go 的 sync/atomic 实战:无锁计数器、CAS 与什么时候别用它

📅 2026/7/22 17:36:47
Go 的 sync/atomic 实战:无锁计数器、CAS 与什么时候别用它
Go 的 sync/atomic 实战:无锁计数器、CAS 与什么时候别用它高并发下给一个计数器加锁,Mutex一上,压测 QPS 掉一截——锁竞争成了瓶颈。这时很多人听说「用 atomic 更快」,于是把sync/atomic拿来到处套,结果要么用错 API 数据还是错,要么把不该无锁的场景硬掰成 atomic 导致代码更难懂。这篇讲清楚 atomic 到底快在哪、CAS 怎么用、以及三个「别用 atomic」的信号。先复现一个 data race一个朴素的并发计数器:packagemainimport(fmtsync)funcmain(){varcountint64varwg sync.WaitGroupfori:0;i1000;i{wg.Add(1)gofunc(){deferwg.Done()forj:0;j1000;j{count// 并发写,data race}}()}wg.Wait()fmt.Println(count)// 期望 1000000,实际每次都不一样且偏小}go run -race main.go会直接报DATA RACE。原因和其他语言一样:count是「读-加-写」三步,不是原子操作,多个 goroutine 交叉执行就会丢更新。方案一:Mutex(正确,但有锁竞争)var(countint64mu sync.Mutex)// ...mu.Lock()countmu.Unlock()正确,但每次自增都要抢锁、释放锁。在只是「加个数」这种极短临界区里,锁的开销(可能陷入内核、goroutine 挂起唤醒)相对于「加 1」本身太重了。方案二:atomic(无锁,更快)sync/atomic用 CPU 的原子指令(如 x86 的LOCK XADD)在硬件层面保证「读-加-写」一步完成,不需要操作系统级的锁:packagemainimport(fmtsyncsync/atomic)funcmain(){varcountint64varwg sync.WaitGroupfori:0;i1000;i{wg.Add(1)gofunc(){deferwg.Done()forj:0;j1000;j{atomic.AddInt64(count,1)// 原子自增,无锁}}()}wg.Wait()fmt.Println(atomic.LoadInt64(count))// 稳定输出 1000000}关键点:读 count 也要用atomic.LoadInt64,不能直接count。直接读虽然多数平台不会崩,但在 race 检测下依然算 data race,而且不保证读到最新值。atomic 变量的读写要「全程 atomic」,混用普通读写就前功尽弃。Go 1.19 更推荐用 atomic.Int64 类型裸用atomic.AddInt64(x, 1)有个隐患:编译器不拦着你在别处对同一个变量做普通读写,一不小心就漏掉 atomic。Go 1.19 起提供了封装类型,把变量「锁死」成只能原子访问:packagemainimport(fmtsyncsync/atomic)funcmain(){varcount atomic.Int64// 类型本身保证只能原子访问varwg sync.WaitGroupfori:0;i1000;i{wg.Add(1)gofunc(){deferwg.Done()forj:0;j1000;j{count.Add(1)// 方法调用,清爽且不会漏}}()}wg.Wait()fmt.Println(count.Load())// 1000000}新代码优先用atomic.Int64/atomic.Bool/atomic.Pointer[T]这套类型,比裸函数更安全。CAS:atomic 的灵魂,不只是加减Add/Load/Store只能做简单读写。真正体现 atomic 威力的是CompareAndSwap(CAS):「如果当前值等于旧值,就改成新值,否则不改」,整个判断修改是原子的。它是实现各种无锁算法的基石。举个实用例子:一个只允许初始化一次的开关,不用锁也不用sync.Once:packagemainimport(fmtsyncsync/atomic)funcmain(){vardone atomic.Boolvarwg sync.WaitGroup initOnce:func(idint){// CAS:只有把 false 换成 true 成功的那个 goroutine 才执行初始化ifdone.CompareAndSwap(false,true){fmt.Printf(goroutine %d 执行了初始化\n,id)}}fori:0;i10;i{wg.Add(1)gofunc(idint){deferwg.Done();initOnce(id)}(i)}wg.Wait()// 无论多少 goroutine 竞争,执行了初始化 只会打印一次}CAS 常配合「循环重试」实现更复杂的无锁更新。比如给一个值做「原子地取最大值」(标准库没直接提供):// 原子地把 addr 更新为 max(当前值, val),无锁实现funcatomicMax(addr*atomic.Int64,valint64){for{old:addr.Load()ifvalold{return// 新值不更大,无需更新}// 尝试用 val 替换 old;若期间被别的 goroutine 改过,CAS 失败,重试ifaddr.CompareAndSwap(old,val){return}}}这个「读旧值 → 算新值 → CAS,失败就重试」的循环,是无锁编程最核心的套路。竞争不激烈时它几乎不重试,性能远超加锁。什么时候「别用」atomic —— 三个信号atomic 不是银弹,滥用会写出难懂又易错的代码。出现下面任一信号,老实用Mutex:信号一:要同时保证多个变量的一致性。atomic 一次只能操作一个变量。如果你需要「同时改余额和流水」并保持一致,atomic 做不到,得用锁把它们框成一个临界区:// 这种「一组变量必须一起改」的场景,atomic 无能为力,用锁mu.Lock()account.Balance-amount account.Historyappend(account.History,tx)mu.Unlock()信号二:临界区里有复杂逻辑或会阻塞。atomic 适合「一次读改写」的极短操作。如果临界区里有 map 遍历、IO、函数调用,硬用 CAS 循环会在高竞争下疯狂重试空转,反而比锁慢、还费 CPU。信号三:操作的是 map、slice 这类复合结构。atomic 只能操作整数、指针、布尔这类机器字大小的值。要并发读写 map,用sync.Mutex保护或直接上sync.Map,别想着用 atomic。一句判断:「一个字大小的值 一步读改写」用 atomic;「多个变量 / 复杂临界区 / 复合结构」用锁。小结count并发不安全,-race能直接抓出来;修复要么加锁,要么用 atomic。atomic 靠 CPU 原子指令实现无锁,极短临界区(如计数器)比 Mutex 快;但 atomic 变量必须全程用Load/Store/Add,不能混普通读写。新代码优先用atomic.Int64/atomic.Bool封装类型,比裸atomic.AddInt64(x,1)更不易漏。CAS 是 atomic 的灵魂:「比较交换」原子完成,配合重试循环能实现只初始化一次、原子取最大值等无锁逻辑。三个「别用 atomic」信号:要保证多变量一致、临界区复杂/阻塞、操作 map/slice——这些老实用锁。记忆点:atomic 守的是「一个字、一步操作」;超出这个范围,锁才是对的工具。