Golang 基礎之併發知識 (三)

帽兒山的槍手 發表於 2022-06-12
Go

大家好,今天將梳理出的 Go語言併發知識內容,分享給大家。 請多多指教,謝謝。

本次《Go語言併發知識》內容共分為三個章節,本文為第三章節。

本章節內容

  • 基本同步原語
  • 常見的鎖型別
  • 擴充套件內容

基本同步原語

Go 語言在 sync 包中提供了用於同步的一些基本原語,包括常見的互斥鎖 Mutex 與讀寫互斥鎖 RWMutex 以及 OnceWaitGroup。這些基本原語的主要作用是提供較為基礎的同步功能,本次僅對 Mutex展開介紹,剩餘其他原語將在後續併發章節中使用。

qPYtXT.jpg

Mutex 是什麼

Mutex 是 golang 標準庫的互斥鎖,主要用來處理併發場景下共享資源的訪問衝突問題。

Mutex 互斥鎖在 sync 包中,它由兩個欄位 statesema 組成,state 表示當前互斥鎖的狀態,而 sema 真正用於控制鎖狀態的訊號量,這兩個加起來只佔 8 個位元組空間的結構體就表示了 Go 語言中的互斥鎖。

type Mutex struct {
    state int32
    sema  uint32
}

互斥鎖的作用,就是同步訪問共享資源。互斥鎖這個名字來自互斥(mutual exclusion)的概念,互斥鎖用於在程式碼上建立一個臨界區,保證同一個時間只有一個 goroutine 可以執行這個臨界區程式碼。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    counter int
    wg sync.WaitGroup
    mutex sync.Mutex // 定義程式碼臨界區
)

func main() {
    wg.Add(2)
    go incCounter()
    go incCounter()
    wg.Wait()
    fmt.Println("counter:", counter)
}

func incCounter() {
    defer wg.Done()
    for count := 0; count < 2; count++ {
        mutex.Lock() // 臨界區, 同一時刻只允許一個 goroutine 進入
        {
            value := counter
            runtime.Gosched() // goroutine退出,返回佇列
            value++
            counter = value
        }
        mutex.Unlock() // 釋放鎖
    }
}

Lock()Unlock() 函式呼叫定義的臨界區裡被保護起來。 使用大括號只是為了讓臨界區看起來更清晰,並不是必需的。同一時刻只有一個 goroutine 可以進入臨界區,直到呼叫 Unlock() 函式之後,其他 goroutine 才能進入臨界區。

Mutex 幾種狀態

  • mutexLocked — 表示互斥鎖的鎖定狀態;
  • mutexWoken — 表示從正常模式被從喚醒;
  • mutexStarving — 當前的互斥鎖進入飢餓狀態;
  • waitersCount — 當前互斥鎖上等待的 Goroutine 個數;

正常模式和飢餓模式

sync.Mutex 有兩種模式 — 正常模式和飢餓模式。

在正常模式中,鎖的等待者會按照先進先出的順序獲取鎖。但是剛被喚起的 Goroutine 與新建立的 Goroutine 競爭時,大概率會獲取不到鎖,為了減少這種情況的出現,一旦 Goroutine 超過 1ms 沒有獲取到鎖,它就會將當前互斥鎖切換飢餓模式,防止部分 Goroutine 被 "餓死"。

飢餓模式是在 Go 語言在 1.9 中通過提交 sync: make Mutex more fair 引入的優化,引入的目的是保證互斥鎖的公平性。

在飢餓模式中,互斥鎖會直接交給等待佇列最前面的 Goroutine。新的 Goroutine 在該狀態下不能獲取鎖、也不會進入自旋狀態,它們只會在佇列的末尾等待。如果一個 Goroutine 獲得了互斥鎖並且它在佇列的末尾或者它等待的時間少於 1ms,那麼當前的互斥鎖就會切換回正常模式。

常見鎖型別

死鎖、活鎖與飢餓

關於這三種鎖模式,已經在 [Golang 基礎之併發知識 (一)]() 文章中進行了簡單說明,上文中針對飢餓模式進行一次補充。

死鎖,作為最常見的鎖,這裡在進行一次補充。

死鎖可以理解為完成一項任務的資源被兩個(或多個)不同的協程分別佔用了,導致它們全都處於等待狀態不能完成下去。在這種情況下,如果沒有外部干預,程式將永遠不會恢復。

// 死鎖案例
package main

import (
    "fmt"
    "sync"
    "time"
)
type value struct {
    mu sync.Mutex
    value int
}

var wg sync.WaitGroup

func main() {
    printSum := func(v1, v2 *value) {
        defer wg.Done()
        v1.mu.Lock() // 加鎖
        defer v1.mu.Unlock() // 釋放鎖

        time.Sleep(1 * time.Second)
        v2.mu.Lock()
        defer v2.mu.Unlock()
        fmt.Printf("sum=%v\n", v1.value+v2.value)
    }

    var a, b value
    wg.Add(2)
    go printSum(&a, &b) // 協程1
    go printSum(&b, &a) // 協程2
    wg.Wait()
}

輸出

fatal error: all goroutines are asleep - deadlock!

死鎖的三個動作

  1. 試圖訪問帶鎖的部分
  2. 試圖呼叫defer關鍵字釋放鎖
  3. 新增休眠時間 以造成死鎖

qC0Q61.png

實質上,我們建立了兩個不能一起運轉的齒輪: 我們的第一個列印總和呼叫a鎖定,然後嘗試鎖定b,但與此同時,我們列印總和的第二個呼叫鎖定了b並嘗試鎖定a。 兩個goroutine都無限地等待著彼此。

自旋鎖

介紹

自旋鎖是指當一個執行緒在獲取鎖的時候,如果鎖已經被其他執行緒獲取,那麼該執行緒將迴圈等待,然後不斷地判斷是否能夠被成功獲取,直到獲取到鎖才會退出迴圈。

獲取鎖的執行緒一直處於活躍狀態,但是並沒有執行任何有效的任務,使用這種鎖會造成 busy-waiting

它是為實現保護共享資源而提出的一種鎖機制。其實,自旋鎖與互斥鎖比較類似,它們都是為了解決某項資源的互斥使用。無論是互斥鎖,還是自旋鎖,在任何時刻,最多隻能由一個保持者,也就說,在任何時刻最多隻能有一個執行單元獲得鎖。但是兩者在排程機制上略有不同。對於互斥鎖,如果資源已經被佔用,資源申請者只能進入睡眠狀態。但是自旋鎖不會引起呼叫者睡眠,如果自旋鎖已經被別的執行單元保持,呼叫者就一直迴圈在那裡看是否該自旋鎖的保持者已經釋放了鎖,“自旋”一詞就是因此而得名。

自旋鎖與互斥鎖
  • 自旋鎖與互斥鎖都是為了實現保護資源共享的機制。
  • 無論是自旋鎖還是互斥鎖,在任意時刻,都最多隻能有一個保持者。
  • 獲取互斥鎖的執行緒,如果鎖已經被佔用,則該執行緒將進入睡眠狀態;獲取自旋鎖的執行緒則不會睡眠,而是一直迴圈等待鎖釋放。
總結
  • 自旋鎖:執行緒獲取鎖的時候,如果鎖被其他執行緒持有,則當前執行緒將迴圈等待,直到獲取到鎖。
  • 自旋鎖等待期間,執行緒的狀態不會改變,執行緒一直是使用者態並且是活動的(active)。
  • 自旋鎖如果持有鎖的時間太長,則會導致其它等待獲取鎖的執行緒耗盡CPU。
  • 自旋鎖本身無法保證公平性,同時也無法保證可重入性。
  • 基於自旋鎖,可以實現具備公平性和可重入性質的鎖。

讀寫鎖

讀寫鎖即針對讀寫操作的互斥鎖。 它與普通的互斥鎖最大的不同,就是可以分別針對讀操作和寫操作進行鎖定和解鎖操作。讀寫鎖遵循的訪問控制規則有所不同。讀寫鎖控制下的多個寫操作之間都是互斥的,並且寫操作與讀操作之間也都是互斥的。

但是,多個讀操作之間卻不存在互斥關係。在這樣的互斥策略之下,讀寫鎖可以在大大降低因使用鎖造成的效能損耗的情況下,完成對共享資源的訪問控制。

Go語言中的讀寫鎖由結構體型別 sync.RWMutex 表示。 與互斥鎖一樣, sync.RWMutex 型別的零值就已經是可用的讀寫鎖例項了。

// 型別方法集
func (*RWMutex) Lock()
func (*RWMutex) Unlock()
func (*RWMutex) RLock()
func (*RWMutex) RUnlock()

擴充套件內容

不公平的鎖

不公平的鎖可被看成是飢餓的一種不太嚴重的表現形式,當某些執行緒爭搶同一把鎖時,其中一部分執行緒在絕大多數時間都可獲取到鎖,另一部分執行緒則遭遇不公平對待。這在帶有共享快取記憶體或者NUMA記憶體 的機器中可能出現,如果CPU 0釋放了一把其他CPU都 想獲取的鎖,因為CPU 0與CPU 1共享內部連線,所以CPU 1相較於CPU 2到7更容易搶到鎖。

反之亦然,如果一段時間後CPU 0又開始爭搶該鎖,那麼CPU 1釋放鎖時CPU 0也更容易獲取鎖,導致鎖繞過了CPU 2到 7,只在CPU 0和1之間換手。

低效率的鎖

鎖是由原子操作和記憶體屏障實現,並且常常帶來快取記憶體未命中。 這些指令代價都比較昂貴,粗略地說開銷比簡單指令高兩個數量級。這可能是鎖的一個嚴重問題,如果用鎖來保護一條指令,你很可能在以百倍的速度帶來開銷。對於相同的程式碼,即使假設擴充套件性非常完美,也需要100個CPU才能跟上一個執行不加鎖版本的CPU。

不過一旦持有了鎖,持有者可以不受干擾地訪問被鎖保護的程式碼。 獲取鎖可能代價高昂,但是一旦持有,特別是對較大的臨界區來說,CPU的快取記憶體反而是高效的效能加速器。

技術文章持續更新,請大家多多關注呀~~

搜尋微信公眾號,關注我【 帽兒山的槍手 】


參考材料

《Go語言設計與實現》書籍
《Concurrency in Go》書籍
《Go 併發程式設計實戰》書籍
《Go 語言實戰》書籍
晁嶽攀老師(鳥窩)的《Go 併發程式設計實戰課》
《深入理解並行程式設計》書籍