转载 | 原子操作和同步原语
在高并发系统中,性能不仅关乎你做了什么,更关乎你避免了什么。锁竞争、缓存行抖动和内存屏障在你达到扩展瓶颈之前,就悄悄地影响了吞吐量。原子操作是 Go
语言提供的最轻量级工具之一,能够有效规避这些问题。
虽然 Go
提供了完整的同步原语套件,但有些问题使用锁会显得过于繁重。原子操作则在低层协调中(如计数器、标志和简单状态机)提供了更清晰且更高效的解决方案,尤其是在高压环境下表现突出。
理解原子操作
原子操作允许对共享数据进行安全的并发访问,而无需像互斥锁(mutex
)那样的显式锁机制。sync/atomic
包提供了低级别的原子内存原语,非常适合用于计数器、标志或简单的状态转换。
原子操作的关键优势是在高竞争情况下的性能表现。加锁会引入协调开销——当许多 goroutine
争夺同一个互斥锁时,性能可能因上下文切换和锁队列管理而下降。原子操作通过在硬件层面,利用 CPU
指令(如 CAS
,比较并交换)直接操作,避免了这些开销。这使得原子操作在以下场景中特别有用:
- 高吞吐量的计数器和标志
- 无锁队列和无锁空闲列表
- 不适合使用锁的低延迟路径
内存模型及与 C++
的比较
理解内存模型在推理并发行为时至关重要。在 C++
中,开发者可以通过内存序(memory order
)对原子操作进行细粒度控制,从而在性能和一致性之间做权衡。默认情况下,Go
语言的原子操作强制执行顺序一致性(sequential consistency
),这意味着它们的行为类似于 C++
中的 std::memory_order_seq_cst
。这是最强且最安全的内存序保证:
- 所有线程都以相同的顺序观察原子操作。
- 在每个操作的前后都会应用完整的内存屏障。
- 原子操作之间的读写不会被重排。
C++ Memory Order | Go Equivalent | Notes |
---|---|---|
memory_order_seq_cst | All atomic.* ops | Full sequential consistency |
memory_order_acquire | Not exposed | Not available in Go |
memory_order_release | Not exposed | Not available in Go |
memory_order_relaxed | Not exposed | Not available in Go |
Go
语言不暴露诸如 relaxed
、acquire
或 release
这类较弱的内存模型。这是刻意简化设计,以提升安全性并降低细微数据竞争的风险。Go
中所有的原子操作隐式实现了 goroutine
间的同步,确保了正确的行为,无需手动管理内存屏障。
这意味着你不必关注指令重排或内存可见性等底层细节,但也意味着你无法像 C++
或 Rust
开发者那样,通过使用 relaxed
原子操作对性能进行细粒度调优。
在 Go
内部(例如在运行时或通过 go:linkname
),存在对放宽内存序的低级访问,但这不安全且不支持应用层代码使用。
常用的原子操作
atomic.AddInt64
、atomic.AddUint32
等:原子地执行加法操作。atomic.LoadInt64
、atomic.LoadPointer
:原子地读取值。atomic.StoreInt64
、atomic.StorePointer
:原子地写入值。atomic.CompareAndSwapInt64
:原子地有条件更新一个值。
原子操作在实际中的使用时机
高吞吐量的指标和计数器
用于跟踪请求数量、丢包数或其他轻量级统计数据:
var requests atomic.Int64
func handleRequest()
这段代码允许多个 goroutine
在不使用锁的情况下安全地递增共享计数器。atomic.AddInt64
确保每次加法操作都是原子性的,防止了竞态条件,并在高负载情况下保持高性能。
快速的无锁标志
多个线程间共享的简单布尔状态:
var shutdown atomic.Int32
func mainLoop()
func stop()
这种模式允许一个 goroutine
向另一个发送停止信号。atomic.LoadInt32
以同步保证读取标志,atomic.StoreInt32
以对所有 goroutine
可见的方式设置标志。这有助于实现安全的关闭信号。
只执行一次的初始化
当你需要更灵活的控制时,可以替代 sync.Once
:
var initialized atomic.Int32
func maybeInit()
这段代码使用 CompareAndSwapInt32
确保只有第一个看到 initialized == 0
的 goroutine
会执行初始化逻辑,其他 goroutine
都会跳过。这种方式高效且避免了 sync.Once
的锁开销,特别适合你需要条件性初始化或重试机制的场景。
无锁队列或空闲链表结构
构建高性能的数据结构:
type node struct
var head atomic.Pointer[node]
func push(n *node)
这段代码实现了一个无锁栈(后进先出队列)。它通过原子地替换头指针来反复尝试将节点插入到链表头部,只有当头指针没有变化时才替换——这是经典的 CAS
(比较并交换)循环。该方法常用于对象池和工作窃取队列中。
减少锁竞争
这种方法在实际系统中很常见,用于减少不必要的锁竞争,比如功能开关、一次性初始化路径或条件缓存机制。原子操作(atomic
)作为获取更昂贵锁之前的快速路径过滤器。
将原子操作与互斥锁(mutex
)结合起来控制昂贵操作:
if atomic.LoadInt32(&someFlag) == 0
mu.Lock()
defer mu.Unclock()
// 进行一些耗时操作
当 someFlag
由另一个 goroutine
设置,而当前 goroutine
仅将其作为只读信号判断是否继续时,这种模式很有效。它避免在高吞吐路径中不必要的锁获取,比如在功能被禁用或任务已完成时短路执行。
然而,如果同一个 goroutine
也负责设置该标志,那么单纯的先加载后加锁是不安全的。另一个 goroutine
可能在检查和加锁之间插入操作,导致不一致的行为。
为了保证操作的安全和原子性,应使用 CompareAndSwap
:
if !atomic.CompareAndSwapInt32(&someFlag, 0, 1)
mu.Lock()
defer mu.Unlock()
// 执行一次性昂贵初始化
该版本保证只有一个 goroutine
继续执行,其他的提前退出。它确保对 someFlag
的检查和更新是原子的。
这里,原子读取充当快速守门员。如果标志未设置,则无需获取互斥锁。这避免了高频代码路径中不必要的锁定,提高了系统在负载下的响应能力。
同步原语
本节内容刻意保持简洁。Go
语言的同步原语——例如 sync.Mutex
、sync.RWMutex
和 sync.Cond
——已经有非常完善的文档和广泛的理解。它们是管理共享内存和协调 goroutine
的重要工具,但它们并非本篇文章的重点。
在本文的语境中,我们仅将它们作为与原子操作进行性能对比的基准。适当地使用这些原语可以带来清晰性和正确性,但它们在高竞争场景下通常代价较高,而原子操作则可提供更轻量的替代方案。
我们将以这些原语作为对比点,来突出何时以及为何原子操作可能带来性能优势。
基准测试影响
为了理解原子操作与互斥锁(mutex
)之间的性能差异,我们可以通过一个简单的基准测试来比较多个 goroutine
同时递增共享计数器所需的时间。
func BenchmarkAtomicIncrement(b *testing.B)
func BenchmarkMutexIncrement(b *testing.B)
这段代码通过两种方式分别对共享变量执行递增操作,并使用 b.RunParallel
让不同的 goroutine
并行执行,从而测量原子操作与互斥锁在并发情况下的性能表现差异。
Benchmark | Iterations | Time per op (ns) | Bytes per op | Allocs per op |
---|---|---|---|---|
BenchmarkAtomicIncrement-14 | 39,910,514 | 80.40 | 0 | 0 |
BenchmarkMutexIncrement-14 | 32,629,298 | 110.7 | 0 | 0 |
原子操作在吞吐量和延迟方面均优于基于互斥锁的递增操作。随着锁争用加剧,这种差异会更加明显,因为避免获取锁有助于减少上下文切换和调度器开销。
何时使用原子操作 vs. 互斥锁
-
原子操作在简单且高频率的场景中表现出色——比如计数器、标志、协调信号等——在这些场景中锁的开销是不成比例的。它们能够避免锁队列和减少上下文切换。但它们也有局限性:无法将多个操作组合在一起,不能回滚,且在超出其适用范围后会增加复杂性。
-
互斥锁仍然是管理复杂共享状态、保护多步骤临界区以及维护不变量的正确工具。当程序逻辑超过几行时,互斥锁更容易理解且通常更安全。
选择使用原子操作还是锁,不是出于理念,而是根据具体范围。当任务简单时,原子操作能轻松应对;当任务变复杂时,锁才能保障安全。
- https://goperf.dev/01-common-patterns/atomic-ops