Go 語言入門很簡單:讀寫鎖
前言
在上一篇文章中,我們介紹了 Go 互斥鎖,這一篇文章我們來介紹 Go 語言幫我們實現的標準庫的 sync.RWMutex{} 讀寫鎖。
通過使用 sync.RWMutex,我們的程序變得更加高效。
什么是讀者-寫者問題
先來了解讀者-寫者問題(Readers–writers problem)的背景。最基本的讀者-寫者問題首先由 Courtois 等人提出并解決。
讀者-寫者問題描述了計算機并發處理讀寫數據遇到的問題,如何保證數據完整性、一致性。解決讀者-寫者問題需保證對于一份資源操作滿足以下下條件:
讀寫互斥
寫寫互斥
允許多個讀者同時讀取
解決讀者-寫者問題,可以采用讀者優先(readers-preference)方案或者寫者優先(writers-preference)方案。
讀者優先(readers-preference):讀者優先是讀操作優先于寫操作,即使寫操作提出申請資源,但只要還有讀者在讀取操作,就還允許其他讀者繼續讀取操作,直到所有讀者結束讀取,才開始寫。讀優先可以提供很高的并發處理性能,但是在頻繁讀取的系統中,會長時間寫阻塞,導致寫饑餓。
寫者優先(writers-preference):寫者優先是寫操作優先于讀操作,如果有寫者提出申請資源,在申請之前已經開始讀取操作的可以繼續執行讀取,但是如果再有讀者申請讀取操作,則不能夠讀取,只有在所有的寫者寫完之后才可以讀取。寫者優先解決了讀者優先造成寫饑餓的問題。但是若在頻繁寫入的系統中,會長時間讀阻塞,導致讀饑餓。
RWMutex設計采用寫者優先方法,保證寫操作優先處理。
回顧一下互斥鎖的案例
多次單筆存款
假設你有一個銀行賬戶,那你既可以進行存錢,也可以查詢余額的操作。
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 $:
$ go run main.go xiaoW's account balance has 12222.00 $.
同時多次存款
但如果我們進行同時存款呢?即使用 goroutine 來生成三個線程來模擬同時存款的操作。然后利用sync.WaitGroup 去等待所有 goroutine 執行完畢,打印最后的余額:
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 次呢?
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()) }
我們多次運行該程序,發現每次運行結果都不一樣。
$ 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 參數來查看我們的代碼是否有競爭:
$ 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 能夠處理該函數。代碼改正如下:
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,得到統一的結果:
$ 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 $.
讀和寫同時進行
雖然我們同一時間存款問題通過互斥鎖得到了解決。但是如果同時存款與查詢余額呢?
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()) }
然后我們運行代碼,就又出現了線程競爭的問題:
$ 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
同理,我們需要對查詢余額作同樣的加鎖處理:
func (a *Account) Balance() (balance float64) { a.mux.Lock() balance = a.balance a.mux.Unlock() return balance }
如果發生讀寫阻塞呢?我們利用 time.Sleep() 來模擬線程阻塞的過程:
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 秒的時間,但是讀取應該只需要更快才對,即查詢操作不應該發生阻塞。
$ 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():
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 元,說明結果是正確的。
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 持有。我們可以利用讀寫鎖來鎖定某個操作以防止其他例程/線程在處理它時更改值,防止程序出現不可預測的錯誤。最后,可以利用讀寫鎖彌補互斥鎖的缺陷,用來加快程序的讀操作,減少程序的運行時間。
靈感來源:
Go 程序設計語言--sync.RWMutex讀寫鎖
stackoverflow -- How to use RWMutex
官方文檔 -- RWMutex
Golang RWMutex示例
Using Mutexes in Golang - A Comprehensive Tutorial With Examples
sync.RWMutex
Go語言實戰筆記(十七)| Go 讀寫鎖
Go 任務調度
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。