在 Go 语言的并发编程世界里,sync.Mutex(互斥锁)通常是我们解决数据竞争(Data Race)的首选武器。然而,在某些追求极致性能的场景下,锁的开销(上下文切换、内核态用户态切换)可能会成为瓶颈。
这时,sync/atomic 包就闪亮登场了。它提供了底层的原子级内存操作,让我们能够以“无锁(Lock-Free)”的方式处理并发数据。
本文将带你深入了解 Atomic 的核心概念、使用场景以及它背后的黑科技。
原子操作(Atomic Operation)是指不会被线程调度机制打断的操作。即使在多核 CPU 上,原子操作一旦开始,就会一直运行到结束,中间不会有任何 Context Switch(上下文切换)。
通俗理解:
如果 Mutex 是在厕所门口挂一把锁,同一时间只能进一个人;
那么 Atomic 就是厕所里只有一个超光速马桶,你还没来得及眨眼,操作就已经完成了,根本不需要锁门。
sync/atomic 包主要提供了五类核心操作,支持的数据类型包括 int32, int64, uint32, uint64, uintptr 以及 Pointer。
最经典的使用场景是并发计数器。
Go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count int64 = 0
var wg sync.WaitGroup
// 启动 1000 个 goroutine 并发增加计数
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// ❌ 错误做法: count++ (非原子操作,会导致数据竞争)
// ✅ 正确做法: 原子增加
atomic.AddInt64(&count, 1)
}()
}
wg.Wait()
fmt.Println("Final Count:", count) // 输出必为 1000
}
Add 也可以处理减法,只需传入负数的补码(或者利用 Go 的溢出特性,但为了代码可读性,建议使用 ^uint64(delta-1) 等技巧,或者直接传入负数 atomic.AddInt64(&i, -1))。在并发环境下,直接读写变量(x = 1 或 y = x)并不一定是安全的(特别是在 32 位机器上读写 64 位变量时)。
Go
var configValue atomic.Value
// 写配置
func setConfig(c Config) {
configValue.Store(c)
}
// 读配置
func getConfig() Config {
return configValue.Load().(Config)
}
这是无锁编程(Lock-Free)的基石。
逻辑: “我认为当前内存里的值是 Old,如果是,请把它改成 New;如果不是,说明被别人改过了,我就什么都不做,并告诉你失败了。”
Go
var value int32 = 10
func main() {
// 尝试将 10 修改为 20
// 参数: 地址, 旧预期值, 新值
swapped := atomic.CompareAndSwapInt32(&value, 10, 20)
if swapped {
fmt.Println("修改成功,新值为:", value)
} else {
fmt.Println("修改失败,旧值已被其他协程修改")
}
}
不管旧值是什么,直接替换为新值,并返回旧值。
Go
oldValue := atomic.SwapInt64(&count, 100)
// count 变为 100,oldValue 拿到之前的 count 值
在 Go 1.19 之前,我们必须调用 atomic.AddInt64(&x, 1),不仅繁琐,而且容易传错变量类型。
Go 1.19 引入了更方便的类型包装:atomic.Int64, atomic.Bool, atomic.Pointer 等。
新写法(推荐):
Go
import "sync/atomic"
func main() {
var count atomic.Int64 // 自动初始化为 0
count.Store(100)
fmt.Println(count.Load())
if count.CompareAndSwap(100, 200) {
fmt.Println("CAS success")
}
}
这种写法更符合面向对象的直觉,且避免了指针传递的错误。
atomic.Value (处理任意类型)Add 和 CAS 只能处理数字,如果你想原子性地替换一个 struct 或者 map 怎么办?这时候就需要 atomic.Value。
经典场景:热加载配置
当配置发生变化时,我们原子性地替换整个配置对象,正在读取旧配置的请求不受影响,新的请求获取新配置。
Go
type Config struct {
ApiKey string
Timeout int
}
var globalConfig atomic.Value
func init() {
// 初始化
globalConfig.Store(Config{ApiKey: "init", Timeout: 10})
}
// 模拟配置更新线程
func updater() {
newConf := Config{ApiKey: "updated", Timeout: 20}
globalConfig.Store(newConf) // 原子替换
}
// 业务线程
func handler() {
// 即使 updater 正在 Store,这里获取到的要么是完整的旧值,要么是完整的新值
// 绝不会出现 ApiKey 是新的但 Timeout 是旧的这种情况
c := globalConfig.Load().(Config)
println(c.ApiKey)
}
很多开发者会纠结:既然 Atomic 性能更好,为什么不全用它?
| 特性 | Atomic (原子操作) | Mutex (互斥锁) |
|---|---|---|
| 底层实现 | CPU 指令级支持 (如 x86 的LOCK前缀) | 操作系统调度,信号量 |
| 性能 | 极高(纳秒级) | 较高(但在高并发竞争下有开销) |
| 适用范围 | 简单的计数器、状态标志位、单变量保护 | 保护一段复杂的代码逻辑、涉及多个变量的更新 |
| 代码复杂度 | 难以编写复杂逻辑,容易写出死循环 | 逻辑清晰,易于维护 |
最佳实践建议:
- 如果你只是为了给一个
int累加,用 Atomic。- 如果你需要保护一块逻辑(比如:如果 map 里没有 key 则写入,有则读取),或者涉及多个变量的一致性,请毫不犹豫使用 Mutex。
- 不要为了炫技而使用 Atomic,可读性通常比微小的性能提升更重要。
atomic.Int64 已经自动处理了对齐问题。如果是老代码,通常将 int64 字段放在 struct 的最前面。sync/atomic 是 Go 高性能并发编程的一把手术刀:精准、高效,但容易伤手。
atomic.Value 是神器。Mutex。