atomic 包的奇怪用法 | 青训营笔记

111 阅读2分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 8 天。

本文以 CC-BY-SA 4.0 发布。

原子操作

atomic 包提供了一些底层的对内存进行原子操作的函数,这些函数对于实现一些同步算法会有帮助。

atomic package - sync/atomic - Go Packages

原子操作想必大家都不陌生。 原子本意为“不可再分”,顾名思义,“原子操作”也就意为“不可再分的操作”。 在并行的语境下,这也就表明了原子操作之间必然(可以看作)有着明确的前后关系 ——原子操作 A 要么是在原子操作 B 开始之前就结束了,要么就是在 B 后才开始, 不会有同时在进行的情况发生。

从最常使用的各种的锁,到数据库的事务到分布式锁, 我们可以采取各种各样的手段保证操作的原子性,避免程序因为共享内存、共享环境、 或是共享资源,而在并行操作时出现各种难以预料的问题。

Go 的 atomic 包

在单机情况下,Go 的 atomic 包里的各种函数可以看作是对硬件直接提供的原子操作指令的一些包装。 最简单的一个例子便是累加器:

num := int32(0)
ptr := &num
for i := 0; i < 1000; i++ {
  go func() {
    for j := 0; j < 1000; j++ {
      atomic.AddInt32(ptr, 1)
    }
  }()
}

因为在现今大多数架构上各种的运算都依赖于寄存器, 所以一个加法操作常常被分为“读取值到寄存器”“进行寄存器加法”“写入内存”等步骤, 这样的操作并不是原子操作。 这也导致了并行的 a += 1 a += 2 的结果并不是 a += 3

想要取得“正确”的结果,我们必须使用 CPU 在指令集层面上为我们提供的原子操作 ——也就是 atomic 包里包装的那些。

False Sharing

原子指令作为硬件层面提供的原子操作,它的开销偏小,但也与各种因素密切相关。 一个值得注意的现象叫 False Sharing

在现代 CPU 里,因为内存离得太远、读取太慢,CPU 里以及会自带多级的缓存, 从 L1 到 L2 到 L3 等等。CPU 缓存的各种一致性保证、同步之类的非常复杂, 特别是在涉及到多核 CPU 的时候——不同核的 L1 缓存通常是独立的。

下面是 ristretto 使用原子指令时遇到的问题:

一般 CPU 缓存的缓存以 cache line 为单位,一般可能是 64 字节左右。 在多核情况下,进行原子操作意味着 CPU 需要对其它核的缓存进行原子的更新。 越是多核同时在对这片 cache line 进行操作,那么其实它的操作开销其实就越大。 也因此,使各种需要 atomic 操作的值相对分布不那么密集, 使不同值分布在不同 cache line 上的话,这会对并行环境更加友好。

用 atomic 操作替代锁

累加是最直观的原子操作。但是其实原子操作也可以从某种程度上替代一些锁。

设想一个累加器,多个 goroutine 对其进行并行的累加, 但是最后加完之后我们需要保证我们把它的值写回到数据库里去。 这时我们当然可以使用熟悉的 WaitGroup,独立出一个 goroutine 负责等待写完, 最后进行收尾操作。我们也可以使用原子递增/减:

num := int32(0)
ptr := &num
marker := 1000
m := &marker
for i := 0; i < 1000; i++ {
  go func() {
    for j := 0; j < 1000; j++ {
      atomic.AddInt32(ptr, 1)
    }
    if atomic.AddInt32(m, -1) == 0 {
      CleanUp()
    }
  }()
}

只有最后一个 goroutine 能够得到 atomic.AddInt32(m, -1) == 0, 这就保证了保存操作一定在最后才得到执行。