Go 語言的原子操作和互斥鎖的區別

KevinYan發表於2020-06-12

這個系列的文章裡介紹了很多併發程式設計裡經常用到的技術,除了Context、計時器、互斥鎖還有通道外還有一種技術–原子操作在一些同步演算法中會被用到。今天的文章裡我們會簡單瞭解一下Go語言裡對原子操作的支援,然後探討一下原子操作和互斥鎖的區別。

文章的主要話題如下:

  • 原子操作
  • Go對原子操作的支援
  • 原子操作和互斥鎖的區別

原子操作

原子操作即是進行過程中不能被中斷的操作,針對某個值的原子操作在被進行的過程中,CPU絕不會再去進行其他的針對該值的操作。為了實現這樣的嚴謹性,原子操作僅會由一個獨立的CPU指令代表和完成。原子操作是無鎖的,常常直接通過CPU指令直接實現。 事實上,其它同步技術的實現常常依賴於原子操作。

Go對原子操作的支援

Go 語言的sync/atomic包提供了對原子操作的支援,用於同步訪問整數和指標。

  • Go語言提供的原子操作都是非入侵式的。
  • 這些函式提供的原子操作共有五種:增減、比較並交換、載入、儲存、交換。
  • 原子操作支援的型別型別包括int32、int64、uint32、uint64、uintptr、unsafe.Pointer。

下面的示例演示如何使用AddInt32函式對int32值執行新增原子操作。 在這個例子中,main goroutine建立了1000個的併發goroutine。 每個新建立的goroutine將整數n加1。

package main

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

func main() {
    var n int32
      var wg sync.WaitGroup
      for i := 0; i < 1000; i++ {
            wg.Add(1)
            go func() {
                  atomic.AddInt32(&n, 1)
                  wg.Done()
            }()
      }
      wg.Wait()

    fmt.Println(atomic.LoadInt32(&n)) // output:1000
}

上面的例子裡你們可以自己試驗一下,如果我們不使用atomic.AddInt32(&n, 1)而是簡單的對變數n進行自增的話得到結果並不是我們預期的1000,這就是我們在文章《Go併發程式設計裡的資料競爭以及解決之道》裡提到過的資料競爭問題,原子操作可確保這些goroutine之間不存在資料競爭。

原子操作中的比較並交換簡稱CAS(Compare And Swap),在sync/atomic包中,這類原子操作由名稱以CompareAndSwap為字首的若干個函式提供

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer,old, new unsafe.Pointer) (swapped bool)
......

呼叫函式後,CompareAndSwap函式會先判斷引數addr指向的操作值與引數old的值是否相等,僅當此判斷得到的結果是true之後,才會用引數new代表的新值替換掉原先的舊值,否則操作就會被忽略。

我們使用的mutex互斥鎖類似悲觀鎖,總是假設會有併發的操作要修改被操作的值,所以使用鎖將相關操作放入臨界區中加以保護。而使用CAS操作的做法趨於樂觀鎖,總是假設被操作值未曾被改變(即與舊值相等),並一旦確認這個假設的真實性就立即進行值替換。在被操作值被頻繁變更的情況下,CAS操作並不那麼容易成功所以需要不斷進行嘗試,直到成功為止。

package main

import (
    "fmt"
    "sync/atomic"
)

var value int32 = 1

func main()  {
    fmt.Println("======old value=======")
    fmt.Println(value)
    fmt.Println("======New value=======")
    fmt.Println(value)

}

//不斷地嘗試原子地更新value的值,直到操作成功為止
func addValue(delta int32){
    for {
        v := value
        if atomic.CompareAndSwapInt32(&value, v, (v + delta)){
            break
        }
    }
}

上面的比較並交換案例中 v:= value為變數v賦值,但要注意,在進行讀取value的操作的過程中,其他對此值的讀寫操作是可以被同時進行的,那麼這個讀操作很可能會讀取到一個只被修改了一半的資料。所以 我們要使用sync/atomic程式碼包中為我們提供的以Load為字首的函式,來避免這樣的糟糕事情發生。

競爭條件是由於非同步的訪問共享資源,並試圖同時讀寫該資源而導致的,使用互斥鎖和通道的思路都是線上程獲得到訪問權後阻塞其他執行緒對共享記憶體的訪問,而使用原子操作解決資料競爭問題則是利用了其不可被打斷的特性。

關於atomic包更詳細的使用介紹可以訪問官方的sync/atomic 中文文件

原子操作與互斥鎖的區別

互斥鎖是一種資料結構,使你可以執行一系列互斥操作。而原子操作是互斥的單個操作,這意味著沒有其他執行緒可以打斷它。那麼就Go語言裡atomic包裡的原子操作和sync包提供的同步鎖有什麼不同呢?

首先atomic操作的優勢是更輕量,比如CAS可以在不形成臨界區和建立互斥量的情況下完成併發安全的值替換操作。這可以大大的減少同步對程式效能的損耗。

原子操作也有劣勢。還是以CAS操作為例,使用CAS操作的做法趨於樂觀,總是假設被操作值未曾被改變(即與舊值相等),並一旦確認這個假設的真實性就立即進行值替換,那麼在被操作值被頻繁變更的情況下,CAS操作並不那麼容易成功。而使用互斥鎖的做法則趨於悲觀,我們總假設會有併發的操作要修改被操作的值,並使用鎖將相關操作放入臨界區中加以保護。

所以總結下來原子操作與互斥鎖的區別有:

  • 互斥鎖是一種資料結構,用來讓一個執行緒執行程式的關鍵部分,完成互斥的多個操作。
  • 原子操作是針對某個值的單個互斥操作。
  • 可以把互斥鎖理解為悲觀鎖,共享資源每次只給一個執行緒使用,其它執行緒阻塞,用完後再把資源轉讓給其它執行緒。

atomic包提供了底層的原子性記憶體原語,這對於同步演算法的實現很有用。這些函式一定要非常小心地使用,使用不當反而會增加系統資源的開銷,對於應用層來說,最好使用通道或sync包中提供的功能來完成同步操作。

針對atomic包的觀點在Google的郵件組裡也有很多討論,其中一個結論解釋是:

應避免使用該包裝。或者,閱讀C ++ 11標準的“原子操作”一章;如果您瞭解如何在C ++中安全地使用這些操作,那麼你才能有安全地使用Go的sync/atomic包的能力。

推薦閱讀

併發題的解題思路和Go語言排程器

本作品採用《CC 協議》,轉載必須註明作者和本文連結

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章