原子内存操作提供了实现其他同步原语所需的低级基础。一般来说,你可以用互斥体和通道替换并发算法的所有原子操作。然而,它们是有趣且有时令人困惑的结构,应该深入了解它们是如何工作的。如果你能够谨慎地使用它们,那么它们完全可以成为代码优化的好工具,而不会增加复杂性。
1. 原子内存操作的内存保证
为什么我们需要单独的函数来进行原子内存操作?如果我们写入一个变量,其大小小于或等于机器字长( 现代计算机的机器字长一般都 8 位的整数倍,如 8 位、16 位等,这是由 int 类型定义的东西),例如 a = 1 ,这不就是原子的吗?
Go 内存模型实际上保证了写操作是原子的,但是它并不能保证其他 goroutine 何时会看到该写操作的效果。
让我们仔细分析这句话的含义。第一层意思是说,如果你从一个 goroutine 中写入与机器字长(即int) 大小相同的共享内存位置并从另一个 goroutine 中读取它,那么即使存在竞争,你也不会观察到写入操作之前的值或写入操作之后的值(并非所有语言都如此)。这意味着,如果写操作大于机器字长,那么读取该值的 goroutine 可能会看到底层对象处于不一致的状态。例如,string 值包括两个值:指向底层数组的指针和字符串长度。对这些单独的写入操作是原子的,但快速读取操作可能会读取带有 nil 数组但长度非零的字符串。
这句话的第二层意思是说,编译器可能优化或重新排序代码,或者硬件可能乱序执行内存操作,从而使另一个 goroutine 在预期时间无法看到写入操作的效果。说明这一点的标准示例就是以下内存竞争:
package main
func main() {
var str string
var done bool
go func() {
str = "Done!"
done = true
}()
for !done {
}
fmt.Println(str)
}
这里就存在内存竞争,因为 str 变量和 done 变量在一个 goroutine 中被写入并在另一个 goroutine 中被读取,但没有显式同步。
该程序有多种可能的结果:
- 它可以输出 Done ! 。
- 它可以输出一个空字符串。这意味着主 goroutine 可以看到对 done 的内存写入,但看不到对 srt 的内存写入。
- 程序可能会挂起。这意味着主 goroutine 看不到对 done 的内存写入。
这就是原子操作发挥作用的地方。以下程序是没有内存竞争的:
func main(){
var str done atomic.Value
var done atomic.Bool
str.Store("")
go func(){
str.Store("Done!")
done.Store(true)
}()
for !done.Load(){
}
fmt.Println(str.Load())
}
原子操作的内存保证如下。如果原子内存写入的效果可以通过原子读取观察到,则原子写入发生在原子读取之前。这也保证了以下程序要么输出 1 ,要么不输出任何东西(永远都不会输出 0):
func main(){
var done atomic.Bool
var a int
go func(){
a = 1
done.Store(true)
}()
if done.Load(){
fmt.Println(a)
}
}
值得一提的是,这里仍然存在竞争条件,但不是内存竞争。根据语句的执行顺序,主 goroutine 可能会也可能不会看到 done 为 true 。但是,如果主 goroutine 看到 done 为 true,那么就可以保证 a = 1 。
这就是为什么使用原子操作会变得复杂的原因之一:内存排序保证是有条件的。它们永远不会阻塞正在运行的 goroutine,因此你测试原子读取是否返回变量的特定值这一事实并不意味着当 if 语句主体运行时它仍然具有相同的值。这就是为什么在使用原子操作时需要小心。使用它们很容易陷入竞争条件,就像之前的程序这样。
2. 比较和交换操作
每当你需要测试条件并根据结果采取行动时,你都可以创建竞争条件。例如,尽管使用了原子操作,但以下函数并不能阻止互斥:
var locked sync.Bool
func wrongCriticalSectionExample(){
if !locked.Load(){
// 其他 goroutine 现在可以锁定它!
locked.Store(true)
defer locked.Store(false)
// 该 goroutine 进入临界区,但其他 goroutine 也可以
}
}
该函数首先测试原子 locked 值是否为 false。两个 goroutine 可以同时执行这条语句,并且看到它为 false,它们都可以进入临界区并将 locked 设置为 true。
这里需要的包含比较和存储操作的原子操作,也就是比较和交换(compare-and-swap,CAS)操作。正如其名称所暗示的那样,它将比较变量是否具有预期值,如果是,则自动将该值与给定值进行交换。如果变量具有不同的值,则不会发生任何更改。也就是说,CAS 操作是以下形式的,并以原子方式完成:
if *variable == testValue {
*variable = newValue
return true
}
return false
现在你可以真正实现非阻塞互斥体:
func criticalSection(){
if locked.CompareAndSwap(false,true)
defer locked.Store(false)
// 临界区
}
}
只有当 locked 为 false 时才会进入临界区。如果是这种情况,那么它会自动将 locked 设置为 true 并进入其临界区;否则,它将跳远临界区并明治继续。因此,这实际上可以用来代替 Mutex.TryLock。
3. 原子的实际用途
3.1 计数器
原子可以用作高效的并发安全计数器。
以下程序示例将创建许多 goroutine, 其中每个 goroutine 都会将共享计数器加 1。另一个 goroutine 则循环直至计数器达到 10000。由于这里使用了原子,因此该程序是无竞争的,并且它始终会通过最终输出 10000 来终止。
var count int64
func main() {
for i := 0,i <10000; i++ {
go func(){
atomic.AddInt64(&count,1)
}()
}
for {
v := atomic.LoadInt64(&count)
fmt.Println(v)
if v == 10000 {
break
}
}
}
3.2 心跳和进度表
有时,goroutine 可能会变得无响应或无法按需要快速进行。心跳实用程序和进度表可用于观察此类 goroutine 。有若干种方法可以做到这一点。例如,被观察的 goroutine 可以使用非阻塞发送来宣布进度,或者它可以通过增加由互斥体保护的共享变量来宣布其进度。原子允许我们在没有互斥体的情况下实现共享变量方案。这样做还有一个可以好处是可以被多个 goroutine 观察而无须额外的同步。
让我们定义一个包含原子值的简单 ProgressMeter 类型:
type ProgressMeter struct {
progress int64
}
被观察的 goroutine 使用以下方法来指示其进度。此方法只是自动将进度值递增1:
func (pm *ProgressMeter) Progress(){
atomic.AddInt64(&pm.progress,1)
}
Get 方法可以返回进度的当前值。请注意,该负载是原子的,否则就有可能会丢失对变量的原子添加:
func (pm *ProgressMeter) Get() int64 {
wg.Wait()
}
可以看到,我们将 cancel 函数传递给了观察者,以便它可以向被观察的 goroutine 发送取消的消息。
3.3 取消
我们可以通过关闭通道来发出取消信号。Context 实现可以使用此范式来发出取消和超时信号。使用原子也可以实现简单的取消方案:
func CancelSupport()(cancel func(),isCancelled func() bool){
v := atomic.Bool{}
cancel = func(){
v.Store(true)
}
isCancelled = func() bool {
return v.Load()
}
return
}
CancelSupport 函数返回两个闭包,其中,cancel() 函数可被调用以发出取消信号,而 isCancelled( )函数则可用于检查取消请求是否已注册。这两个闭包共享一个原子 bool 值,这可以按如下方式使用:
func main(){
cancel,isCanceled := CancelSupport()
wg := sync.WaitGroup{}
wg.Add(1)
go func(){
defer wg.Done()
for {
time.Sleep(100 * time.Millisecond)
if isCanceled(){
fmt.Println("Cancelled")
return
}
}
}()
time.AfterFunc(5*time.Second,cancel)
wg.Wait()
}
3.4 检测变化
假设你有一个可以从多个 goroutine 中更新的共享变量。你读取此变量,执行了一些计算,现在你想要更新它。但是,在你获得副本后,另一个 goroutine 可能已经修改了该变量。因此,当且仅当其他 goroutine 没有更改此变量时,你才可以更新它。
以下代码片段使用比较和交换(CAS)操作对此进行说明:
var sharedValue atomic.Pointer[SomeStruct]
func unpdateSharedValue(){
myCopy := sharedValue.Load()
newCopy := computeNewCopy(*myCopy)
if sharedValue.CompareAndSwap(myCopy,&newCopy){
fmt.Println("Set value successful")
} else {
fmt.Println("Another goroutine modified the value")
}
}
这段代码很容易出现竞争,所以你必须小心。SharedValue.Load( )调用以原子方式返回指向共享值的指针。如果另一个 goroutine 修改了指向 *sharedValue 对象的内容,则出现了竞争。仅当所有 goroutine 以原子方式获取指针并复制底层数据结构时,这才有效。然后,我们使用 CAS 写入修改后的副本,但如果另一个 goroutine 表现得更快,则写入操作会失败。