Go 語言入門很簡單:讀寫鎖

語言: CN / TW / HK

theme: channing-cyan highlight: arduino-light


我正在參與掘金創作者訓練營第4期,點擊瞭解活動詳情,一起學習吧!

前言

在上一篇文章中,我們介紹了 Go 互斥鎖,這一篇文章我們來介紹 Go 語言幫我們實現的標準庫的 sync.RWMutex{} 讀寫鎖。

通過使用 sync.RWMutex,我們的程序變得更加高效。

什麼是讀者-寫者問題

先來了解讀者-寫者問題(Readers–writers problem)的背景。最基本的讀者-寫者問題首先由 Courtois 等人提出並解決。

讀者-寫者問題描述了計算機併發處理讀寫數據遇到的問題,如何保證數據完整性、一致性。解決讀者-寫者問題需保證對於一份資源操作滿足以下下條件:

  • 讀寫互斥
  • 寫寫互斥
  • 允許多個讀者同時讀取

解決讀者-寫者問題,可以採用讀者優先(readers-preference)方案或者寫者優先(writers-preference)方案。

  • 讀者優先(readers-preference) :讀者優先是讀操作優先於寫操作,即使寫操作提出申請資源,但只要還有讀者在讀取操作,就還允許其他讀者繼續讀取操作,直到所有讀者結束讀取,才開始寫。讀優先可以提供很高的併發處理性能,但是在頻繁讀取的系統中,會長時間寫阻塞,導致寫飢餓。
  • 寫者優先(writers-preference) :寫者優先是寫操作優先於讀操作,如果有寫者提出申請資源,在申請之前已經開始讀取操作的可以繼續執行讀取,但是如果再有讀者申請讀取操作,則不能夠讀取,只有在所有的寫者寫完之後才可以讀取。寫者優先解決了讀者優先造成寫飢餓的問題。但是若在頻繁寫入的系統中,會長時間讀阻塞,導致讀飢餓。

RWMutex設計採用寫者優先方法,保證寫操作優先處理。

回顧一下互斥鎖的案例

多次單筆存款

假設你有一個銀行賬户,那你既可以進行存錢,也可以查詢餘額的操作。

```go package main

import "fmt"

type Account struct { name string balance float64 }

// func (a *Account) Deposit(amount float64) { a.balance += amount }

func (a *Account) Balance() float64 { return a.balance }

func main() { user := &Account{"xiaoW", 0} user.Deposit(10000) user.Deposit(200) user.Deposit(2022)

fmt.Printf("%s's account balance has %.2f $.", user.name, user.Balance())

} ```

執行該代碼,進行三筆存款,我們可以看到輸出的賬户餘額為 12222.00 $:

bash $ go run main.go xiaoW's account balance has 12222.00 $.

同時多次存款

但如果我們進行同時存款呢?即使用 goroutine 來生成三個線程來模擬同時存款的操作。然後利用sync.WaitGroup 去等待所有 goroutine 執行完畢,打印最後的餘額:

```go package main

import ( "fmt" "sync" )

type Account struct { name string balance float64 }

func (a *Account) Deposit(amount float64) { a.balance += amount }

func (a *Account) Balance() float64 { return a.balance }

func main() {

var wg sync.WaitGroup
user := &Account{"xiaoW", 0}

wg.Add(3)

go func() {
    user.Deposit(10000)
    wg.Done()
}()

go func() {
    user.Deposit(200)
    wg.Done()
}()

go func() {
    user.Deposit(2022)
    wg.Done()
}()

wg.Wait()
fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())

} ```

同時執行 3 次是沒問題的,但如果執行 1000 次呢?

```go package main

import ( "fmt" "sync" )

type Account struct { name string balance float64 }

// func (a *Account) Deposit(amount float64) { a.balance += amount }

func (a *Account) Balance() float64 { return a.balance }

func main() {

var wg sync.WaitGroup
user := &Account{"xiaoW", 0}

n := 1000
wg.Add(n)
for i := 1; i <= n; i++ {
    go func() {
        user.Deposit(1000)
        wg.Done()
    }()
}

wg.Wait()
fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())

} ```

我們多次運行該程序,發現每次運行結果都不一樣。

```bash $ go run main.go xiaoW's account banlance has 0.00 $.

$ go run main.go xiaoW's account banlance has 886000.00 $.

$ go run main.go xiaoW's account banlance has 2000.00 $. ```

正常的結果應該為 1000 * 1000 = 1000000.00 的餘額才對,運行很多次的情況下才能看到一次正常的結果。

xiaoW's account banlance has 1000000.00 $.

使用 -race 參數來查看數據競爭

我們可以利用 -race 參數來查看我們的代碼是否有競爭:

```bash $ go run -race main.go ================== WARNING: DATA RACE Read at 0x00c00000e040 by goroutine 7: main.(*Account).Deposit() /home/wade/GoProjects/Go RWMutex/v2/main.go:15 +0x48 main.main.func1() /home/wade/GoProjects/Go RWMutex/v2/main.go:31 +0x36

Previous write at 0x00c00000e040 by goroutine 45: main.(*Account).Deposit() /home/wade/GoProjects/Go RWMutex/v2/main.go:15 +0x6e main.main.func1() /home/wade/GoProjects/Go RWMutex/v2/main.go:31 +0x36

Goroutine 7 (running) created at: main.main() /home/wade/GoProjects/Go RWMutex/v2/main.go:30 +0x144

Goroutine 45 (finished) created at: main.main() /home/wade/GoProjects/Go RWMutex/v2/main.go:30 +0x144 ================== xiaoW's account banlance has 996000.00 $.Found 1 data race(s) exit status 66 ```

我們可以看到了發生了 goroutine 的線程競爭,goroutine 7 在讀的時候,goroutine 45 在寫,最終導致了讀寫不一致,所以最終的餘額也都不符合我們的預期。

互斥鎖:sync.Mutex

對於上述發生的線程競爭問題,我們就可以使用互斥鎖來解決,即同一時間只能有一個 goroutine 能夠處理該函數。代碼改正如下:

```go package main

import ( "fmt" "sync" )

type Account struct { name string balance float64 mux sync.Mutex }

// func (a *Account) Deposit(amount float64) { a.mux.Lock() // lock a.balance += amount a.mux.Unlock() // unlock }

func (a *Account) Balance() float64 { return a.balance }

func main() {

var wg sync.WaitGroup
user := &Account{}
user.name = "xiaoW"

n := 1000
wg.Add(n)
for i := 1; i <= n; i++ {
    go func() {
        user.Deposit(1000)
        wg.Done()
    }()
}

wg.Wait()
fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())

} ```

此時,我們再運行 3次 go run -race main.go,得到統一的結果:

```bash $ go run -race main.go xiaoW's account banlance has 1000000.00 $.

$ go run -race main.go xiaoW's account banlance has 1000000.00 $.

$ go run -race main.go xiaoW's account banlance has 1000000.00 $. ```

讀和寫同時進行

雖然我們同一時間存款問題通過互斥鎖得到了解決。但是如果同時存款與查詢餘額呢?

```go package main

import ( "fmt" "sync" )

type Account struct { name string balance float64 mux sync.Mutex }

// func (a *Account) Deposit(amount float64) { a.mux.Lock() // lock a.balance += amount a.mux.Unlock() // unlock }

func (a *Account) Balance() float64 { return a.balance }

func main() {

var wg sync.WaitGroup
user := &Account{}
user.name = "xiaoW"

n := 1000
wg.Add(n)
for i := 1; i <= n; i++ {
    go func() {
        user.Deposit(1000)
        wg.Done()
    }()
}

// 查詢餘額 wg.Add(n) for i := 0; i <= n; i++ { go func() { _ = user.Balance() wg.Done() }() }

wg.Wait()
fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())

} ```

然後我們運行代碼,就又出現了線程競爭的問題:

```bash $ go run -race main.go ================== WARNING: DATA RACE Read at 0x00c0000ba010 by goroutine 73: main.(*Account).Balance() /home/wade/GoProjects/Go RWMutex/v2/main.go:22 +0x44 main.main.func2() /home/wade/GoProjects/Go RWMutex/v2/main.go:43 +0x32

Previous write at 0x00c0000ba010 by goroutine 72: main.(*Account).Deposit() /home/wade/GoProjects/Go RWMutex/v2/main.go:17 +0x84 main.main.func1() /home/wade/GoProjects/Go RWMutex/v2/main.go:35 +0x46

Goroutine 73 (running) created at: main.main() /home/wade/GoProjects/Go RWMutex/v2/main.go:42 +0x1ba

Goroutine 72 (finished) created at: main.main() /home/wade/GoProjects/Go RWMutex/v2/main.go:34 +0x15e ================== panic: sync: negative WaitGroup counter

goroutine 2018 [running]: sync.(WaitGroup).Add(0xc0000b4010, 0xffffffffffffffff) /usr/local/go/src/sync/waitgroup.go:74 +0x2e5 sync.(WaitGroup).Done(...) /usr/local/go/src/sync/waitgroup.go:99 main.main.func2(0xc0000ba000, 0xc0000b4010) /home/wade/GoProjects/Go RWMutex/v2/main.go:44 +0x5d created by main.main /home/wade/GoProjects/Go RWMutex/v2/main.go:42 +0x1bb exit status 2 ```

同理,我們需要對查詢餘額作同樣的加鎖處理:

go func (a *Account) Balance() (balance float64) { a.mux.Lock() balance = a.balance a.mux.Unlock() return balance }

如果發生讀寫阻塞呢?我們利用 time.Sleep() 來模擬線程阻塞的過程:

```go package main

import ( "log" "sync" "time" )

type Account struct { balance float64 mux sync.Mutex }

// func (a *Account) Deposit(amount float64) { a.mux.Lock() // lock time.Sleep(time.Second * 2) a.balance += amount a.mux.Unlock() // unlock }

func (a *Account) Balance() (balance float64) { a.mux.Lock() time.Sleep(time.Second * 2) balance = a.balance a.mux.Unlock() return balance }

func main() {

wg := &sync.WaitGroup{}
user := &Account{}

n := 5
wg.Add(n)
for i := 1; i <= n; i++ {
    go func() {
        user.Deposit(1000)
        log.Printf("寫:存款: %v", 1000)
        wg.Done()
    }()
}

wg.Add(n)
for i := 0; i <= n; i++ {
    go func() {
        log.Printf("讀:餘額: %v", user.Balance())
        wg.Done()
    }()
}

wg.Wait()

} ```

我們在程序中,每隔兩秒處理一次存款和查詢操作,總共發生 5 次存款和 5 次查詢,那麼就需要 20 秒來執行這個程序。如果存款可以接受 2 秒的時間,但是讀取應該只需要更快才對,即查詢操作不應該發生阻塞。

bash $ go run -race main.go 2022/02/28 14:31:43 寫:存款: 1000 2022/02/28 14:31:45 寫:存款: 1000 2022/02/28 14:31:47 寫:存款: 1000 2022/02/28 14:31:49 寫:存款: 1000 2022/02/28 14:31:51 寫:存款: 1000 2022/02/28 14:31:53 讀:餘額: 5000 2022/02/28 14:31:55 讀:餘額: 5000 2022/02/28 14:31:57 讀:餘額: 5000 2022/02/28 14:31:59 讀:餘額: 5000 2022/02/28 14:32:01 讀:餘額: 5000

讀寫鎖:sync.RWMutex

Mutex 將所有的 goroutine 視為平等的,並且只允許一個 goroutine 獲取鎖。針對這種情況,讀寫鎖就該被派上用場了。

RWMutex 是 Go 語言中內置的一個 reader/writer 鎖,用來解決讀者-寫者問題(Readers–writers problem)。任意數量的讀取器可以同時獲取鎖,或者單個寫入器可以獲取鎖。 這個想法是讀者只關心與寫者的衝突,並且可以毫無困難地與其他讀者併發執行。

Go 的讀寫鎖的特點:多讀單寫。 RWMutex 結構更靈活,支持兩類 goroutine:readers 和 writers。 在任意一時刻,一個 RWMutex 只能由任意數量的 readers 持有,或者只能由一個 writers 持有。

讀寫鎖的四個方法

  • RLock():此方法嘗試獲取讀鎖,並會阻塞直到被獲取
  • RUnlock():解鎖讀鎖
  • Lock():獲取寫鎖,阻塞直到被獲取
  • UnLock():釋放寫鎖
  • RLocker():該方法返回一個指向 Locker 的指針,用於獲取和釋放讀鎖

讀寫鎖演示

把互斥鎖改為讀寫鎖也很簡單,只需要把 sync.Mutex 換成 sync.RWMutex ,然後在讀操作的地方改為 RLock(),釋放讀鎖改為 RUnlock()

```go package main

import ( "log" "sync" "time" )

type Account struct { balance float64 mux sync.RWMutex // 讀寫鎖 }

// func (a *Account) Deposit(amount float64) { a.mux.Lock() // 寫鎖 time.Sleep(time.Second * 2) a.balance += amount a.mux.Unlock() // 釋放寫鎖 }

func (a *Account) Balance() (balance float64) { a.mux.RLock() // 讀鎖 time.Sleep(time.Second * 2) balance = a.balance a.mux.RUnlock() // 釋放讀鎖 return balance }

func main() {

wg := &sync.WaitGroup{}
user := &Account{}

n := 5
wg.Add(n)
for i := 1; i <= n; i++ {
    go func() {
        user.Deposit(1000)
        log.Printf("寫:存款: %v", 1000)
        wg.Done()
    }()
}

wg.Add(n)
for i := 0; i <= n; i++ {
    go func() {
        log.Printf("讀:餘額: %v", user.Balance())
        wg.Done()
    }()
}

wg.Wait()

} ```

明顯能感覺到讀操作變快了,發生一次寫之後,直接發生 6 次讀操作,説明讀操作是同時進行的,存款1000 一次後,6 次讀操作都是 1000 元,説明結果是正確的。

bash 2022/02/28 14:42:50 寫:存款: 1000 2022/02/28 14:42:52 讀:餘額: 1000 2022/02/28 14:42:52 讀:餘額: 1000 2022/02/28 14:42:52 讀:餘額: 1000 2022/02/28 14:42:52 讀:餘額: 1000 2022/02/28 14:42:52 讀:餘額: 1000 2022/02/28 14:42:52 讀:餘額: 1000 2022/02/28 14:42:54 寫:存款: 1000 2022/02/28 14:42:56 寫:存款: 1000 2022/02/28 14:42:58 寫:存款: 1000

總結

本文從讀者-寫者問題出發,回顧了互斥鎖的案例:一個銀行賬户存款和查詢的競爭問題的出現以及解決方法。最後引出 Go 自帶的讀寫鎖 sync.RWMutex

讀寫鎖的特點是多讀單寫,一個 RWMutex 只能由任意數量的 readers 持有,或者只能由一個 writers 持有。我們可以利用讀寫鎖來鎖定某個操作以防止其他例程/線程在處理它時更改值,防止程序出現不可預測的錯誤。最後,可以利用讀寫鎖彌補互斥鎖的缺陷,用來加快程序的讀操作,減少程序的運行時間。

靈感來源:

  1. Go 程序設計語言--sync.RWMutex讀寫鎖
  2. stackoverflow -- How to use RWMutex
  3. 官方文檔 -- RWMutex
  4. Golang RWMutex示例
  5. Using Mutexes in Golang - A Comprehensive Tutorial With Examples
  6. sync.RWMutex
  7. Go語言實戰筆記(十七)| Go 讀寫鎖