前面讲 Mutex、WaitGroup 等并发原语的实现时,你会发现它们的底层都依赖 sync/atomic 包的原子操作。原子操作是并发编程的最底层基石——比锁更轻量、比 Channel 更快,适合特定场景下的高性能并发控制。这一篇我们专门来讲 atomic。
一、什么是原子操作?
原子操作是指 不会被中断的操作。在其他 goroutine 看来,原子操作要么已经完成,要么还没开始,不会看到"执行了一半"的中间状态。
原子操作为什么能保证这一点?因为 CPU 硬件直接支持:
| 架构 | 硬件指令 |
|---|---|
| x86/amd64 | LOCK 指令前缀(如 LOCK CMPXCHG),XCHG 自带 LOCK 语义 |
| ARM | LDREX / STREX(Load-Link / Store-Conditional) |
| MIPS | LL / SC(Load-Linked / Store-Conditional) |
共同点:硬件保证在操作期间其他核不会访问同一内存地址。
二、atomic 包提供的操作
Go 的 sync/atomic 包提供了五类原子操作:
| 类别 | 说明 | 函数 |
|---|---|---|
| Add | 原子加减,返回新值 | AddInt32 / AddInt64 / AddUint32 / AddUint64 / AddUintptr |
| Load | 原子读取 | LoadInt32 / LoadInt64 / LoadUint32 / LoadUint64 / … |
| Store | 原子写入 | StoreInt32 / StoreInt64 / StoreUint32 / … |
| Swap | 原子交换,返回旧值 | SwapInt32 / SwapInt64 / SwapUint32 / … |
| CompareAndSwap | CAS,旧值匹配才交换,返回是否成功 | CompareAndSwapInt32 / CompareAndSwapInt64 / … |
Add:原子加减
var counter int64
// 多个 goroutine 并发递增
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1) // 原子 +1
}()
}
wg.Wait()
fmt.Println(counter) // 稳定输出 10000减法怎么做?加一个负数:
atomic.AddInt64(&counter, -1) // 原子 -1
// 对于 uint 类型,利用补码:
atomic.AddUint32(&ucount, ^uint32(0)) // 等价于 -1Load / Store:原子读写
var flag int32
// goroutine A:原子写
atomic.StoreInt32(&flag, 1)
// goroutine B:原子读
if atomic.LoadInt32(&flag) == 1 {
fmt.Println("flag 已设置")
}为什么需要 Load/Store?直接读写变量不行吗?在 32 位系统上,读写一个 int64 需要两条指令(高位 + 低位),一个 goroutine 写到一半时,另一个 goroutine 可能读到"新高位 + 旧低位"的撕裂值。即使在 64 位系统上对齐的 int64 通常是原子的,Go 规范也没有保证这一点——用 atomic 才是可移植的正确做法。
CompareAndSwap(CAS):原子比较交换
CAS 是最强大的原子操作,很多无锁数据结构都基于它:
// CAS: 如果当前值 == old,则设置为 new,返回是否成功
swapped := atomic.CompareAndSwapInt64(&value, old, new)典型用法——无锁的自旋更新:
// 原子地把 value 翻倍
for {
old := atomic.LoadInt64(&value)
new := old * 2
if atomic.CompareAndSwapInt64(&value, old, new) {
break // CAS 成功,退出
}
// CAS 失败(被其他 goroutine 修改了),重试
}三、Go 1.19+ atomic 类型
Go 1.19 引入了一组泛型化的原子类型,更安全、更易用:
| 类型 | 说明 |
|---|---|
atomic.Bool | 原子布尔值 |
atomic.Int32 / atomic.Int64 | 原子整型 |
atomic.Uint32 / atomic.Uint64 / atomic.Uintptr | 原子无符号整型 |
atomic.Pointer[T] | 原子指针(泛型) |
atomic.Value | 原子任意值(interface{}) |
对比新旧两种写法:
// 旧写法:函数式,需要传指针
var counter int64
atomic.AddInt64(&counter, 1)
v := atomic.LoadInt64(&counter)
// 新写法(Go 1.19+):方法式,更安全
var counter atomic.Int64
counter.Add(1)
v := counter.Load()新类型的优势:
| 旧函数式 API 的问题 | 新方法式 API 的改进 |
|---|---|
| 必须传指针,容易误传值 | 方法调用,不会传错 |
| 变量声明和使用分离,可能被非原子地访问 | 类型本身就标记了"这是原子变量" |
| 没有类型约束,int32 和 int64 容易混淆 | go vet 可以检测对原子类型的非原子访问 |
atomic.Value:存储任意类型
atomic.Value 可以原子地存储和加载任意类型的值,常用于配置热更新:
var config atomic.Value
// 初始化
config.Store(map[string]string{
"host": "localhost",
"port": "8080",
})
// goroutine A:原子更新配置
config.Store(map[string]string{
"host": "prod.example.com",
"port": "443",
})
// goroutine B:原子读取配置
cfg := config.Load().(map[string]string)
fmt.Println(cfg["host"])注意:atomic.Value 存储的值类型必须一致。第一次 Store 的是什么类型,后续 Store 的也必须是同一类型,否则 panic。
atomic.Pointer[T]:类型安全的原子指针
type Config struct {
Host string
Port int
}
var configPtr atomic.Pointer[Config]
// 原子更新
configPtr.Store(&Config{Host: "localhost", Port: 8080})
// 原子读取
cfg := configPtr.Load()
fmt.Println(cfg.Host) // 无需类型断言四、实战:基于 CAS 的无锁栈
用 CAS 实现一个无锁并发栈:
type node[T any] struct {
value T
next *node[T]
}
type LockFreeStack[T any] struct {
top atomic.Pointer[node[T]]
}
func (s *LockFreeStack[T]) Push(value T) {
n := &node[T]{value: value}
for {
old := s.top.Load()
n.next = old
if s.top.CompareAndSwap(old, n) {
return // CAS 成功
}
// CAS 失败,重试
}
}
func (s *LockFreeStack[T]) Pop() (T, bool) {
for {
old := s.top.Load()
if old == nil {
var zero T
return zero, false // 栈为空
}
if s.top.CompareAndSwap(old, old.next) {
return old.value, true // CAS 成功
}
// CAS 失败,重试
}
}五、atomic vs Mutex:怎么选?
| 特性 | atomic | Mutex |
|---|---|---|
| 性能 | ✅ 极高(硬件级) | 一般(涉及调度) |
| 适用操作 | 简单的读/写/加减 | 任意复杂的临界区 |
| 复合操作 | ❌ 只能单步原子 | ✅ 多步骤原子 |
| 代码复杂度 | 低(简单场景) | 低(通用) |
| 无锁数据结构 | ✅ CAS 自旋 | ❌ 需要锁 |
选择依据:
| 场景 | 推荐 |
|---|---|
| 简单的计数器、标记位 | atomic |
| 配置的原子替换 | atomic.Value / atomic.Pointer[T] |
| 需要保护多个变量的一致性 | Mutex |
| 复杂的临界区逻辑 | Mutex |
六、使用 atomic 最常踩的 3 个坑
坑 1:64 位原子操作在 32 位系统上的对齐问题
在 32 位系统上,64 位原子操作要求变量 8 字节对齐,否则会 panic:
type Bad struct {
flag bool // 1 byte
counter int64 // ❌ 偏移 1 byte,未对齐
}
type Good struct {
counter int64 // ✅ 放在第一个字段,保证对齐
flag bool
}提示:Go 1.19+ 的
atomic.Int64等类型内部已经处理了对齐问题,优先使用它们。
坑 2:混合使用原子和非原子操作
var counter int64
// goroutine A
atomic.AddInt64(&counter, 1) // 原子操作
// goroutine B
fmt.Println(counter) // ❌ 直接读取,非原子!一旦一个变量被原子操作访问,所有对它的访问都必须用原子操作。 混合使用会导致数据竞争。
坑 3:CAS 自旋不加退避
高竞争场景下,纯 CAS 自旋会浪费 CPU:
// ❌ 高竞争下空转
for !atomic.CompareAndSwapInt64(&v, old, new) {
old = atomic.LoadInt64(&v)
new = f(old)
}
// ✅ 加入 runtime.Gosched() 让出 CPU
for !atomic.CompareAndSwapInt64(&v, old, new) {
runtime.Gosched() // 让其他 goroutine 有机会执行
old = atomic.LoadInt64(&v)
new = f(old)
}七、实战建议
- 简单计数器用
atomic.Int64——比 Mutex 快一个数量级 - 配置热更新用
atomic.Value或atomic.Pointer[T]——无锁读,写时原子替换 - Go 1.19+ 优先用原子类型——比函数式 API 更安全、更清晰
- 不要过度使用无锁结构——CAS 自旋在高竞争下可能比 Mutex 更慢
- 所有访问要么全用原子,要么全用锁——混合使用是数据竞争
# 两个应该加入 CI 的命令
go vet ./...
go test -race ./...atomic 是 Go 并发编程的最底层工具——所有上层的 Mutex、WaitGroup、Once 都建立在它之上。在性能敏感的场景中,直接使用 atomic 可以获得硬件级的并发性能。但它只适合简单的原子操作,复杂的临界区逻辑还是交给 Mutex 更靠谱。