Go:有了 sync 為什麼還有 atomic?

2021-12-22 Go語言中文網

atomic

Go 是一種擅長並發的語言,啟動新的 goroutine 就像輸入 「go」 一樣簡單。隨著你發現自己構建的系統越來越複雜,正確保護對共享資源的訪問以防止競爭條件變得極其重要。此類資源可能包括可即時更新的配置(例如功能標誌)、內部狀態(例如斷路器狀態)等。

01 什麼是競態條件?

對於大多數讀者來說,這可能是基礎知識,但由於本文的其餘部分取決於對競態條件的理解,因此有必要進行簡短的複習。競態條件是一種情況,在這種情況下,程序的行為取決於其他不可控事件的順序或時間。在大多數情況下,這種情況是一個錯誤,因為可能會發生不希望的結果。

舉個具體的例子或許更容易理解:

// race_condition_test.go
package main

import (
 "fmt"
 "sort"
 "sync"
 "testing"
)

func Test_RaceCondition(t *testing.T) {
 var s = make([]int, 0)

 wg := sync.WaitGroup{}

 // spawn 10 goroutines to modify the slice in parallel
 for i := 0; i < 10; i++ {
  wg.Add(1)
  go func(i int) {
   defer wg.Done()
   s = append(s, i) //add a new item to the slice
  }(i)
 }

 wg.Wait()
 
 sort.Ints(s) //sort the response to have comparable results
 fmt.Println(s)
}

執行一:

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

這裡看起來一切都很好。這是我們預期的輸出。該程序迭代了 10 次,並在每次迭代時將索引添加到切片中。

執行二:

=== RUN   Test_RaceCondition
[0 3]
--- PASS: Test_RaceCondition (0.00s)

等等,這裡發生了什麼?這次我們的響應切片中只有兩個元素。這是因為切片的內容 s 在加載和修改之間發生了變化,導致程序覆蓋了一些結果。這種特殊的競態條件是由數據競爭引起的,在這種情況下,多個 goroutine 嘗試同時訪問特定的共享變量,並且這些 goroutine 中的至少一個嘗試修改它。(注意,以上結果並非一定如此,每次運行結果可能都不相同)

如果你使用 -race 標誌執行測試,go 甚至會告訴你存在數據競爭並幫助你準確定位:

$ go test race_condition_test.go -race

==================
WARNING: DATA RACE
Read at 0x00c000132048 by goroutine 9:
  command-line-arguments.Test_RaceCondition.func1()
      /home/sfinlay/go/src/benchmarks/race_condition_test.go:20 +0xb4
  command-line-arguments.Test_RaceCondition·dwrap·1()
      /home/sfinlay/go/src/benchmarks/race_condition_test.go:21 +0x47

Previous write at 0x00c000132048 by goroutine 8:
  command-line-arguments.Test_RaceCondition.func1()
      /home/sfinlay/go/src/benchmarks/race_condition_test.go:20 +0x136
  command-line-arguments.Test_RaceCondition·dwrap·1()
      /home/sfinlay/go/src/benchmarks/race_condition_test.go:21 +0x47

Goroutine 9 (running) created at:
  command-line-arguments.Test_RaceCondition()
      /home/sfinlay/go/src/benchmarks/race_condition_test.go:18 +0xc5
  testing.tRunner()
      /usr/local/go/src/testing/testing.go:1259 +0x22f
  testing.(*T).Run·dwrap·21()
      /usr/local/go/src/testing/testing.go:1306 +0x47

Goroutine 8 (finished) created at:
  command-line-arguments.Test_RaceCondition()
      /home/sfinlay/go/src/benchmarks/race_condition_test.go:18 +0xc5
  testing.tRunner()
      /usr/local/go/src/testing/testing.go:1259 +0x22f
  testing.(*T).Run·dwrap·21()
      /usr/local/go/src/testing/testing.go:1306 +0x47
==================

02 並發控制

保護對這些共享資源的訪問通常涉及常見的內存同步機制,例如通道或互斥鎖。

這是將競態條件調整為使用互斥鎖的相同測試用例:

func Test_NoRaceCondition(t *testing.T) {
 var s = make([]int, 0)

 m := sync.Mutex{}
 wg := sync.WaitGroup{}

 // spawn 10 goroutines to modify the slice in parallel
 for i := 0; i < 10; i++ {
  wg.Add(1)
  go func(i int) {
   m.Lock()
   defer wg.Done()
   defer m.Unlock()
   s = append(s, i)
  }(i)
 }

 wg.Wait()

 sort.Ints(s) //sort the response to have comparable results
 fmt.Println(s)
}

這次它始終返回所有 10 個整數,因為它確保每個 goroutine 僅在沒有其他人執行時才讀寫切片。如果第二個 goroutine 同時嘗試獲取鎖,它必須等到前一個 goroutine 完成(即直到它解鎖)。

然而,對於高吞吐量系統,性能變得非常重要,因此減少鎖爭用(即一個進程或線程試圖獲取另一個進程或線程持有的鎖的情況)變得更加重要。執行此操作的最基本方法之一是使用讀寫鎖 ( sync.RWMutex) 而不是標準 sync.Mutex,但是 Go 還提供了一些原子內存原語即 atomic 包。

03 原子

Go 的 atomic 包提供了用於實現同步算法的低級原子內存原語。這聽起來像是我們需要的東西,所以讓我們嘗試用 atomic 重寫該測試:

import "sync/atomic"

func Test_RaceCondition_Atomic(t *testing.T) {
 var s = atomic.Value{}
 s.Store([]int{}) // store empty slice as the base

 wg := sync.WaitGroup{}

 // spawn 10 goroutines to modify the slice in parallel
 for i := 0; i < 10; i++ {
  wg.Add(1)
  go func(i int) {
   defer wg.Done()
   s1 := s.Load().([]int)
   s.Store(append(s1, i)) //replace the slice with a new one containing the new item
  }(i)
 }

 wg.Wait()

 s1 := s.Load().([]int)
 sort.Ints(s1) //sort the response to have comparable results
 fmt.Println(s1)
}

執行結果:

=== RUN   Test_RaceCondition_Atomic
[1 3]
--- PASS: Test_RaceCondition_Atomic (0.00s)

什麼?這和我們之前遇到的問題完全一樣,那麼這個包有什麼好處呢?

04 讀取-複製-更新

atomic 不是靈丹妙藥,它顯然不能替代互斥鎖,但是當涉及到可以使用讀取-複製-更新[1]模式管理的共享資源時,它非常出色。在這種技術中,我們通過引用獲取當前值,當我們想要更新它時,我們不修改原始值,而是替換指針(因此沒有人訪問另一個線程可能訪問的相同資源)。前面的示例無法使用此模式實現,因為它應該隨著時間的推移擴展現有資源而不是完全替換其內容,但在許多情況下,讀取-複製-更新是完美的。

這是一個基本示例,我們可以在其中獲取和存儲布爾值(例如,對於功能標誌很有用)。在這個例子中,我們正在執行一個並行基準測試,比較原子和讀寫互斥:

package main

import (
 "sync"
 "sync/atomic"
 "testing"
)

type AtomicValue struct{
 value atomic.Value
}

func (b *AtomicValue) Get() bool {
 return b.value.Load().(bool)
}

func (b *AtomicValue) Set(value bool) {
 b.value.Store(value)
}

func BenchmarkAtomicValue_Get(b *testing.B) {
 atomB := AtomicValue{}
 atomB.value.Store(false)

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   atomB.Get()
  }
 })
}

/************/

type MutexBool struct {
 mutex sync.RWMutex
 flag  bool
}

func (mb *MutexBool) Get() bool {
 mb.mutex.RLock()
 defer mb.mutex.RUnlock()
 return mb.flag
}

func BenchmarkMutexBool_Get(b *testing.B) {
 mb := MutexBool{flag: true}

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   mb.Get()
  }
 })
}

結果:

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

結果很清楚。atomic 的速度提高了 89 倍以上。並且可以通過使用更原始的類型來進一步改進:

type AtomicBool struct{ flag int32 }

func (b *AtomicBool) Get() bool {
 return atomic.LoadInt32(&(b.flag)) != 0
}

func (b *AtomicBool) Set(value bool) {
 var i int32 = 0
 if value {
  i = 1
 }
 atomic.StoreInt32(&(b.flag), int32(i))
}

func BenchmarkAtomicBool_Get(b *testing.B) {
 atomB := AtomicBool{flag: 1}

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   atomB.Get()
  }
 })
}
cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz
BenchmarkAtomicBool_Get
BenchmarkAtomicBool_Get-8     1000000000          0.3161 ns/op

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

寫操作也顯示出明顯的差異(儘管規模並不那麼令人印象深刻):

func BenchmarkAtomicBool_Set(b *testing.B) {
 atomB := AtomicBool{flag: 1}

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   atomB.Set(true)
  }
 })
}

/************/

func BenchmarkAtomicValue_Set(b *testing.B) {
 atomB := AtomicValue{}
 atomB.value.Store(false)

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   atomB.Set(true)
  }
 })
}

/************/

func BenchmarkMutexBool_Set(b *testing.B) {
 mb := MutexBool{flag: true}

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   mb.Set(true)
  }
 })
}

結果:

cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz
BenchmarkAtomicBool_Set
BenchmarkAtomicBool_Set-8     64624705         16.79 ns/op
BenchmarkAtomicValue_Set
BenchmarkAtomicValue_Set-8    47654121         26.43 ns/op
BenchmarkMutexBool_Set
BenchmarkMutexBool_Set-8      20124637         66.50 ns/op

在這裡我們可以看到 atomic 在寫入時比在讀取時慢得多,但仍然比互斥鎖快得多。有趣的是,我們可以看到互斥鎖讀取和寫入之間的差異不是很明顯(慢 30%)。儘管如此, atomic 仍然表現得更好(比互斥鎖快 2-4 倍)。

05 為什麼 atomic 這麼快?

簡而言之,原子操作很快,因為它們依賴於原子 CPU 指令而不是依賴外部鎖。使用互斥鎖時,每次獲得鎖時,goroutine 都會短暫暫停或中斷,這種阻塞佔使用互斥鎖所花費時間的很大一部分。原子操作可以在沒有任何中斷的情況下執行。

06 atomic 總是答案嗎?

正如我們在一個早期示例中已經證明的那樣,atomic 無法解決所有問題,某些操作只能使用互斥鎖來解決。

考慮以下示例,該示例演示了我們使用 map 作為內存緩存的常見模式:

package main

import (
 "sync"
 "sync/atomic"
 "testing"
)

//Don't use this implementation!
type AtomicCacheMap struct {
 value atomic.Value //map[int]int
}

func (b *AtomicCacheMap) Get(key int) int {
 return b.value.Load().(map[int]int)[key]
}

func (b *AtomicCacheMap) Set(key, value int) {
 oldMap := b.value.Load().(map[int]int)
 newMap := make(map[int]int, len(oldMap)+1)
 for k, v := range oldMap {
  newMap[k] = v
 }
 newMap[key] = value
 b.value.Store(newMap)
}

func BenchmarkAtomicCacheMap_Get(b *testing.B) {
 atomM := AtomicCacheMap{}
 atomM.value.Store(testMap)

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   atomM.Get(0)
  }
 })
}

func BenchmarkAtomicCacheMap_Set(b *testing.B) {
 atomM := AtomicCacheMap{}
 atomM.value.Store(testMap)

 var i = 0
 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   atomM.Set(i, i)
   i++
  }
 })
}

/************/

type MutexCacheMap struct {
 mutex sync.RWMutex
 value map[int]int
}

func (mm *MutexCacheMap) Get(key int) int {
 mm.mutex.RLock()
 defer mm.mutex.RUnlock()
 return mm.value[key]
}

func (mm *MutexCacheMap) Set(key, value int) {
 mm.mutex.Lock()
 defer mm.mutex.Unlock()
 mm.value[key] = value
}

func BenchmarkMutexCacheMap_Get(b *testing.B) {
 mb := MutexCacheMap{value: testMap}

 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   mb.Get(0)
  }
 })
}

func BenchmarkMutexCacheMap_Set(b *testing.B) {
 mb := MutexCacheMap{value: testMap}

 var i = 0
 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   mb.Set(i, i)
   i++
  }
 })
}

結果:

cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz
BenchmarkAtomicCacheMap_Get
BenchmarkAtomicCacheMap_Get-8    301664540           4.194 ns/op
BenchmarkAtomicCacheMap_Set
BenchmarkAtomicCacheMap_Set-8       87637            95889 ns/op
BenchmarkMutexCacheMap_Get
BenchmarkMutexCacheMap_Get-8     20000959            54.63 ns/op
BenchmarkMutexCacheMap_Set
BenchmarkMutexCacheMap_Set-8      5012434            267.2 ns/op

哎呀,這種表現是痛苦的。這意味著,當必須複製大型結構時,atomic 的性能非常差。不僅如此,此代碼還包含競態條件。就像本文開頭的切片案例一樣,原子緩存示例具有競態條件,其中可能會在複製 map 和存儲 map 的時間之間添加新的緩存條目,在這種情況下,新條目將丟失。在這種情況下,該 -race 標誌不會檢測到任何數據競爭,因為沒有對同一 map 的並發訪問。

07 注意事項

Go 的文檔[2]警告了 atomic 包的潛在誤用:

這些函數需要非常小心才能正確使用。除了特殊的低級應用程式,同步最好使用通道或 sync 包的工具來完成。通過通信共享內存;不要通過共享內存進行通信。

開始使用 atomic 包時,你可能會遇到的第一個問題是:

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

使用 atomic.Store,確保每次調用方法時都存儲完全相同的類型很重要。這聽起來很容易,但通常並不像聽起來那麼簡單:

package main

import (
 "fmt"
 "sync/atomic"
)

//Our own custom error type which implements the error interface
type CustomError struct {
 Code    int
 Message string
}

func (e CustomError) Error() string {
 return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

func InternalServerError(msg string) error {
 return CustomError{Code: 500, Message: msg}
}

func main() {
 var (
  err1 error = fmt.Errorf("error happened")
  err2 error = InternalServerError("another error happened")
 )

 errVal := atomic.Value{}
 errVal.Store(err1)
 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

相關焦點

  • GO的空結構體struct{}到底有什麼用?
    type S struct{}func (s *S) addr() { fmt.Printf("%p\n", s) }func main() {  var a, b S  a.addr()  b.addr()}四、chan struct{}在Go語言中,有一種特殊的struct{}類型的channel。
  • 深度閱讀之《Concurrency in Go》
    輸入多了之後,會發現很多中文文章很難讀,可能還有很多錯漏之處。不客氣地說,輸入的是垃圾,輸出的只能是垃圾。曹大經常說需要多看英文資料[1],包括各種新出的英文書、文章等等,這從他的書單[2]也可以看出來。我自己的情況是:英文資料讀的不多,英文技術書則基本就沒完整地讀過一本。之前在寫文章的過程中,還是看了一些英文文章,收穫很大。
  • Lip Sync Battle is on. (主頁君首次出鏡,最後有彩蛋哦 closing credits)
    請問 現在還有人不知道火遍全球的對口型大賽麼?
  • 16款微星Freesync顯示器通過G-Sync兼容測試
    本周,微星(MSI)宣布,旗下16款VESA adaptive顯示器(Freesync)經測試,可完美調用G-Sync兼容模式,包括Optix G24C/MAG24C/MAG241C/MAG241CR等。遺憾的是,Optix G24VC和G241VC沒有通過兼容測試(主要是因為沒原生DP口)。據悉,G-Sync兼容可在NVIDIA控制面板中打開。
  • Go 1.10中值得關注的幾個變化
    接下來,我就和大家一起看看即將發布的Go 1.10都有哪些值得重點關注的變化。一、語言Go language Spec是當前Go語言的唯一語言規範標準,雖然其嚴謹性與那些以ISO標準形式編寫成的語言規範(比如:C語言、C++語言的規範)還有一定差距。因此,對go spec的優化,就是在嚴謹性方面下功夫。
  • GO編程模式系列(一):切片,接口,時間和性能
    其中,主要包括,數組切片的一些小坑,還有接口編程,以及時間和程序運行性能相關的話題。   cap   int           //容量有多大}用圖示來看,一個空的slice的表現如下:那麼,我們有沒有更好的方法?下面是另外一個解。
  • 開學第一天「去上學」為什麼在英語裡是go to school,而不加the?
    為什麼在英語裡「去上學」是"go to school",而不是"go to the school"?我們來簡單了解一下一般情況下,我們說「去……」,這個地方是特指的,或說話雙方的知道的,要加冠詞的,比如:go to the bank 去銀行go to the grocery store去雜貨店這些表達裡名詞都可以理解為具體名詞,我們專門指哪家銀行或雜貨店。
  • Go語言入門分享
    /hello就可以執行了;或者直接 go run hello.go 合二為一去執行。執行這個命令並不需要設置環境變量就可以了。看起來和c差不多,但是和Java不一樣,運行的時候不需要虛擬機。早期的GO工程也是使用Makefile來編譯,後來有了強大的命令 go build、go run,可以直接識別目錄還是文件。
  • LG 27UL650 27英寸顯示器(4K、HDR400、sRGB99%、FreeSync)白色...
    【PConline 聚超值】 4K解析度,支持 freesync LG 27UL650顯示器採用27英寸屏幕,比例為16:9,解析度3840x2160,面板類型為IPS,響應時間5ms,最大亮度350nit。
  • Once you go black,never you go back!
    「Once you go black,never you go back!」(一旦妳試過黑人,妳就永遠回不去)為什麼我要用加大的字號?可能確實變大了,回不去了吧..中國長沙女大生王燁,半年前與迦納男友傑森交往,不時就在網路曬恩愛,炫耀男友有多溫柔體貼。家人雖然也擔心,但沒有多說什麼。
  • go home為什麼不加to?
    go這個詞,是一個不及物動詞。也就是說,它的後面是不能直接跟名詞的。所以,go的後面常常跟「介詞」「副詞」。我們從go這個單詞出發,學一些語法知識。一、go+to(介詞)+名詞go代表(出發、去)的意思。to是一個介詞,代表著方向性,朝著什麼地方,到什麼地方。所以,to後面跟上名詞,就是要去的「目的地」。
  • 為什麼圍棋的英文是Go而不是Weiqi呢
    alpha(α),希臘字母表的第一個字母,有第一個、開端、最初的含意。與「Go」連在一起可謂一語雙關,既可以理解為「最初一步」,也可以看做「第一圍棋」,因為英語對圍棋的稱呼,正是「Go」。 但是,大家有沒有想過為什麼圍棋的英文是「Go」,而不是Weiqi呢? 圍棋起源於中國,屬於琴棋書畫四藝之一。
  • gofair付費會員權限,除了youtube還有什麼適合海外推廣產品
    gofair付費會員權限,除了youtube還有什麼適合海外推廣產品的國際性視頻站?除了youtube還有什麼適合海外推廣產品的國際性視頻站?外貿營銷時,視頻營銷現在是關鍵!沒視頻老外不願搭理你,有視頻你就高人一等,就這麼簡單!由於種種原因,許多海外視頻站現在國內都陸續屏蔽了,有些甚至屏蔽得越來越徹底了今天就來談談目前適合產品視頻存放的國際三大視頻站: youtube、vimeo、gofair。
  • Go mod 之痛
    本文會列舉一些我和我的同事使用 go mod 時碰到的問題,有些問題是 go mod 本身的問題,有些可能是第三方 goproxy 實現的問題。如果你做過比較大型的 go 項目開發,相信總會有那麼幾個讓你會心一笑。Go 命令的副作用從老版本一路升級過來的 gopher 很難理解為什麼升級了新版本之後,go fmt 一個文件都變得非常卡頓。
  • 【偉哥音樂推薦】不為什麼堅持系列——Won't Go Home Without You
    won't go home without you Maroon 5 I asked her to stay 我請她留下 But she wouldn't listen 但她不會聽的 She left before I had the chance
  • Go error 處理最佳實踐
    顯然不是,尤其喜歡 c 語言的人,反而喜歡每次都做判斷在我看來 go 的痛點不是缺少泛型,不是 error 太挫,而是 GC 太弱,尤其對大內存非常不友好,這方面可以參考真實環境下大內存 Go 服務性能優化一例當前 error 的問題有兩點:
  • Go的過去式為何是Went?
    這段介紹對讀者們而言或許多餘,但我寫它是想說明兩件事。第一,有時解答一個貌似不咋樣的問題老折騰了。第二,這個插曲可以警醒我們。這本探討異幹互補來源的主要著作是一本百年前的"著名"的書,在它之前還有很多重要的先行者。就像很多研究者說的,「每一個人」都知道它。不過,顯然,這本書並不是名震天下,許多歷史語言學老學究折騰了好多年也不知道它。