转载 | 原子操作和同步原语

Apr 18, 2025

在高并发系统中,性能不仅关乎你做了什么,更关乎你避免了什么。锁竞争、缓存行抖动和内存屏障在你达到扩展瓶颈之前,就悄悄地影响了吞吐量。原子操作是 Go 语言提供的最轻量级工具之一,能够有效规避这些问题。

虽然 Go 提供了完整的同步原语套件,但有些问题使用锁会显得过于繁重。原子操作则在低层协调中(如计数器、标志和简单状态机)提供了更清晰且更高效的解决方案,尤其是在高压环境下表现突出。

理解原子操作

原子操作允许对共享数据进行安全的并发访问,而无需像互斥锁(mutex)那样的显式锁机制。sync/atomic 包提供了低级别的原子内存原语,非常适合用于计数器、标志或简单的状态转换。

原子操作的关键优势是在高竞争情况下的性能表现。加锁会引入协调开销——当许多 goroutine 争夺同一个互斥锁时,性能可能因上下文切换和锁队列管理而下降。原子操作通过在硬件层面,利用 CPU 指令(如 CAS,比较并交换)直接操作,避免了这些开销。这使得原子操作在以下场景中特别有用:

  • 高吞吐量的计数器和标志
  • 无锁队列和无锁空闲列表
  • 不适合使用锁的低延迟路径

内存模型及与 C++ 的比较

理解内存模型在推理并发行为时至关重要。在 C++ 中,开发者可以通过内存序(memory order)对原子操作进行细粒度控制,从而在性能和一致性之间做权衡。默认情况下,Go 语言的原子操作强制执行顺序一致性(sequential consistency),这意味着它们的行为类似于 C++ 中的 std::memory_order_seq_cst。这是最强且最安全的内存序保证:

  • 所有线程都以相同的顺序观察原子操作。
  • 在每个操作的前后都会应用完整的内存屏障。
  • 原子操作之间的读写不会被重排。
C++ Memory OrderGo EquivalentNotes
memory_order_seq_cstAll atomic.* opsFull sequential consistency
memory_order_acquireNot exposedNot available in Go
memory_order_releaseNot exposedNot available in Go
memory_order_relaxedNot exposedNot available in Go

Go 语言不暴露诸如 relaxedacquirerelease 这类较弱的内存模型。这是刻意简化设计,以提升安全性并降低细微数据竞争的风险。Go 中所有的原子操作隐式实现了 goroutine 间的同步,确保了正确的行为,无需手动管理内存屏障。

这意味着你不必关注指令重排或内存可见性等底层细节,但也意味着你无法像 C++Rust 开发者那样,通过使用 relaxed 原子操作对性能进行细粒度调优。

Go 内部(例如在运行时或通过 go:linkname),存在对放宽内存序的低级访问,但这不安全且不支持应用层代码使用。

常用的原子操作

  • atomic.AddInt64atomic.AddUint32 等:原子地执行加法操作。
  • atomic.LoadInt64atomic.LoadPointer:原子地读取值。
  • atomic.StoreInt64atomic.StorePointer:原子地写入值。
  • atomic.CompareAndSwapInt64:原子地有条件更新一个值。

原子操作在实际中的使用时机

高吞吐量的指标和计数器

用于跟踪请求数量、丢包数或其他轻量级统计数据:

var requests atomic.Int64

func handleRequest() {
    requests.Add(1)
}

这段代码允许多个 goroutine 在不使用锁的情况下安全地递增共享计数器。atomic.AddInt64 确保每次加法操作都是原子性的,防止了竞态条件,并在高负载情况下保持高性能。

快速的无锁标志

多个线程间共享的简单布尔状态:

var shutdown atomic.Int32

func mainLoop() {
    for {
        if shutdown.Load() == 1 {
            break
        }
        // 执行工作
    }
}

func stop() {
    shutdown.Store(1)
}

这种模式允许一个 goroutine 向另一个发送停止信号。atomic.LoadInt32 以同步保证读取标志,atomic.StoreInt32 以对所有 goroutine 可见的方式设置标志。这有助于实现安全的关闭信号。

只执行一次的初始化

当你需要更灵活的控制时,可以替代 sync.Once

var initialized atomic.Int32

func maybeInit() {
    if initialized.CompareAndSwap(0, 1) {
        // 初始化资源
    }
}

这段代码使用 CompareAndSwapInt32 确保只有第一个看到 initialized == 0goroutine 会执行初始化逻辑,其他 goroutine 都会跳过。这种方式高效且避免了 sync.Once 的锁开销,特别适合你需要条件性初始化或重试机制的场景。

无锁队列或空闲链表结构

构建高性能的数据结构:

type node struct {
    next *node
    val  any
}

var head atomic.Pointer[node]

func push(n *node) {
    for {
        old := head.Load()
        n.next = old
        if head.CompareAndSwap(old, n) {
            return
        }
    }
}

这段代码实现了一个无锁栈(后进先出队列)。它通过原子地替换头指针来反复尝试将节点插入到链表头部,只有当头指针没有变化时才替换——这是经典的 CAS(比较并交换)循环。该方法常用于对象池和工作窃取队列中。

减少锁竞争

这种方法在实际系统中很常见,用于减少不必要的锁竞争,比如功能开关、一次性初始化路径或条件缓存机制。原子操作(atomic)作为获取更昂贵锁之前的快速路径过滤器。

将原子操作与互斥锁(mutex)结合起来控制昂贵操作:

if atomic.LoadInt32(&someFlag) == 0 {
    return
}
mu.Lock()
defer mu.Unclock()
// 进行一些耗时操作

someFlag 由另一个 goroutine 设置,而当前 goroutine 仅将其作为只读信号判断是否继续时,这种模式很有效。它避免在高吞吐路径中不必要的锁获取,比如在功能被禁用或任务已完成时短路执行。

然而,如果同一个 goroutine 也负责设置该标志,那么单纯的先加载后加锁是不安全的。另一个 goroutine 可能在检查和加锁之间插入操作,导致不一致的行为。

为了保证操作的安全和原子性,应使用 CompareAndSwap

if !atomic.CompareAndSwapInt32(&someFlag, 0, 1) {
    return // 工作已经在进行中或已完成
}
mu.Lock()
defer mu.Unlock()
// 执行一次性昂贵初始化

该版本保证只有一个 goroutine 继续执行,其他的提前退出。它确保对 someFlag 的检查和更新是原子的。

这里,原子读取充当快速守门员。如果标志未设置,则无需获取互斥锁。这避免了高频代码路径中不必要的锁定,提高了系统在负载下的响应能力。

同步原语

本节内容刻意保持简洁。Go 语言的同步原语——例如 sync.Mutexsync.RWMutexsync.Cond ——已经有非常完善的文档和广泛的理解。它们是管理共享内存和协调 goroutine 的重要工具,但它们并非本篇文章的重点。

在本文的语境中,我们仅将它们作为与原子操作进行性能对比的基准。适当地使用这些原语可以带来清晰性和正确性,但它们在高竞争场景下通常代价较高,而原子操作则可提供更轻量的替代方案。

我们将以这些原语作为对比点,来突出何时以及为何原子操作可能带来性能优势。

基准测试影响

为了理解原子操作与互斥锁(mutex)之间的性能差异,我们可以通过一个简单的基准测试来比较多个 goroutine 同时递增共享计数器所需的时间。

func BenchmarkAtomicIncrement(b *testing.B) {
    var counter int64
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            atomic.AddInt64(&counter, 1)
        }
    })
}

func BenchmarkMutexIncrement(b *testing.B) {
    var (
        counter int64
        mu      sync.Mutex
    )
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.Lock()
            counter++
            mu.Unlock()
        }
    })
}

这段代码通过两种方式分别对共享变量执行递增操作,并使用 b.RunParallel 让不同的 goroutine 并行执行,从而测量原子操作与互斥锁在并发情况下的性能表现差异。

Benchmark Iterations Time per op (ns) Bytes per op Allocs per op
BenchmarkAtomicIncrement-1439,910,51480.4000
BenchmarkMutexIncrement-1432,629,298110.700

原子操作在吞吐量和延迟方面均优于基于互斥锁的递增操作。随着锁争用加剧,这种差异会更加明显,因为避免获取锁有助于减少上下文切换和调度器开销。

何时使用原子操作 vs. 互斥锁

  • 原子操作在简单且高频率的场景中表现出色——比如计数器、标志、协调信号等——在这些场景中锁的开销是不成比例的。它们能够避免锁队列和减少上下文切换。但它们也有局限性:无法将多个操作组合在一起,不能回滚,且在超出其适用范围后会增加复杂性。

  • 互斥锁仍然是管理复杂共享状态、保护多步骤临界区以及维护不变量的正确工具。当程序逻辑超过几行时,互斥锁更容易理解且通常更安全。

选择使用原子操作还是锁,不是出于理念,而是根据具体范围。当任务简单时,原子操作能轻松应对;当任务变复杂时,锁才能保障安全。


  • https://goperf.dev/01-common-patterns/atomic-ops
https://inasa.dev/posts/rss.xml