Go:有了 Sync 为什么还有 Atomic?

开发 后端
Go 是一种擅长并发的语言,启动新的 goroutine 就像输入 “go” 一样简单。随着你发现自己构建的系统越来越复杂,正确保护对共享资源的访问以防止竞争条件变得极其重要。此类资源可能包括可即时更新的配置(例如功能标志)、内部状态(例如断路器状态)等。

[[438792]]

Go 是一种擅长并发的语言,启动新的 goroutine 就像输入 “go” 一样简单。随着你发现自己构建的系统越来越复杂,正确保护对共享资源的访问以防止竞争条件变得极其重要。此类资源可能包括可即时更新的配置(例如功能标志)、内部状态(例如断路器状态)等。

01 什么是竞态条件?

对于大多数读者来说,这可能是基础知识,但由于本文的其余部分取决于对竞态条件的理解,因此有必要进行简短的复习。竞态条件是一种情况,在这种情况下,程序的行为取决于其他不可控事件的顺序或时间。在大多数情况下,这种情况是一个错误,因为可能会发生不希望的结果。

举个具体的例子或许更容易理解:

  1. // race_condition_test.go 
  2. package main 
  3.  
  4. import ( 
  5.  "fmt" 
  6.  "sort" 
  7.  "sync" 
  8.  "testing" 
  9.  
  10. func Test_RaceCondition(t *testing.T) { 
  11.  var s = make([]int, 0) 
  12.  
  13.  wg := sync.WaitGroup{} 
  14.  
  15.  // spawn 10 goroutines to modify the slice in parallel 
  16.  for i := 0; i < 10; i++ { 
  17.   wg.Add(1) 
  18.   go func(i int) { 
  19.    defer wg.Done() 
  20.    s = append(s, i) //add a new item to the slice 
  21.   }(i) 
  22.  } 
  23.  
  24.  wg.Wait() 
  25.   
  26.  sort.Ints(s) //sort the response to have comparable results 
  27.  fmt.Println(s) 

执行一:

  1. $ go test -v race_condition_test.go 
  2. === RUN   Test_RaceCondition 
  3. [0 1 2 3 4 5 6 7 8 9] 
  4. --- PASS: Test_RaceCondition (0.00s) 

这里看起来一切都很好。这是我们预期的输出。该程序迭代了 10 次,并在每次迭代时将索引添加到切片中。

执行二:

  1. === RUN Test_RaceCondition 
  2.  
  3. [0 3] 
  4.  
  5. --- PASS: Test_RaceCondition (0.00s) 

等等,这里发生了什么?这次我们的响应切片中只有两个元素。这是因为切片的内容 s 在加载和修改之间发生了变化,导致程序覆盖了一些结果。这种特殊的竞态条件是由数据竞争引起的,在这种情况下,多个 goroutine 尝试同时访问特定的共享变量,并且这些 goroutine 中的至少一个尝试修改它。(注意,以上结果并非一定如此,每次运行结果可能都不相同)

如果你使用 -race 标志执行测试,go 甚至会告诉你存在数据竞争并帮助你准确定位:

  1. $ go test race_condition_test.go -race 
  2.  
  3. ================== 
  4. WARNING: DATA RACE 
  5. Read at 0x00c000132048 by goroutine 9: 
  6.   command-line-arguments.Test_RaceCondition.func1() 
  7.       /home/sfinlay/go/src/benchmarks/race_condition_test.go:20 +0xb4 
  8.   command-line-arguments.Test_RaceCondition·dwrap·1() 
  9.       /home/sfinlay/go/src/benchmarks/race_condition_test.go:21 +0x47 
  10.  
  11. Previous write at 0x00c000132048 by goroutine 8: 
  12.   command-line-arguments.Test_RaceCondition.func1() 
  13.       /home/sfinlay/go/src/benchmarks/race_condition_test.go:20 +0x136 
  14.   command-line-arguments.Test_RaceCondition·dwrap·1() 
  15.       /home/sfinlay/go/src/benchmarks/race_condition_test.go:21 +0x47 
  16.  
  17. Goroutine 9 (running) created at
  18.   command-line-arguments.Test_RaceCondition() 
  19.       /home/sfinlay/go/src/benchmarks/race_condition_test.go:18 +0xc5 
  20.   testing.tRunner() 
  21.       /usr/local/go/src/testing/testing.go:1259 +0x22f 
  22.   testing.(*T).Run·dwrap·21() 
  23.       /usr/local/go/src/testing/testing.go:1306 +0x47 
  24.  
  25. Goroutine 8 (finished) created at
  26.   command-line-arguments.Test_RaceCondition() 
  27.       /home/sfinlay/go/src/benchmarks/race_condition_test.go:18 +0xc5 
  28.   testing.tRunner() 
  29.       /usr/local/go/src/testing/testing.go:1259 +0x22f 
  30.   testing.(*T).Run·dwrap·21() 
  31.       /usr/local/go/src/testing/testing.go:1306 +0x47 
  32. ================== 

02 并发控制

保护对这些共享资源的访问通常涉及常见的内存同步机制,例如通道或互斥锁。

这是将竞态条件调整为使用互斥锁的相同测试用例:

  1. func Test_NoRaceCondition(t *testing.T) { 
  2.  var s = make([]int, 0) 
  3.  
  4.  m := sync.Mutex{} 
  5.  wg := sync.WaitGroup{} 
  6.  
  7.  // spawn 10 goroutines to modify the slice in parallel 
  8.  for i := 0; i < 10; i++ { 
  9.   wg.Add(1) 
  10.   go func(i int) { 
  11.    m.Lock() 
  12.    defer wg.Done() 
  13.    defer m.Unlock() 
  14.    s = append(s, i) 
  15.   }(i) 
  16.  } 
  17.  
  18.  wg.Wait() 
  19.  
  20.  sort.Ints(s) //sort the response to have comparable results 
  21.  fmt.Println(s) 

这次它始终返回所有 10 个整数,因为它确保每个 goroutine 仅在没有其他人执行时才读写切片。如果第二个 goroutine 同时尝试获取锁,它必须等到前一个 goroutine 完成(即直到它解锁)。

然而,对于高吞吐量系统,性能变得非常重要,因此减少锁争用(即一个进程或线程试图获取另一个进程或线程持有的锁的情况)变得更加重要。执行此操作的最基本方法之一是使用读写锁 ( sync.RWMutex) 而不是标准 sync.Mutex,但是 Go 还提供了一些原子内存原语即 atomic 包。

03 原子

Go 的 atomic 包提供了用于实现同步算法的低级原子内存原语。这听起来像是我们需要的东西,所以让我们尝试用 atomic 重写该测试:

  1. import "sync/atomic" 
  2.  
  3. func Test_RaceCondition_Atomic(t *testing.T) { 
  4.  var s = atomic.Value{} 
  5.  s.Store([]int{}) // store empty slice as the base 
  6.  
  7.  wg := sync.WaitGroup{} 
  8.  
  9.  // spawn 10 goroutines to modify the slice in parallel 
  10.  for i := 0; i < 10; i++ { 
  11.   wg.Add(1) 
  12.   go func(i int) { 
  13.    defer wg.Done() 
  14.    s1 := s.Load().([]int
  15.    s.Store(append(s1, i)) //replace the slice with a new one containing the new item 
  16.   }(i) 
  17.  } 
  18.  
  19.  wg.Wait() 
  20.  
  21.  s1 := s.Load().([]int
  22.  sort.Ints(s1) //sort the response to have comparable results 
  23.  fmt.Println(s1) 

执行结果:

  1. === RUN Test_RaceCondition_Atomic 
  2.  
  3. [1 3] 
  4.  
  5. --- PASS: Test_RaceCondition_Atomic (0.00s) 

什么?这和我们之前遇到的问题完全一样,那么这个包有什么好处呢?

04 读取-复制-更新

atomic 不是灵丹妙药,它显然不能替代互斥锁,但是当涉及到可以使用读取-复制-更新[1]模式管理的共享资源时,它非常出色。在这种技术中,我们通过引用获取当前值,当我们想要更新它时,我们不修改原始值,而是替换指针(因此没有人访问另一个线程可能访问的相同资源)。前面的示例无法使用此模式实现,因为它应该随着时间的推移扩展现有资源而不是完全替换其内容,但在许多情况下,读取-复制-更新是完美的。

这是一个基本示例,我们可以在其中获取和存储布尔值(例如,对于功能标志很有用)。在这个例子中,我们正在执行一个并行基准测试,比较原子和读写互斥:

  1. package main 
  2.  
  3. import ( 
  4.  "sync" 
  5.  "sync/atomic" 
  6.  "testing" 
  7.  
  8. type AtomicValue struct{ 
  9.  value atomic.Value 
  10.  
  11. func (b *AtomicValue) Get() bool { 
  12.  return b.value.Load().(bool) 
  13.  
  14. func (b *AtomicValue) Set(value bool) { 
  15.  b.value.Store(value) 
  16.  
  17. func BenchmarkAtomicValue_Get(b *testing.B) { 
  18.  atomB := AtomicValue{} 
  19.  atomB.value.Store(false
  20.  
  21.  b.RunParallel(func(pb *testing.PB) { 
  22.   for pb.Next() { 
  23.    atomB.Get() 
  24.   } 
  25.  }) 
  26.  
  27. /************/ 
  28.  
  29. type MutexBool struct { 
  30.  mutex sync.RWMutex 
  31.  flag  bool 
  32.  
  33. func (mb *MutexBool) Get() bool { 
  34.  mb.mutex.RLock() 
  35.  defer mb.mutex.RUnlock() 
  36.  return mb.flag 
  37.  
  38. func BenchmarkMutexBool_Get(b *testing.B) { 
  39.  mb := MutexBool{flag: true
  40.  
  41.  b.RunParallel(func(pb *testing.PB) { 
  42.   for pb.Next() { 
  43.    mb.Get() 
  44.   } 
  45.  }) 

结果:

  1. cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz 
  2. BenchmarkAtomicValue_Get 
  3. BenchmarkAtomicValue_Get-8    1000000000          0.5472 ns/op 
  4. BenchmarkMutexBool_Get 
  5. BenchmarkMutexBool_Get-8      24966127            48.80 ns/op 

结果很清楚。atomic 的速度提高了 89 倍以上。并且可以通过使用更原始的类型来进一步改进:

  1. type AtomicBool struct{ flag int32 } 
  2.  
  3. func (b *AtomicBool) Get() bool { 
  4.  return atomic.LoadInt32(&(b.flag)) != 0 
  5.  
  6. func (b *AtomicBool) Set(value bool) { 
  7.  var i int32 = 0 
  8.  if value { 
  9.   i = 1 
  10.  } 
  11.  atomic.StoreInt32(&(b.flag), int32(i)) 
  12.  
  13. func BenchmarkAtomicBool_Get(b *testing.B) { 
  14.  atomB := AtomicBool{flag: 1} 
  15.  
  16.  b.RunParallel(func(pb *testing.PB) { 
  17.   for pb.Next() { 
  18.    atomB.Get() 
  19.   } 
  20.  }) 
  21. cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz 
  22. BenchmarkAtomicBool_Get 
  23. BenchmarkAtomicBool_Get-8     1000000000          0.3161 ns/op 

此版本比互斥锁版本快 154 倍以上。

写操作也显示出明显的差异(尽管规模并不那么令人印象深刻):

  1. func BenchmarkAtomicBool_Set(b *testing.B) { 
  2.  atomB := AtomicBool{flag: 1} 
  3.  
  4.  b.RunParallel(func(pb *testing.PB) { 
  5.   for pb.Next() { 
  6.    atomB.Set(true
  7.   } 
  8.  }) 
  9.  
  10. /************/ 
  11.  
  12. func BenchmarkAtomicValue_Set(b *testing.B) { 
  13.  atomB := AtomicValue{} 
  14.  atomB.value.Store(false
  15.  
  16.  b.RunParallel(func(pb *testing.PB) { 
  17.   for pb.Next() { 
  18.    atomB.Set(true
  19.   } 
  20.  }) 
  21.  
  22. /************/ 
  23.  
  24. func BenchmarkMutexBool_Set(b *testing.B) { 
  25.  mb := MutexBool{flag: true
  26.  
  27.  b.RunParallel(func(pb *testing.PB) { 
  28.   for pb.Next() { 
  29.    mb.Set(true
  30.   } 
  31.  }) 

结果:

  1. cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz 
  2. BenchmarkAtomicBool_Set 
  3. BenchmarkAtomicBool_Set-8     64624705         16.79 ns/op 
  4. BenchmarkAtomicValue_Set 
  5. BenchmarkAtomicValue_Set-8    47654121         26.43 ns/op 
  6. BenchmarkMutexBool_Set 
  7. BenchmarkMutexBool_Set-8      20124637         66.50 ns/op 

在这里我们可以看到 atomic 在写入时比在读取时慢得多,但仍然比互斥锁快得多。有趣的是,我们可以看到互斥锁读取和写入之间的差异不是很明显(慢 30%)。尽管如此, atomic 仍然表现得更好(比互斥锁快 2-4 倍)。

05 为什么 atomic 这么快?

简而言之,原子操作很快,因为它们依赖于原子 CPU 指令而不是依赖外部锁。使用互斥锁时,每次获得锁时,goroutine 都会短暂暂停或中断,这种阻塞占使用互斥锁所花费时间的很大一部分。原子操作可以在没有任何中断的情况下执行。

06 atomic 总是答案吗?

正如我们在一个早期示例中已经证明的那样,atomic 无法解决所有问题,某些操作只能使用互斥锁来解决。

考虑以下示例,该示例演示了我们使用 map 作为内存缓存的常见模式:

  1. package main 
  2.  
  3. import ( 
  4.  "sync" 
  5.  "sync/atomic" 
  6.  "testing" 
  7.  
  8. //Don't use this implementation! 
  9. type AtomicCacheMap struct { 
  10.  value atomic.Value //map[int]int 
  11.  
  12. func (b *AtomicCacheMap) Get(key intint { 
  13.  return b.value.Load().(map[int]int)[key
  14.  
  15. func (b *AtomicCacheMap) Set(key, value int) { 
  16.  oldMap := b.value.Load().(map[int]int
  17.  newMap := make(map[int]int, len(oldMap)+1) 
  18.  for k, v := range oldMap { 
  19.   newMap[k] = v 
  20.  } 
  21.  newMap[key] = value 
  22.  b.value.Store(newMap) 
  23.  
  24. func BenchmarkAtomicCacheMap_Get(b *testing.B) { 
  25.  atomM := AtomicCacheMap{} 
  26.  atomM.value.Store(testMap) 
  27.  
  28.  b.RunParallel(func(pb *testing.PB) { 
  29.   for pb.Next() { 
  30.    atomM.Get(0) 
  31.   } 
  32.  }) 
  33.  
  34. func BenchmarkAtomicCacheMap_Set(b *testing.B) { 
  35.  atomM := AtomicCacheMap{} 
  36.  atomM.value.Store(testMap) 
  37.  
  38.  var i = 0 
  39.  b.RunParallel(func(pb *testing.PB) { 
  40.   for pb.Next() { 
  41.    atomM.Set(i, i) 
  42.    i++ 
  43.   } 
  44.  }) 
  45.  
  46. /************/ 
  47.  
  48. type MutexCacheMap struct { 
  49.  mutex sync.RWMutex 
  50.  value map[int]int 
  51.  
  52. func (mm *MutexCacheMap) Get(key intint { 
  53.  mm.mutex.RLock() 
  54.  defer mm.mutex.RUnlock() 
  55.  return mm.value[key
  56.  
  57. func (mm *MutexCacheMap) Set(key, value int) { 
  58.  mm.mutex.Lock() 
  59.  defer mm.mutex.Unlock() 
  60.  mm.value[key] = value 
  61.  
  62. func BenchmarkMutexCacheMap_Get(b *testing.B) { 
  63.  mb := MutexCacheMap{value: testMap} 
  64.  
  65.  b.RunParallel(func(pb *testing.PB) { 
  66.   for pb.Next() { 
  67.    mb.Get(0) 
  68.   } 
  69.  }) 
  70.  
  71. func BenchmarkMutexCacheMap_Set(b *testing.B) { 
  72.  mb := MutexCacheMap{value: testMap} 
  73.  
  74.  var i = 0 
  75.  b.RunParallel(func(pb *testing.PB) { 
  76.   for pb.Next() { 
  77.    mb.Set(i, i) 
  78.    i++ 
  79.   } 
  80.  }) 

结果:

  1. cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz 
  2. BenchmarkAtomicCacheMap_Get 
  3. BenchmarkAtomicCacheMap_Get-8    301664540           4.194 ns/op 
  4. BenchmarkAtomicCacheMap_Set 
  5. BenchmarkAtomicCacheMap_Set-8       87637            95889 ns/op 
  6. BenchmarkMutexCacheMap_Get 
  7. BenchmarkMutexCacheMap_Get-8     20000959            54.63 ns/op 
  8. BenchmarkMutexCacheMap_Set 
  9. BenchmarkMutexCacheMap_Set-8      5012434            267.2 ns/op 

哎呀,这种表现是痛苦的。这意味着,当必须复制大型结构时,atomic 的性能非常差。不仅如此,此代码还包含竞态条件。就像本文开头的切片案例一样,原子缓存示例具有竞态条件,其中可能会在复制 map 和存储 map 的时间之间添加新的缓存条目,在这种情况下,新条目将丢失。在这种情况下,该 -race 标志不会检测到任何数据竞争,因为没有对同一 map 的并发访问。

07 注意事项

Go 的文档[2]警告了 atomic 包的潜在误用:

这些函数需要非常小心才能正确使用。除了特殊的低级应用程序,同步最好使用通道或 sync 包的工具来完成。通过通信共享内存;不要通过共享内存进行通信。

开始使用 atomic 包时,你可能会遇到的第一个问题是:

  1. panic: sync/atomic: store of inconsistently typed value into Value 

使用 atomic.Store,确保每次调用方法时都存储完全相同的类型很重要。这听起来很容易,但通常并不像听起来那么简单:

  1. package main 
  2.  
  3. import ( 
  4.  "fmt" 
  5.  "sync/atomic" 
  6.  
  7. //Our own custom error type which implements the error interface 
  8. type CustomError struct { 
  9.  Code    int 
  10.  Message string 
  11.  
  12. func (e CustomError) Error() string { 
  13.  return fmt.Sprintf("%d: %s", e.Code, e.Message) 
  14.  
  15. func InternalServerError(msg string) error { 
  16.  return CustomError{Code: 500, Message: msg} 
  17.  
  18. func main() { 
  19.  var ( 
  20.   err1 error = fmt.Errorf("error happened"
  21.   err2 error = InternalServerError("another error happened"
  22.  ) 
  23.  
  24.  errVal := atomic.Value{} 
  25.  errVal.Store(err1) 
  26.  errVal.Store(err2) //panics here 

两个值都是 error 类型是不够的,因为它们只是实现了错误接口。它们的具体类型仍然不同,因此 atomic 不喜欢它。

08 总结

竞态条件很糟糕,应该保护对共享资源的访问。互斥体很酷,但由于锁争用而趋于缓慢。对于某些读取-复制-更新模式有意义的情况(这往往是动态配置之类的东西,例如特性标志、日志级别或 map 或结构体,一次填充例如通过 JSON 解析等),尤其是当读取次数比写入次数多时。atomic 通常不应用于其他用例(例如,随时间增长的变量,如缓存),并且该特性的使用需要非常小心。

可能最重要的方法是将锁保持在最低限度,如果你在在考虑原子等替代方案,请务必在投入生产之前对其进行广泛的测试和试验。

原文链接:https://www.sixt.tech/golangs-atomic

参考资料

[1]读取-复制-更新: https://en.wikipedia.org/wiki/Read-copy-update

[2]文档: https://pkg.go.dev/sync/atomic

本文转载自微信公众号「幽鬼」,可以通过以下二维码关注。转载本文请联系幽鬼公众号。

 

责任编辑:武晓燕 来源: 幽鬼
相关推荐

2016-09-27 21:25:08

Go语言Ken Thompso

2022-06-07 08:39:35

RPCHTTP

2021-04-09 09:55:55

DockerGoLinux

2019-08-05 14:23:43

DockerKubernetes容器

2021-11-12 07:21:51

Go线程安全

2020-11-25 09:36:17

HTTPRPC远程

2023-01-12 09:01:01

MongoDBMySQL

2023-09-12 14:02:30

数组vector

2023-12-11 12:03:14

Python工具元组

2020-02-27 21:03:30

调度器架构效率

2017-01-18 09:42:11

Go

2021-10-12 18:48:07

HTTP 协议Websocket网络通信

2021-12-20 10:30:33

forforEach前端

2022-09-13 08:44:02

IP网络MAC地址

2022-01-12 20:04:09

网络故障断网事件网络安全

2020-04-07 16:12:56

Go编程语言开发

2023-10-24 15:15:26

HTTPWebSocket

2024-04-16 08:26:18

IP地址MAC地址

2022-01-12 09:31:18

Go 变量方式

2021-12-13 20:09:33

GoElasticsearJava
点赞
收藏

51CTO技术栈公众号