Go 語言入門很簡單:讀寫鎖
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 持有。我們可以利用讀寫鎖來鎖定某個操作以防止其他例程/線程在處理它時更改值,防止程序出現不可預測的錯誤。最後,可以利用讀寫鎖彌補互斥鎖的缺陷,用來加快程序的讀操作,減少程序的運行時間。
靈感來源:
- 一文帶你瞭解 Python 中的繼承知識點
- 如何使用 HTML 和 CSS 寫一個登錄界面
- 代碼之外:寫作是倒逼成長的最佳方式
- Redis 的快速介紹及其基本數據類型和操作
- 經久不衰的設計定律就是——不要讓我思考的設計
- 一文了解 Python 中的裝飾器
- 聊聊 Go 語言與雲原生技術
- Go Web 編程入門:驗證器
- Golang 的藝術、哲學和科學
- Django API 開發:視圖設置和路由
- Web 編程入門:什麼是Web API?
- Python 實現設計模式之工廠模式
- 好開心我進入了面試環節,那麼我該如何自我介紹?
- 鴻蒙學習筆記:利用鴻蒙JavaUI 框架的 WebView 加載在線網頁
- Go 語言入門很簡單:讀寫鎖