Skip to content
Go
sync/atomic — 原子操作与无锁编程

sync/atomic — 原子操作与无锁编程

sync/atomic 提供 CPU 级别的原子操作。与 sync.Mutex 不同,原子操作通常不涉及 goroutine 阻塞/唤醒,开销低——但也只覆盖"单个值"的场景。


1. 什么是"原子操作"

“原子"意味着操作不可分割:不存在别的 goroutine 观察到中间状态的可能。

counter++ // 非原子:读 → 加 → 写 三步,并发下会丢更新

atomic.AddInt64(&counter, 1) // 原子:硬件保证整体不可分割

底层在不同架构上对应不同指令(x86 的 LOCK XADD/LOCK CMPXCHG,ARM64 的 LDADDAL/CASPAL 等),Go 帮你抹平差异。


2. 两种 API 风格:先用类型化的

2.1 Go 1.19+ 类型化原子变量(推荐)

var counter atomic.Int64
counter.Add(1)          // 返回加后的新值
n := counter.Load()
counter.Store(0)
counter.CompareAndSwap(0, 100)

var flag atomic.Bool
flag.Store(true)
if flag.CompareAndSwap(false, true) { ... } // 抢占式"只让一个做"

var ptr atomic.Pointer[Config] // 泛型指针,类型安全
ptr.Store(&Config{Port: 8080})
cfg := ptr.Load()

可用类型:atomic.Bool / Int32 / Int64 / Uint32 / Uint64 / Uintptr / Pointer[T] / Value。

Go 1.23 给整数类型补了位运算方法 And(mask) / Or(mask)(返回旧值,原子执行):

var bits atomic.Uint32
old := bits.Or(1 << 3)   // 原子置位
old = bits.And(^(1 << 3)) // 原子清位

2.2 老的函数式 API(存量代码)

var counter int64
atomic.AddInt64(&counter, 1)
val := atomic.LoadInt64(&counter) // 必须用 Load,直接 counter 读不是原子语义保证
atomic.StoreInt64(&counter, 0)
old := atomic.SwapInt64(&counter, 100)

对齐坑(仅 32 位平台):32 位平台上 int64/uint64 原子操作要求 8 字节对齐,没对齐会 panic。老写法的规避是"64 位字段放结构体最前面”。用类型化的 atomic.Int64 不用操心——该类型内部自带对齐保证(go doc 明确说明)。

类型化 API 还有额外好处:不会忘记取地址、不会把非原子读写混进来(字段类型本身就是原子的)。新项目一律用类型化。


3. CAS — 比较并交换

CAS 是无锁结构的核心原语:

// 语义:若 *addr == old,写入 new 并返回 true;否则不改、返回 false
swapped := counter.CompareAndSwap(old, new)

3.1 CAS 循环

// "读-改-写"复合更新(比如取最大值)
func updateMax(x *atomic.Int64, v int64) {
    for {
        old := x.Load()
        if v <= old {
            return
        }
        if x.CompareAndSwap(old, v) {
            return
        }
        // 有别人抢先改了,重试
    }
}

性能:高竞争下 CAS 循环会反复重试。简单计数直接用 Add(硬件单指令)远比 CAS 循环高效;只有"更新依赖旧值且无法用一条算术指令表达"时才用 CAS。

3.2 ABA 问题

Go 的 Pointer[T] CAS 只比较指针值:对象被释放又复用同地址时会误判"没变"。Go 里 GC 保证活对象地址稳定,但用 unsafe.Pointer 做无锁结构时 ABA 依然存在。真正要写无锁数据结构,优先 Add/Swap 而非裸 CAS 指针,或把"代数"塞进值里(CAS (ptr, version) 结构体的副本指针)。


4. atomic.Value — 任意类型的原子读写

var config atomic.Value

config.Store(&Config{Port: 8080})
cfg := config.Load().(*Config)

// Go 1.17+ 增补
config.CompareAndSwap(oldCfg, newCfg)
old := config.Swap(newCfg)

陷阱:

  • 首次 Store 决定了类型,之后 Store 不同类型直接 panic。
  • 首次 Load(Store 之前)返回 nil,Load().(*Config) 立即 panic——先判 nil 再断言,或用零值安全的 atomic.Pointer[T](未 Store 时 Load 返回类型化 nil 指针)。
  • 原子性只覆盖"指针值本身"。Store 之后再修改指针指向的结构体字段,读侧看不到一致状态——正确姿势永远是"构造新对象 → 整体替换"(copy-on-write)。

4.1 配置热更新的标准配方

var conf atomic.Pointer[AppConfig]

// 加载/热更新:整份替换,从不就地修改
func reload(path string) error {
    c, err := parse(path)
    if err != nil {
        return err
    }
    conf.Store(c) // 读侧要么旧要么新,绝不会读到半新半旧
    return nil
}

// 读侧
func handler(w http.ResponseWriter, r *http.Request) {
    c := conf.Load() // 一次 Load,整个请求用同一份快照
    render(w, c.Theme)
}

关键点是一致性快照:一个请求里多处读 conf.Load() 可能拿到不同版本,应该 Load 一次存局部变量贯穿使用。


5. 一次性初始化的正确姿势

// 错误写法(网上常见的"atomic.Bool 当 Once"):
var initialized atomic.Bool
func ensureInit() {
    if initialized.Load() {
        return
    }
    doExpensiveSetup()   // ← 两个 goroutine 同时到达会各自执行一遍!
    initialized.Store(true)
}

上面的代码没有“只执行一次"的保证:检查和置位之间有窗口。要么用 CAS 抢"执行权”:

// 正确写法 1:CAS 抢执行权(注意:后来者会立刻返回,可能还没初始化完!)
var started atomic.Bool
func ensureInit() {
    if started.CompareAndSwap(false, true) {
        doExpensiveSetup()
    }
    // 若需要"必须等它做完",这里还得再同步(等 Done 标志/条件变量)
}

所以工程结论是:别用原子变量造 Once,直接用现成的:

// 正确写法 2(首选):sync.OnceValue(Go 1.21+),自带 happens-before 与"等待完成"语义
var getConn = sync.OnceValue(func() *Conn { return dial() })

// 有错误返回的场景
var getConf = sync.OnceValues(func() (*Conf, error) { return load() })

sync.Once/OnceValue 的语义是"所有人阻塞到初始化完成",原子 CAS 方案给不了这个保证。


6. 无锁栈示例与其局限

type node struct {
    value int
    next  *node
}

type LockFreeStack struct {
    head atomic.Pointer[node]
}

func (s *LockFreeStack) Push(v int) {
    n := &node{value: v}
    for {
        old := s.head.Load()
        n.next = old
        if s.head.CompareAndSwap(old, n) {
            return
        }
    }
}

func (s *LockFreeStack) Pop() (int, bool) {
    for {
        old := s.head.Load()
        if old == nil {
            return 0, false
        }
        if s.head.CompareAndSwap(old, old.next) {
            return old.value, true
        }
    }
}

这个教科书版本能工作,但要清楚代价:每次 Push 都会让 head 的 next 指向旧头,CAS 失败重试;且它只保证单链表结构一致,Pop 与 Push 并发时仍可能 ABA。在 Go 里,无锁栈的收益往往不敌 Mutex + slice(临界区极短、无系统调用),除非压测证明锁竞争是瓶颈。先测量,再无锁。


7. 内存顺序:Go 比 C++ 简单

Go 的 atomic 操作按 sequential consistency(顺序一致) 理解即可,不需要指定 memory order。Go 内存模型明确:对原子变量的读写构成 happens-before 边——

// goroutine A
data = 42                    // 普通写
flag.Store(true)             // 原子写

// goroutine B
if flag.Load() {             // 原子读看到 true
    use(data)                // 保证看到 42(Store 先于 Load 同步)
}

这与 C++ 的 memory_order_acquire/release 家族不同:Go 没有"弱顺序原子"可选项,换来的是不用思考 fence。代价是极端性能场景下 Go 的原子比 C++ 放松一点的版本略贵(编译器生成的 barrier 更保守),但绝大多数业务根本碰不到这个差异。

注意:happens-before 只对"成对的原子操作"生效。atomic.StoreInt64(&a, 1) 和普通读 if b := a 混用依然竞态——要么全程原子,要么全程锁,混用是最危险的状态。


8. atomic vs Mutex:怎么选

场景 推荐
单个计数/仪表值 atomic
开关/标志位、状态整份替换 atomic(Pointer/Value,copy-on-write)
多字段必须一起成立的不变量 Mutex
一段代码临界区(含 IO/调用链) Mutex
等待某个条件成立 Mutex+Cond 或 channel
“只初始化一次” sync.Once / OnceValue

一句话:原子保护的是一个字;锁保护的是一组关系。 需要同时改两个字段且别人不能看见中间状态,原子帮不了你。


9. 实战建议

  • 新项目直接上 atomic.Int64/Bool/Pointer[T],不要再写函数式 API;32 位对齐问题随之消失。
  • 配置/路由表/白名单这类"读极多、偶尔整份换新"的场景,用 atomic.Pointer[T] 整份替换,比 RWMutex 更简单且更快——前提是永远构造新对象,绝不就地改。
  • 别用原子变量手搓 Once;sync.OnceValue 一行解决还带正确的可见性语义。
  • “无锁一定比有锁快"是错觉:Go 的 Mutex 在无竞争时是一条 CAS + 快速返回,和原子开销同量级;竞争加剧时锁会退让给调度器,CAS 循环反而空转烧 CPU。性能结论以 -race 干净 + 压测数据为准。
  • 监控上,Go 1.20+ 的 runtime/metrics 没有原子指标,但可以用 pprof CPU 火焰图看 CAS 重试热点(函数名含 CompareAndSwap 且占比高 = 竞争过烈),考虑分片(sharding:N 个桶相加)代替单变量原子。