5. Go 併發編程--sync/atomic


1. 前言

在學習 mutex後。 在讀源碼的時候發現裏面使用了很多atomaic 包的方法來保證原子。在併發編程中,常常提到的併發安全,具體數據就是 對數據的修改讀取是否是原子操作 所以也常說,併發是否能保證了原子操作。在Golang語言中,實現原子操作是在標準庫實現的,即 sync/atomic


2. 簡單的使用

2.1 mutex示例

在Mutex示例中,使用讀寫鎖和互斥鎖實現代碼如下

package syncDemo

import "sync"

// RWMutexConfig 讀寫鎖實現
type RWMutexConfig struct {
    rw   sync.RWMutex
    data []int
}

// Get get config data
func (c *RWMutexConfig) Get() []int {
    c.rw.RLock()
    defer c.rw.RUnlock()
    return c.data
}

// Set set config data
func (c *RWMutexConfig) Set(n []int) {
    c.rw.Lock()
    defer c.rw.Unlock()
    c.data = n
}

// MutexConfig 互斥鎖實現
type MutexConfig struct {
    data []int
    mu   sync.Mutex
}

// Get get config data
func (c *MutexConfig) Get() []int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.data
}

// Set set config data
func (c *MutexConfig) Set(n []int) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data = n
}

併發基準測試代碼如下

package syncDemo

import "testing"

type iConfig interface {
    Get() []int
    Set([]int)
}

func bench(b *testing.B, c iConfig) {

    b.RunParallel(func(p *testing.PB) {
        for p.Next() {
            c.Set([]int{100})
            c.Get()
            c.Get()
            c.Get()
            c.Set([]int{100})
            c.Get()
            c.Get()
        }
    })
}

func BenchmarkMutexConfig(b *testing.B) {
    conf := &MutexConfig{data: []int{1, 2, 3}}
    bench(b, conf)
}

func BenchmarkRWMutexConfig(b *testing.B) {
    conf := &RWMutexConfig{data: []int{1, 2, 3}}
    bench(b, conf)
}

運行 基準測試 結果如下

D:\goProject\src\daily測試\syncDemo\mutex>go test -race -bench=.
goos: windows
goarch: amd64
pkg: syncDemo
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkMutexConfig-8             85347             15611 ns/op
BenchmarkRWMutexConfig-8           94676             11367 ns/op  # 讀寫鎖實現性能高於互鎖
PASS
ok      syncDemo        4.140s

2.2 sync/atomic實現方式如下

// Config atomic 實現
type AtomaticConfig struct {
    v atomic.Value // 假設 data 就是整個 config 了
}

// Get get config data
func (c *AtomaticConfig) Get() []int {
    // 類型斷言
    return (*c.v.Load().(*[]int))
}

// Set set config data
func (c *AtomaticConfig) Set(n []int) {
    c.v.Store(&n)
}

基準測試代碼如下

package syncDemo

import "testing"

type iConfig interface {
    Get() []int
    Set([]int)
}

func bench(b *testing.B, c iConfig) {

    b.RunParallel(func(p *testing.PB) {
        for p.Next() {
            c.Set([]int{100})
            c.Get()
            c.Get()
            c.Get()
            c.Set([]int{100})
            c.Get()
            c.Get()
        }
    })
}

func BenchmarkAtomaticConfig(b *testing.B) {
    conf := &AtomaticConfig{}
    bench(b, conf)
}

讀寫鎖互斥鎖atomic 基準測試放一起測試,測試性能

package syncDemo

import "testing"

type iConfig interface {
    Get() []int
    Set([]int)
}

func bench(b *testing.B, c iConfig) {

    b.RunParallel(func(p *testing.PB) {
        for p.Next() {
            c.Set([]int{100})
            c.Get()
            c.Get()
            c.Get()
            c.Set([]int{100})
            c.Get()
            c.Get()
        }
    })
}

// 互斥鎖
func BenchmarkMutexConfig(b *testing.B) {
    conf := &MutexConfig{data: []int{1, 2, 3}}
    bench(b, conf)
}

// 讀寫鎖
func BenchmarkRWMutexConfig(b *testing.B) {
    conf := &RWMutexConfig{data: []int{1, 2, 3}}
    bench(b, conf)
}

// atomic 
func BenchmarkAtomicConfig(b *testing.B) {
    conf := &AtomicConfig{}
    bench(b, conf)
}

運行基準測試結果如下,可以發現 atomic 的性能又好上了許多

D:\goProject\src\daily測試\syncDemo\mutex>go test -race -bench=.
goos: windows
goarch: amd64
pkg: syncDemo
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkMutexConfig-8             75313             16914 ns/op
BenchmarkRWMutexConfig-8           88446             11719 ns/op
BenchmarkAtomicConfig-8           260695              4679 ns/op
PASS
ok      syncDemo        5.362s

atomic.Value 這種適合配置文件這種讀特別多,寫特別少的場景,因爲他是 COW(Copy On Write)寫時複製的一種思想,COW 就是指我需要寫入的時候我先把老的數據複製一份到一個新的對象,然後再寫入新的值。


維基百科對COW(Copy On Write)的描述如下

寫入時複製(英語:Copy-on-write,簡稱 COW)是一種計算機程序設計領域的優化策略。其核心思想是,如果有多個調用者(callers)同時請求相同資源(如內存或磁盤上的數據存儲),他們會共同獲取相同的指針指向相同的資源,直到某個調用者試圖修改資源的內容時,系統纔會真正複製一份專用副本(private copy)給該調用者,而其他調用者所見到的最初的資源仍然保持不變。這過程對其他的調用者都是透明的。此作法主要的優點是如果調用者沒有修改該資源,就不會有副本(private copy)被創建,因此多個調用者只是讀取操作時可以共享同一份資源。

這種思路會有一個問題,就是可能有部分 goroutine 在使用老的對象,所以老的對象不會立即被回收,如果存在大量寫入的話,會導致產生大量的副本,性能反而不一定好 。
這種方式的好處就是不用加鎖,所以也不會有 goroutine 的上下文切換,並且在讀取的時候大家都讀取的相同的副本所以性能上回好一些。

COW 策略在 linux, redis 當中都用的很多


3. 源碼分析

3.1 方法總結

atomic 的函數簽名有很多,但是大部分都是重複的爲了不同的數據類型創建了不同的簽名,這就是沒有泛型的壞處了,基礎庫會比較麻煩

  1. 第一類 AddXXX 當需要添加的值爲負數的時候,做減法,正數做加法
    // 第一類,AddXXX
    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)
    
  2. 第二類 CompareAndSwapXXX CAS 操作, 會先比較傳入的地址的值是否是 old,如果是的話就嘗試賦新值,如果不是的話就直接返回 false,返回 true 時表示賦值成功。
    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)
    
  3. 第三類 LoadXXX,從某個地址中取值
    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)
    
  4. 第四類 StoreXXX ,給某個地址賦值
    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)
    
  5. 第五類 SwapXXX ,交換兩個值,並且返回老的值
    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)
    
  6. 最後一類Value 用於任意類型的值的 Store、Load,我們開始的案例就用到了這個,這是 1.4 版本之後引入的,簽名的方法都只能作用於特定的類型,引入這個方法之後就可以用於任意類型了。
    type Value
    func (v *Value) Load() (x interface{})
    func (v *Value) Store(x interface{})
    

3.2 CAS

CAS(Compare-and-Swap),即比較並替換,是一種實現併發算法時常用到的技術,Java併發包中的很多類都使用了CAS技術。

CAS需要有3個操作數:內存地址V,舊的預期值A,即將要更新的目標值B。

CAS指令執行時,當且僅當內存地址V的值與預期值A相等時,將內存地址V的值修改爲B,否則就什麼都不做。整個比較並替換的操作是一個原子操作。


3.2.1 延申閱讀: CAS的缺點

CAS雖然很高效的解決了原子操作問題,但是CAS仍然存在三大問題。

  1. 循環時間長開銷很大。

    循環時間長開銷很大:我們可以看到getAndAddInt方法執行時,如果CAS失敗,會一直進行嘗試。如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷。

  2. 只能保證一個共享變量的原子操作。

    只能保證一個共享變量的原子操作:當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖來保證原子性。

  3. ABA問題。

什麼是ABA問題?ABA問題怎麼解決?
CAS 的使用流程通常如下:
1)首先從地址 V 讀取值 A;
2)根據 A 計算目標值 B;
3)通過 CAS 以原子的方式將地址 V 中的值從 A 修改爲 B。

但是在第1步中讀取的值是A,並且在第3步修改成功了,我們就能說它的值在第1步和第3步之間沒有被其他線程改變過了嗎?

如果在這段期間它的值曾經被改成了B,後來又被改回爲A,那CAS操作就會誤認爲它從來沒有被改變過。這個漏洞稱爲CAS操作的“ABA”問題。Java併發包爲了解決這個問題,提供了一個帶有標記的原子引用類“AtomicStampedReference”,它可以通過控制變量值的版本來保證CAS的正確性。因此,在使用CAS前要考慮清楚“ABA”問題是否會影響程序併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

sync/atomic 包中的源碼除了 Value 之外,其他的函數都是沒有直接的源碼的, 需要去 tuntime/internal/atomic 中尋找, CAS函數爲例, 其他都是大同小異


3.2.2 VALUE

Value 是個 結構體,結構體中只有唯一的成員是個 interface類型,也就意味着value可以是個任意類型的值

type VALUE struct {
  v interface{}
}

v 用來保存 傳入的值


store

store方法是將值存儲爲x, 需要注意的是,每次傳入的x 不能爲nil, 並且他們的類型必須是相同的, 不讓會導致 panic異常

源碼如下:

func (v Value) Store(x interface{}){
  if x == nil{
    panic("sync/atomic: store of nil value into Value")
  }
  // ifaceWords 其實就是定義了一下 interface 的結構,包含 data 和 type 兩部分
  // 這裏 vp 是原有值,xp 是傳入的值
	vp := (*ifaceWords)(unsafe.Pointer(v))
	xp := (*ifaceWords)(unsafe.Pointer(&x))
    // for 循環不斷嘗試
	for {
        // 這裏先用原子方法取一下老的類型值
		typ := LoadPointer(&vp.typ)
		if typ == nil {
      // 等於 nil 就說明原始值沒有,需要存儲新的值。
      // 調用 runtime 的方法禁止搶佔,避免操作完成一半就被搶佔了
      // 同時可以避免 GC 的時候看到 unsafe.Pointer(^uintptr(0)) 這個中間狀態的值
			runtime_procPin()
			if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {
				runtime_procUnpin()
				continue
			}

			// 分別把值和類型保存下來
			StorePointer(&vp.data, xp.data)
			StorePointer(&vp.typ, xp.typ)
			runtime_procUnpin()
			return
		}

		if uintptr(typ) == ^uintptr(0) {
      // 如果判斷髮現這個類型是這個固定值,說明當前第一次賦值還沒有完成,所以進入自旋等待
			continue
		}
		// 第一次賦值已經完成,判斷新的賦值的類型和之前是否一致,如果不一致就直接 panic
		if typ != xp.typ {
			panic("sync/atomic: store of inconsistently typed value into Value")
		}
    // 保存值
		StorePointer(&vp.data, xp.data)
		return
	}
}
}

複雜邏輯在第一次寫入,因爲第一次寫入的時候有兩次原子寫操作,所以這個時候用 typ 值作爲一個判斷,通過不同值判斷當前所處的狀態,這個在我們業務代碼中其實也經常用到。然後因爲引入了這個中間狀態,所以又使用了 runtime_procPin 方法避免搶佔

func sync_runtime_procPin() int {
	return procPin()
}

func procPin() int {
    // 獲取到當前 goroutine 的 m
	_g_ := getg()
	mp := _g_.m

    // unpin 的時候就是 locks--
	mp.locks++
	return int(mp.p.ptr().id)
}

Load

該方法使用來 加載數據,也就是讀取數據, 源碼如下

func (v *Value) Load() (x interface{}) {
  // ifaceWords 是定義了一個 interface 的結構,包含 data 和 type 兩部分
	vp := (*ifaceWords)(unsafe.Pointer(v))
  
  //從結構體中獲取類型
	typ := LoadPointer(&vp.typ)
  // 這個說明還沒有第一次 store 或者是第一次 store 還沒有完成
	if typ == nil || uintptr(typ) == ^uintptr(0) {
		// First store not yet completed.
		return nil
	}
  // 獲取值
	data := LoadPointer(&vp.data)
  // 構造 x 類型
	xp := (*ifaceWords)(unsafe.Pointer(&x))
	xp.typ = typ
	xp.data = data
	return
}

3. 實戰:實現一個“無鎖”的棧

3.1 無鎖棧

對多線程場景下的無鎖操作的研究一直是個熱點,理想中的無鎖操作,它應能天然地避開有鎖操作的一些缺陷,比如:

  1. 減少線程切換,能夠相對快速高效地讀寫(不使用 mutex, semaphore)
  2. 避免死鎖的可能,任何操作都應能在有限的等待時間內完成,

go實現無鎖棧代碼如下

package main

import (
	"sync/atomic"
	"unsafe"
)

// LFStack 無鎖棧
// 使用鏈表實現
type LFStack struct {
	head unsafe.Pointer // 棧頂
}

// Node 節點
type Node struct {
	val  int32
	next unsafe.Pointer
}

// NewLFStack NewLFStack
func NewLFStack() *LFStack {
	n := unsafe.Pointer(&Node{})
	return &LFStack{head: n}
}

// Push 入棧
func (s *LFStack) Push(v int32) {
	n := &Node{val: v}

	for {
		// 先取出棧頂
		old := atomic.LoadPointer(&s.head)
		n.next = old
		// CAS操作
		if atomic.CompareAndSwapPointer(&s.head, old, unsafe.Pointer(n)) {
			return
		}
	}
}

// Pop 出棧,沒有數據時返回 nil
func (s *LFStack) Pop() int32 {
	for {
		// 先取出棧頂
		old := atomic.LoadPointer(&s.head)
		if old == nil {
			return 0
		}

		oldNode := (*Node)(old)
		// 取出下一個節點
		next := atomic.LoadPointer(&oldNode.next)
		// 重置棧頂
		if atomic.CompareAndSwapPointer(&s.head, old, next) {
			return oldNode.val
		}
	}
}

3.2 擴展閱讀

棧(stack)又名堆棧,它是一種運算受限的線性表。限定僅在表尾進行插入和刪除操作的線性表。這一端被稱爲棧頂,相對地,把另一端稱爲棧底。向一個棧插入新元素又稱作進棧入棧壓棧,它是把新元素放到棧頂元素的上面,使之成爲新的棧頂元素;從一個棧刪除元素又稱作出棧或退棧,它是把棧頂元素刪除掉,使其相鄰的元素成爲新的棧頂元素。




4. 參考

  1. CAS概述
  2. 百度百科棧
  3. https://lailin.xyz/post/go-training-week3-atomic.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章