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 个桶相加)代替单变量原子。