bigywater.tech
Go · 72

sync/atomic:原子操作与 Mutex 的性能边界

“用 atomic 比 Mutex 快”只在一定条件下成立。这篇把同一个计数器在不同并发度下的耗时量出来,看 atomic、CAS 循环、Mutex 各自在哪里变慢,再用伪共享说明慢的真正原因是缓存行。

4.1 vs 7.7 ns
无竞争时 atomic.Add 与 Mutex 保护的加一
53.7 / 157.2 / 254.9 ns
8 个 goroutine 争同一计数器时的 atomic、Mutex、CAS 循环
57.1 vs 0.8 ns
8 个 goroutine 各用自己的原子计数器:相邻放置对隔开 128 字节

争的是同一个缓存行

实测 · 本机执行输出
  单位 ns/次
  atomic  G=1:    4.1  G=2:   10.5  G=4:   26.9  G=8:   53.7
  cas     G=1:    7.5  G=2:   24.1  G=4:   77.4  G=8:  254.9
  mutex   G=1:    7.7  G=2:   61.3  G=4:  185.3  G=8:  157.2

无竞争时 atomic 比 Mutex 快一点(4.1 对 7.7 ns),差别不大。8 个 goroutine 争同一个计数器,atomic 慢到 53.7 ns,Mutex 157.2 ns,CAS 循环 254.9 ns——它失败了要重试。三者慢的根源都是这一个缓存行在核之间来回传递。

下限与伪共享

实测 · 本机执行输出
  局部变量 G=1:    0.3  G=8:    0.1

每个 goroutine 先在局部变量里累加,一次性汇总,每次只要 0.1–0.3 ns。更有意思的是伪共享:8 个 goroutine 各用自己的 atomic 变量,逻辑上互不相干,放在相邻内存里却是 57.1 ns,隔开 128 字节后只要 0.8 ns。慢的不是原子指令,是缓存行。

指令层面

实测 · 本机执行输出
  相邻放置(无填充)      G=1:    4.0  G=8:   57.1
  各占 128 字节      G=1:    3.9  G=8:    0.8

arm64 上 Add 编译成 LDADDALD,CompareAndSwap 编译成 CASALD,前面各有一个对 runtime.arm64HasATOMICS 的检查。另一条分支(没有原子扩展时)我没有展开。

什么时候选哪个

实测 · 本机执行输出
  main.add:TEXT main.add(SB), LEAF|NOFRAME|ABIInternal, $0-0 ; NOP ; HINT $0 ; MOVBU runtime.arm64HasATOMICS(SB), R1 ; TBZ $0, R1, 40 ; MOVD $1, R1 ; MOVD $main.v(SB), R2 ; LDADDALD R1, (R2), R0
  main.cas:TEXT main.cas(SB), LEAF|NOFRAME|ABIInternal, $0-8 ; NOP ; ADD $1, R0, R1 ; MOVBU runtime.arm64HasATOMICS(SB), R2 ; TBZ $0, R2, 44 ; MOVD $main.v(SB), R2 ; MOVD R0, R27 ; CASALD R27, (R2), R1

计数这类单个变量用 atomic 比 Mutex 省;高并发下最好的办法是别共享——按 goroutine 分片、再汇总。包文档也建议优先用 channel 或 sync 包。以上是这台 8 线程 arm64 机器上的结论,别的核数、别的架构需要自己量。

读文档

实测 · 本机执行输出
  implementing synchronization algorithms.
  These functions require great care to be used correctly. Except for special,
  low-level applications, synchronization is better done with channels or the
  facilities of the sync package. Share memory by communicating; don't communicate
  by sharing memory.
  The swap operation, implemented by the SwapT functions, is the atomic equivalent
  of:
  old = *addr
  *addr = new
  return old
  The compare-and-swap operation, implemented by the CompareAndSwapT functions,
  is the atomic equivalent of:

sync/atomic 文档开头就提醒这些函数要极其小心,除非是低层应用,同步最好用 channel 或 sync 包。

分步演示

原子操作与 Mutex
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。基准是自己写的:2000 万次操作平均分给 G 个 goroutine,用 close(start) 同时放行,墙钟时间 ÷ 2000 万,每格 3 次取中位数,GOMAXPROCS=8;伪共享用两种结构(无填充 / [120]byte 填充)的数组;汇编来自 go build -gcflags=-S;文档来自 go doc sync/atomic
    • 没有验证:x86 上的结果、缓存行大小(这里用 128 字节填充,没有单独测 64 字节是否够)、runtime.arm64HasATOMICS 为假时的路径、Mutex 在饥饿模式下的行为、更多/更少核数时的曲线、LDADDALD 的内存序语义细节
    京ICP备2026067502号-1京公网安备11010502063487号