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

前言

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

  • 進程間通訊:管道、消息隊列、信號等
  • 線程間通訊:互斥鎖、條件變量等

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

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

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)
}

結果spa

=== 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。

文章示例代碼

相關文章
相關標籤/搜索