Go併發程式設計之傳統同步—(1)互斥鎖

sown發表於2020-10-04

前言

先回顧一下,在 C 或者其它程式語言的併發程式設計中,主要存在兩種通訊(IPC):

  • 程式間通訊:管道、訊息佇列、訊號等
  • 執行緒間通訊:互斥鎖、條件變數等

利用以上通訊手段採取的同步措施,最終是為了達到以下兩種目的:

  • 維持共享資料一致性,併發安全
  • 控制流程管理,更好的協同工作

Go語言中除了保留了傳統的同步支援,還提供了特有的 CSP 併發程式設計模型。

傳統同步

互斥鎖

接下來通過一個“做累加”的示例程式,展示競爭狀態(race condition)。

不加鎖

開啟 5000 個 goroutine,讓每個 goroutine 給 counter 加 1,最終在所有 goroutine 都完成任務時 counter 的值應該為 5000,先試下不加鎖的示例程式表現如何

func TestDemo1(t *testing.T) {
    counter := 0
    for i := 0; i < 5000; i++ {
        go func() {
            counter++
        }()
    }
    time.Sleep(1 * time.Second)
    t.Logf("counter = %d", counter)
}

結果

=== RUN   TestDemo1
    a1_test.go:18: counter = 4663
--- PASS: TestDemo1 (1.00s)
PASS

多試幾次,結果一直是小於 5000 的不定值。
競爭狀態下程式行為的影像表示
image

加鎖

將剛剛的程式碼稍作改動

func TestDemo2(t *testing.T) {
    var mut sync.Mutex // 宣告鎖
    counter := 0
    for i := 0; i < 5000; i++ {
        go func() {
            mut.Lock() // 加鎖
            counter++
            mut.Unlock() // 解鎖
        }()
    }
    time.Sleep(1 * time.Second)
    t.Logf("counter = %d", counter)
}

結果

=== RUN   TestDemo2
    a1_test.go:35: counter = 5000
--- PASS: TestDemo2 (1.01s)
PASS

counter = 5000,返回的結果對了。

這就是互斥鎖,在程式碼上建立一個臨界區(critical section),保證序列操作(同一時間只有一個 goroutine 執行臨界區程式碼)。

阻塞

那麼互斥鎖是怎麼序列的呢?把每一步的執行過程列印出來看下

func TestDemo3(t *testing.T) {
    var mut sync.Mutex
    counter := 0
    go func() {
        mut.Lock()
        log.Println("goroutine B Lock")
        counter = 1
        log.Println("goroutine B counter =", counter)
        time.Sleep(5 * time.Second)
        mut.Unlock()
        log.Println("goroutine B Unlock")
    }()
    time.Sleep(1 * time.Second)
    mut.Lock()
    log.Println("goroutine A Lock")
    counter = 2
    log.Println("goroutine A counter =", counter)
    mut.Unlock()
    log.Println("goroutine A Unlock")
}

結果

=== RUN   TestDemo3
2020/09/30 22:14:00 goroutine B Lock
2020/09/30 22:14:00 goroutine B counter = 1
2020/09/30 22:14:05 goroutine B Unlock
2020/09/30 22:14:05 goroutine A Lock
2020/09/30 22:14:05 goroutine A counter = 2
2020/09/30 22:14:05 goroutine A Unlock
--- PASS: TestDemo3 (5.00s)
PASS

通過每個操作記錄下來的時間可以看出,goroutine A 的 Lock 一直阻塞到了 goroutine B 的 Unlock。
image

解鎖

這時候有個疑問,那 goroutine B 上的鎖,goroutine A 能解鎖嗎?修改一下剛才的程式碼,試一下

func TestDemo5(t *testing.T) {
    var mut sync.Mutex
    counter := 0
    go func() {
        mut.Lock()
        log.Println("goroutine B Lock")
        counter = 1
        log.Println("goroutine B counter =", counter)
        time.Sleep(5 * time.Second)
        //mut.Unlock()
        //log.Println("goroutine B Unlock")
    }()
    time.Sleep(1 * time.Second)
    mut.Unlock()
    log.Println("goroutine A Unlock")
    counter = 2
    log.Println("goroutine A counter =", counter)
    time.Sleep(2 * time.Second)
}

結果

=== RUN   TestDemo5
2020/09/30 22:15:03 goroutine B Lock
2020/09/30 22:15:03 goroutine B counter = 1
2020/09/30 22:15:04 goroutine A Unlock
2020/09/30 22:15:04 goroutine A counter = 2
--- PASS: TestDemo5 (3.01s)
PASS

測試通過,未報錯,counter 的值也被成功修改,證明B上的鎖,是可以被A解開的。

再進一步,goroutine A 不解鎖,直接修改已經被 goroutine B 鎖住的 counter 的值可以嗎?試一下

func TestDemo6(t *testing.T) {
    var mut sync.Mutex
    counter := 0
    go func() {
        mut.Lock()
        log.Println("goroutine B Lock")
        counter = 1
        log.Println("goroutine B counter =", counter)
        time.Sleep(5 * time.Second)
        mut.Unlock()
        log.Println("goroutine B Unlock")
    }()
    time.Sleep(1 * time.Second)
    //log.Println("goroutine A Unlock")
    //mut.Unlock()
    counter = 2
    log.Println("goroutine A counter =", counter)
    time.Sleep(10 * time.Second)
}

結果

=== RUN   TestDemo6
2020/09/30 22:15:43 goroutine B Lock
2020/09/30 22:15:43 goroutine B counter = 1
2020/09/30 22:15:44 goroutine A counter = 2
2020/09/30 22:15:48 goroutine B Unlock
--- PASS: TestDemo6 (11.00s)
PASS

測試通過,未報錯,證明B上的鎖,A可以不用解鎖直接改。

延伸

鎖的兩種通常處理方式

  • 一種是沒有獲取到鎖的執行緒就一直迴圈等待判斷該資源是否已經釋放鎖,這種鎖叫做自旋鎖,它不用將執行緒阻塞起來(NON-BLOCKING);
  • 還有一種處理方式就是把自己阻塞起來,等待重新排程請求,這種叫做互斥鎖。

飢餓模式

當互斥鎖不斷地試圖獲得一個永遠無法獲得的鎖時,它可能會遇到飢餓問題。
在版本1.9中,Go通過新增一個新的飢餓模式來解決先前的問題,所有等待鎖定超過一毫秒的 goroutine,也稱為有界等待,將被標記為飢餓。當標記為飢餓時,解鎖方法現在將把鎖直接移交給第一位等待著。

讀寫鎖

讀寫鎖和上面的多也差不多,有這麼幾種情況

  • 在寫鎖已被鎖定的情況下試圖鎖定寫鎖,會阻塞當前的 goroutine。
  • 在寫鎖已被鎖定的情況下試圖鎖定讀鎖,會阻塞當前的 goroutine。
  • 在讀鎖已被鎖定的情況下試圖鎖定寫鎖,會阻塞當前的 goroutine。
  • 在讀鎖已被鎖定的情況下試圖鎖定讀鎖,不會阻塞當前的 goroutine。

panic錯誤

無論是互斥鎖還是讀寫鎖在程式執行時一定是成對的,不然就會引發不可恢復的panic。

總結

  1. 鎖一定要用對地方,特別是要注意Lock產生的阻塞對效能的影響。
  2. 在各種程式的邏輯分支下,都要確保鎖的成對出現。
  3. 讀寫鎖是對互斥鎖的一個擴充套件,提高了程式的可讀性。
  4. 臨界區是需要每個 goroutine 主動遵守的,說白了就是每個 goroutine 的程式碼都存在 Lock。

文章示例程式碼

相關文章