Go併發程式設計之原子操作sync/atomic

coding進階發表於2022-05-10

sync/atomic定義

官方文件地址:https://pkg.go.dev/sync/atomi...

Go語言標準庫中的sync/atomic包提供了偏底層的原子記憶體原語(atomic memory primitives),用於實現同步演算法,其本質是將底層CPU提供的原子操作指令封裝成了Go函式。

使用sync/atomic提供的原子操作可以確保在任意時刻只有一個goroutine對變數進行操作,避免併發衝突。

使用sync/atomic需要特別小心,Go官方建議只有在一些偏底層的應用場景裡才去使用sync/atomic,其它場景建議使用channel或者sync包裡的鎖。

Share memory by communicating; don't communicate by sharing memory.

sync/atomic提供了5種型別的原子操作和1個Value型別。

5種型別的原子操作

  • swap操作:SwapXXX
  • compare-and-swap操作:CompareAndSwapXXX
  • add操作:AddXXX
  • load操作:LoadXXX
  • store操作:StoreXXX

這幾種型別的原子操作只支援幾個基本的資料型別。

add操作的Addxxx函式只支援int32, int64, uint32, uint64, uintptr這5種基本資料型別。

其它型別的操作函式只支援int32, int64, uint32, uint64, uintptr, unsafe.Pointer這6種基本資料型別。

Value型別

由於上面5種型別的原子操作只支援幾種基本的資料型別,因此為了擴大原子操作的使用範圍,Go團隊在1.4版本的sync/atomic包中引入了一個新的型別ValueValue型別可以用來讀取(Load)和修改(Store)任意型別的值。

Go 1.4版本的Value型別只有LoadStore2個方法,Go 1.17版本又給Value型別新增了CompareAndSwapSwap這2個新方法。

sync/atomic實踐

swap操作

swap操作支援int32, int64, uint32, uint64, uintptr, unsafe.Pointer這6種基本資料型別,對應有6個swap操作函式。

func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)

swap操作實現的功能是把addr 指標指向的記憶體裡的值替換為新值new,然後返回舊值old,是如下虛擬碼的原子實現:

old = *addr
*addr = new
return old

我們拿SwapInt32舉個例子:

// swap.go
package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var newValue int32 = 200
    var dst int32 = 100
    // 把dst的值替換為newValue
    old := atomic.SwapInt32(&dst, newValue)
    // 列印結果
    fmt.Println("old value: ", old, " new value:", dst)
}

上面程式的執行結果如下:

old value:  100  new value: 200

compare-and-swap操作

compare-and-swap(CAS)操作支援int32, int64, uint32, uint64, uintptr, unsafe.Pointer這6種基本資料型別,對應有6個compare-and-swap操作函式。

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)

compare-and-swap操作實現的功能是先比較addr 指標指向的記憶體裡的值是否為舊值old相等。

  • 如果相等,就把addr指標指向的記憶體裡的值替換為新值new,並返回true,表示操作成功。
  • 如果不相等,直接返回false,表示操作失敗。

compare-and-swap操作是如下虛擬碼的原子實現:

if *addr == old {
    *addr = new
    return true
}
return false

我們拿CompareAndSwapInt32舉個例子:

// compare-and-swap.go
package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var dst int32 = 100
    oldValue := atomic.LoadInt32(&dst)
    var newValue int32 = 200
    // 先比較dst的值和oldValue的值,如果相等,就把dst的值替換為newValue
    swapped := atomic.CompareAndSwapInt32(&dst, oldValue, newValue)
    // 列印結果
    fmt.Printf("old value: %d, swapped value: %d, swapped success: %v\n", oldValue, dst, swapped)
}

上面程式的執行結果如下:

old value: 100, swapped value: 200, swapped success: true

add操作

add操作支援int32, int64, uint32, uint64, uintptr這5種基本資料型別,對應有5個add操作函式。

func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)

add操作實現的功能是把addr 指標指向的記憶體裡的值和delta做加法,然後返回新值,是如下虛擬碼的原子實現:

*addr += delta
return *addr

我們拿AddInt32舉個例子:

// add.go
package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

var wg sync.WaitGroup

// 多個goroutine併發讀寫sum,有併發衝突,最終計算得到的sum值是不準確的
func test1() {
    var sum int32 = 0
    N := 100
    wg.Add(N)
    for i := 0; i < N; i++ {
        go func(i int32) {
            sum += i
            wg.Done()
        }(int32(i))
    }
    wg.Wait()
    fmt.Println("func test1, sum=", sum)
}

// 使用原子操作計算sum,沒有併發衝突,最終計算得到sum的值是準確的
func test2() {
    var sum int32 = 0
    N := 100
    wg.Add(N)
    for i := 0; i < N; i++ {
        go func(i int32) {
            atomic.AddInt32(&sum, i)
            wg.Done()
        }(int32(i))
    }
    wg.Wait()
    fmt.Println("func test2, sum=", sum)
}

func main() {
    test1()
    test2()
}

上面程式的執行結果如下:

func test1, sum= 4857
func test2, sum= 4950

注意:對於test1函式,你本地執行得到的結果可能和我的不一樣,這個值並不是一個固定值。

load操作

load操作支援int32, int64, uint32, uint64, uintptr, unsafe.Pointer這6種基本資料型別,對應有6個load操作函式。

func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)

load操作實現的功能是返回addr 指標指向的記憶體裡的值,是如下虛擬碼的原子實現:

return *addr

我們拿LoadInt32舉個例子:

// load.go
package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var sum int32 = 100
    result := atomic.LoadInt32(&sum)
    fmt.Println("result=", result)
}

上面程式的執行結果如下:

result= 100

store操作

store操作支援int32, int64, uint32, uint64, uintptr, unsafe.Pointer這6種基本資料型別,對應有6個store操作函式。

func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)

store操作實現的功能是把addr 指標指向的記憶體裡的值修改為val,是如下虛擬碼的原子實現:

*addr = val

我們拿StoreInt32舉個例子:

// store.go
package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var sum int32 = 100
    var newValue int32 = 200
    // 將sum的值修改為newValue
    atomic.StoreInt32(&sum, newValue)
    // 讀取修改後的sum值
    result := atomic.LoadInt32(&sum)
    // 列印結果
    fmt.Println("result=", result)
}

上面程式的執行結果如下:

result= 200

Value型別

Go標準庫裡的sync/atomic包提供了Value型別,可以用來併發讀取和修改任何型別的值。

Value型別的定義如下:

// A Value provides an atomic load and store of a consistently typed value.
// The zero value for a Value returns nil from Load.
// Once Store has been called, a Value must not be copied.
//
// A Value must not be copied after first use.
type Value struct {
    v any
}

Value型別有4個方法:CompareAndSwap, Load, Store, Swap,定義如下:

func (v *Value) CompareAndSwap(old, new any) (swapped bool)
func (v *Value) Load() (val any)
func (v *Value) Store(val any)
func (v *Value) Swap(new any) (old any)

原始碼實現:https://cs.opensource.google/...

下面是一個具體的示例:對map[string][string]型別做併發讀寫,為了避免加鎖,使用value型別來讀取和修改map[string][string]

package main

import (
    "sync/atomic"
    "time"
)

func loadConfig() map[string]string {
    // 從資料庫或者檔案系統中讀取配置資訊,然後以map的形式存放在記憶體裡
    return make(map[string]string)
}

func requests() chan int {
    // 將從外界中接收到的請求放入到channel裡
    return make(chan int)
}

func main() {
    // config變數用來存放該服務的配置資訊
    var config atomic.Value
    // 初始化時從別的地方載入配置檔案,並存到config變數裡
    config.Store(loadConfig())
    go func() {
        // 每10秒鐘定時拉取最新的配置資訊,並且更新到config變數裡
        for {
            time.Sleep(10 * time.Second)
            // 對應於賦值操作 config = loadConfig()
            config.Store(loadConfig())
        }
    }()
    // 建立協程,每個工作協程都會根據它所讀取到的最新的配置資訊來處理請求
    for i := 0; i < 10; i++ {
        go func() {
            for r := range requests() {
                // 對應於取值操作 c := config
                // 由於Load()返回的是一個interface{}型別,所以我們要先強制轉換一下
                c := config.Load().(map[string]string)
                // 這裡是根據配置資訊處理請求的邏輯...
                _, _ = r, c
            }
        }()
    }
}

總結和注意事項

  • 原子操作由底層CPU的原子操作指令支援。
  • 5種原子操作和Value型別的官方文件地址:https://pkg.go.dev/sync/atomi...
  • CAS操作會有ABA問題
  • 對於386處理器架構,64-bit原子操作函式使用了奔騰MMX或更新處理器型號才支援的CPU指令。對於非Linux的ARM處理器架構,64-bit原子操作函式使用了ARMv6k core或更新處理器型號才支援的CPU指令。對於ARM, 386和32-bit MIPS處理器架構,原子操作的呼叫者要對進行原子訪問的64bit字(word)按照64-bit進行記憶體對齊。變數或者分配的結構體、陣列和切片的第1個字可以認為是64-bit對齊的。(這塊涉及到記憶體對齊,後面抽個專題詳解)

開源地址

文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程

公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。

個人網站:Jincheng's Blog

知乎:無忌

References

相關文章