26 | sync.Mutex與sync.RWMutex
從本篇文章開始,我們將一起探討 Go 語言自帶標準庫中一些比較核心的程式碼包。這會涉及這些程式碼包的標準用法、使用禁忌、背後原理以及周邊的知識。
既然 Go 語言是以獨特的併發程式設計模型傲視群雄的語言,那麼我們就先來學習與併發程式設計關係最緊密的程式碼包。
前導內容: 競態條件、臨界區與同步工具
我們首先要看的就是sync包。這裡的“sync”的中文意思是“同步”。我們下面就從同步講起。
相比於 Go 語言宣揚的“用通訊的方式共享資料”,通過共享資料的方式來傳遞資訊和協調執行緒執行的做法其實更加主流,畢竟大多數的現代程式語言,都是用後一種方式作為併發程式設計的解決方案的(這種方案的歷史非常悠久,恐怕可以追溯到上個世紀多程式程式設計時代伊始了)。
一旦資料被多個執行緒共享,那麼就很可能會產生爭用和衝突的情況。這種情況也被稱為競態條件(race condition),這往往會破壞共享資料的一致性。
共享資料的一致性代表著某種約定,即:多個執行緒對共享資料的操作總是可以達到它們各自預期的效果。
如果這個一致性得不到保證,那麼將會影響到一些執行緒中程式碼和流程的正確執行,甚至會造成某種不可預知的錯誤。這種錯誤一般都很難發現和定位,排查起來的成本也是非常高的,所以一定要儘量避免。
舉個例子,同時有多個執行緒連續向同一個緩衝區寫入資料塊,如果沒有一個機制去協調這些執行緒的寫入操作的話,那麼被寫入的資料塊就很可能會出現錯亂。比如,線上程 A 還沒有寫完一個資料塊的時候,執行緒 B 就開始寫入另外一個資料塊了。
顯然,這兩個資料塊中的資料會被混在一起,並且已經很難分清了。因此,在這種情況下,我們就需要採取一些措施來協調它們對緩衝區的修改。這通常就會涉及同步。
概括來講,同步的用途有兩個,一個是避免多個執行緒在同一時刻操作同一個資料塊,另一個是協調多個執行緒,以避免它們在同一時刻執行同一個程式碼塊。
由於這樣的資料塊和程式碼塊的背後都隱含著一種或多種資源(比如儲存資源、計算資源、I/O 資源、網路資源等等),所以我們可以把它們看做是共享資源,或者說共享資源的代表。我們所說的同步其實就是在控制多個執行緒對共享資源的訪問。
一個執行緒在想要訪問某一個共享資源的時候,需要先申請對該資源的訪問許可權,並且只有在申請成功之後,訪問才能真正開始。
而當執行緒對共享資源的訪問結束時,它還必須歸還對該資源的訪問許可權,若要再次訪問仍需申請。
你可以把這裡所說的訪問許可權想象成一塊令牌,執行緒一旦拿到了令牌,就可以進入指定的區域,從而訪問到資源,而一旦執行緒要離開這個區域了,就需要把令牌還回去,絕不能把令牌帶走。
如果針對某個共享資源的訪問令牌只有一塊,那麼在同一時刻,就最多隻能有一個執行緒進入到那個區域,並訪問到該資源。
這時,我們可以說,多個併發執行的執行緒對這個共享資源的訪問是完全序列的。只要一個程式碼片段需要實現對共享資源的序列化訪問,就可以被視為一個臨界區(critical section),也就是我剛剛說的,由於要訪問到資源而必須進入的那個區域。
比如,在我前面舉的那個例子中,實現了資料塊寫入操作的程式碼就共同組成了一個臨界區。如果針對同一個共享資源,這樣的程式碼片段有多個,那麼它們就可以被稱為相關臨界區。
它們可以是一個內含了共享資料的結構體及其方法,也可以是操作同一塊共享資料的多個函式。臨界區總是需要受到保護的,否則就會產生競態條件。施加保護的重要手段之一,就是使用實現了某種同步機制的工具,也稱為同步工具。
(競態條件、臨界區與同步工具)
在 Go 語言中,可供我們選擇的同步工具並不少。其中,最重要且最常用的同步工具當屬互斥量(mutual exclusion,簡稱 mutex)。sync包中的Mutex就是與其對應的型別,該型別的值可以被稱為互斥量或者互斥鎖。
一個互斥鎖可以被用來保護一個臨界區或者一組相關臨界區。我們可以通過它來保證,在同一時刻只有一個 goroutine 處於該臨界區之內。
為了兌現這個保證,每當有 goroutine 想進入臨界區時,都需要先對它進行鎖定,並且,每個 goroutine 離開臨界區時,都要及時地對它進行解鎖。
鎖定操作可以通過呼叫互斥鎖的Lock方法實現,而解鎖操作可以呼叫互斥鎖的Unlock方法。以下是 demo58.go 檔案中重點程式碼經過簡化之後的片段:
mu.Lock()
_, err := writer.Write([]byte(data))
if err != nil {
log.Printf("error: %s [%d]", err, id)
}
mu.Unlock()
你可能已經看出來了,這裡的互斥鎖就相當於我們前面說的那塊訪問令牌。
demo58.go
package main
import (
"bytes"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"sync"
)
// protecting 用於指示是否使用互斥鎖來保護資料寫入。
// 若值等於0則表示不使用,若值大於0則表示使用。
// 改變該變數的值,然後多執行幾次程式,並觀察程式列印的內容。
var protecting uint
func init() {
flag.UintVar(&protecting, "protecting", 1,
"It indicates whether to use a mutex to protect data writing.")
}
func main() {
flag.Parse()
// buffer 代表緩衝區。
var buffer bytes.Buffer
const (
max1 = 5 // 代表啟用的goroutine的數量。
max2 = 10 // 代表每個goroutine需要寫入的資料塊的數量。
max3 = 10 // 代表每個資料塊中需要有多少個重複的數字。
)
// mu 代表以下流程要使用的互斥鎖。
var mu sync.Mutex
// sign 代表訊號的通道。
sign := make(chan struct{}, max1)
for i := 1; i <= max1; i++ {
go func(id int, writer io.Writer) {
defer func() {
sign <- struct{}{}
}()
for j := 1; j <= max2; j++ {
// 準備資料。
header := fmt.Sprintf("\n[id: %d, iteration: %d]",
id, j)
data := fmt.Sprintf(" %d", id*j)
// 寫入資料。
if protecting > 0 {
mu.Lock()
}
_, err := writer.Write([]byte(header))
if err != nil {
log.Printf("error: %s [%d]", err, id)
}
for k := 0; k < max3; k++ {
_, err := writer.Write([]byte(data))
if err != nil {
log.Printf("error: %s [%d]", err, id)
}
}
if protecting > 0 {
mu.Unlock()
}
}
}(i, &buffer)
}
for i := 0; i < max1; i++ {
<-sign
}
data, err := ioutil.ReadAll(&buffer)
if err != nil {
log.Fatalf("fatal error: %s", err)
}
log.Printf("The contents:\n%s", data)
}
那麼,我們怎樣才能用好這塊訪問令牌呢?請看下面的問題。
我們今天的問題是:我們使用互斥鎖時有哪些注意事項?
這裡有一個典型回答。
使用互斥鎖的注意事項如下:
- 不要重複鎖定互斥鎖;
- 不要忘記解鎖互斥鎖,必要時使用defer語句;
- 不要對尚未鎖定或者已解鎖的互斥鎖解鎖;
- 不要在多個函式之間直接傳遞互斥鎖。
問題解析
首先,你還是要把互斥鎖看作是針對某一個臨界區或某一組相關臨界區的唯一訪問令牌。
雖然沒有任何強制規定來限制,你用同一個互斥鎖保護多個無關的臨界區,但是這樣做,一定會讓你的程式變得很複雜,並且也會明顯地增加你的心智負擔。
你要知道,對一個已經被鎖定的互斥鎖進行鎖定,是會立即阻塞當前的 goroutine 的。這個 goroutine 所執行的流程,會一直停滯在呼叫該互斥鎖的Lock方法的那行程式碼上。
直到該互斥鎖的Unlock方法被呼叫,並且這裡的鎖定操作成功完成,後續的程式碼(也就是臨界區中的程式碼)才會開始執行。這也正是互斥鎖能夠保護臨界區的原因所在。
一旦,你把一個互斥鎖同時用在了多個地方,就必然會有更多的 goroutine 爭用這把鎖。這不但會讓你的程式變慢,還會大大增加死鎖(deadlock)的可能性。
所謂的死鎖,指的就是當前程式中的主 goroutine,以及我們啟用的那些 goroutine 都已經被阻塞。這些 goroutine 可以被統稱為使用者級的 goroutine。這就相當於整個程式都已經停滯不前了。
Go 語言執行時系統是不允許這種情況出現的,只要它發現所有的使用者級 goroutine 都處於等待狀態,就會自行丟擲一個帶有如下資訊的 panic:
fatal error: all goroutines are asleep - deadlock!
注意,這種由 Go 語言執行時系統自行丟擲的 panic 都屬於致命錯誤,都是無法被恢復的,呼叫recover函式對它們起不到任何作用。也就是說,一旦產生死鎖,程式必然崩潰。
因此,我們一定要儘量避免這種情況的發生。而最簡單、有效的方式就是讓每一個互斥鎖都只保護一個臨界區或一組相關臨界區。
在這個前提之下,我們還需要注意,對於同一個 goroutine 而言,既不要重複鎖定一個互斥鎖,也不要忘記對它進行解鎖。
一個 goroutine 對某一個互斥鎖的重複鎖定,就意味著它自己鎖死了自己。先不說這種做法本身就是錯誤的,在這種情況下,想讓其他的 goroutine 來幫它解鎖是非常難以保證其正確性的。
我以前就在團隊程式碼庫中見到過這樣的程式碼。那個作者的本意是先讓一個 goroutine 自己鎖死自己,然後再讓一個負責排程的 goroutine 定時地解鎖那個互斥鎖,從而讓前一個 goroutine 週期性地去做一些事情,比如每分鐘檢查一次伺服器狀態,或者每天清理一次日誌。
這個想法本身是沒有什麼問題的,但卻選錯了實現的工具。對於互斥鎖這種需要精細化控制的同步工具而言,這樣的任務並不適合它。
在這種情況下,即使選用通道或者time.Ticker型別,然後自行實現功能都是可以的,程式的複雜度和我們的心智負擔也會小很多,更何況還有不少已經很完備的解決方案可供選擇。
話說回來,其實我們說“不要忘記解鎖互斥鎖”的一個很重要的原因就是:避免重複鎖定。
因為在一個 goroutine 執行的流程中,可能會出現諸如“鎖定、解鎖、再鎖定、再解鎖”的操作,所以如果我們忘記了中間的解鎖操作,那就一定會造成重複鎖定。
除此之外,忘記解鎖還會使其他的 goroutine 無法進入到該互斥鎖保護的臨界區,這輕則會導致一些程式功能的失效,重則會造成死鎖和程式崩潰。
在很多時候,一個函式執行的流程並不是單一的,流程中間可能會有分叉,也可能會被中斷。
如果一個流程在鎖定了某個互斥鎖之後分叉了,或者有被中斷的可能,那麼就應該使用defer語句來對它進行解鎖,而且這樣的defer語句應該緊跟在鎖定操作之後。這是最保險的一種做法。
忘記解鎖導致的問題有時候是比較隱祕的,並不會那麼快就暴露出來。這也是我們需要特別關注它的原因。相比之下,解鎖未鎖定的互斥鎖會立即引發 panic。
並且,與死鎖導致的 panic 一樣,它們是無法被恢復的。因此,我們總是應該保證,對於每一個鎖定操作,都要有且只有一個對應的解鎖操作。
換句話說,我們應該讓它們成對出現。這也算是互斥鎖的一個很重要的使用原則了。在很多時候,利用defer語句進行解鎖可以更容易做到這一點。
(互斥鎖的重複鎖定和重複解鎖)
最後,可能你已經知道,Go 語言中的互斥鎖是開箱即用的。換句話說,一旦我們宣告瞭一個sync.Mutex型別的變數,就可以直接使用它了。
不過要注意,該型別是一個結構體型別,屬於值型別中的一種。把它傳給一個函式、將它從函式中返回、把它賦給其他變數、讓它進入某個通道都會導致它的副本的產生。
並且,原值和它的副本,以及多個副本之間都是完全獨立的,它們都是不同的互斥鎖。
如果你把一個互斥鎖作為引數值傳給了一個函式,那麼在這個函式中對傳入的鎖的所有操作,都不會對存在於該函式之外的那個原鎖產生任何的影響。
所以,你在這樣做之前,一定要考慮清楚,這種結果是你想要的嗎?我想,在大多數情況下應該都不是。即使你真的希望,在這個函式中使用另外一個互斥鎖也不要這樣做,這主要是為了避免歧義。
以上這些,就是我想要告訴你的關於互斥鎖的鎖定、解鎖,以及傳遞方面的知識。這其中還包括了我的一些理解。希望能夠對你有用。相關的例子我已經寫在 demo59.go 檔案中了,你可以去閱讀一番,並執行起來看看。
package main
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"sync"
"time"
)
// singleHandler 代表單次處理函式的型別。
type singleHandler func() (data string, n int, err error)
// handlerConfig 代表處理流程配置的型別。
type handlerConfig struct {
handler singleHandler // 單次處理函式。
goNum int // 需要啟用的goroutine的數量。
number int // 單個goroutine中的處理次數。
interval time.Duration // 單個goroutine中的處理間隔時間。
counter int // 資料量計數器,以位元組為單位。
counterMu sync.Mutex // 資料量計數器專用的互斥鎖。
}
// count 會增加計數器的值,並會返回增加後的計數。
func (hc *handlerConfig) count(increment int) int {
hc.counterMu.Lock()
defer hc.counterMu.Unlock()
hc.counter += increment
return hc.counter
}
func main() {
// mu 代表以下流程要使用的互斥鎖。
// 在下面的函式中直接使用即可,不要傳遞。
var mu sync.Mutex
// genWriter 代表的是用於生成寫入函式的函式。
genWriter := func(writer io.Writer) singleHandler {
return func() (data string, n int, err error) {
// 準備資料。
data = fmt.Sprintf("%s\t",
time.Now().Format(time.StampNano))
// 寫入資料。
mu.Lock()
defer mu.Unlock()
n, err = writer.Write([]byte(data))
return
}
}
// genReader 代表的是用於生成讀取函式的函式。
genReader := func(reader io.Reader) singleHandler {
return func() (data string, n int, err error) {
buffer, ok := reader.(*bytes.Buffer)
if !ok {
err = errors.New("unsupported reader")
return
}
// 讀取資料。
mu.Lock()
defer mu.Unlock()
data, err = buffer.ReadString('\t')
n = len(data)
return
}
}
// buffer 代表緩衝區。
var buffer bytes.Buffer
// 資料寫入配置。
writingConfig := handlerConfig{
handler: genWriter(&buffer),
goNum: 5,
number: 4,
interval: time.Millisecond * 100,
}
// 資料讀取配置。
readingConfig := handlerConfig{
handler: genReader(&buffer),
goNum: 10,
number: 2,
interval: time.Millisecond * 100,
}
// sign 代表訊號的通道。
sign := make(chan struct{}, writingConfig.goNum+readingConfig.goNum)
// 啟用多個goroutine對緩衝區進行多次資料寫入。
for i := 1; i <= writingConfig.goNum; i++ {
go func(i int) {
defer func() {
sign <- struct{}{}
}()
for j := 1; j <= writingConfig.number; j++ {
time.Sleep(writingConfig.interval)
data, n, err := writingConfig.handler()
if err != nil {
log.Printf("writer [%d-%d]: error: %s",
i, j, err)
continue
}
total := writingConfig.count(n)
log.Printf("writer [%d-%d]: %s (total: %d)",
i, j, data, total)
}
}(i)
}
// 啟用多個goroutine對緩衝區進行多次資料讀取。
for i := 1; i <= readingConfig.goNum; i++ {
go func(i int) {
defer func() {
sign <- struct{}{}
}()
for j := 1; j <= readingConfig.number; j++ {
time.Sleep(readingConfig.interval)
var data string
var n int
var err error
for {
data, n, err = readingConfig.handler()
if err == nil || err != io.EOF {
break
}
// 如果讀比寫快(讀時會發生EOF錯誤),那就等一會兒再讀。
time.Sleep(readingConfig.interval)
}
if err != nil {
log.Printf("reader [%d-%d]: error: %s",
i, j, err)
continue
}
total := readingConfig.count(n)
log.Printf("reader [%d-%d]: %s (total: %d)",
i, j, data, total)
}
}(i)
}
// signNumber 代表需要接收的訊號的數量。
signNumber := writingConfig.goNum + readingConfig.goNum
// 等待上面啟用的所有goroutine的執行全部結束。
for j := 0; j < signNumber; j++ {
<-sign
}
}
知識擴充套件
問題 1:讀寫鎖與互斥鎖有哪些異同?
讀寫鎖是讀 / 寫互斥鎖的簡稱。在 Go 語言中,讀寫鎖由sync.RWMutex型別的值代表。與sync.Mutex型別一樣,這個型別也是開箱即用的。
顧名思義,讀寫鎖是把對共享資源的“讀操作”和“寫操作”區別對待了。它可以對這兩種操作施加不同程度的保護。換句話說,相比於互斥鎖,讀寫鎖可以實現更加細膩的訪問控制。
一個讀寫鎖中實際上包含了兩個鎖,即:讀鎖和寫鎖。sync.RWMutex型別中的Lock方法和Unlock方法分別用於對寫鎖進行鎖定和解鎖,而它的RLock方法和RUnlock方法則分別用於對讀鎖進行鎖定和解鎖。
package main
import (
"log"
"sync"
"time"
)
// counter 代表計數器。
type counter struct {
num uint // 計數。
mu sync.RWMutex // 讀寫鎖。
}
// number 會返回當前的計數。
func (c *counter) number() uint {
c.mu.RLock()
defer c.mu.RUnlock()
return c.num
}
// add 會增加計數器的值,並會返回增加後的計數。
func (c *counter) add(increment uint) uint {
c.mu.Lock()
defer c.mu.Unlock()
c.num += increment
return c.num
}
func main() {
c := counter{}
count(&c)
redundantUnlock()
}
func count(c *counter) {
// sign 用於傳遞演示完成的訊號。
sign := make(chan struct{}, 3)
go func() { // 用於增加計數。
defer func() {
sign <- struct{}{}
}()
for i := 1; i <= 10; i++ {
time.Sleep(time.Millisecond * 500)
c.add(1)
}
}()
go func() {
defer func() {
sign <- struct{}{}
}()
for j := 1; j <= 20; j++ {
time.Sleep(time.Millisecond * 200)
log.Printf("The number in counter: %d [%d-%d]",
c.number(), 1, j)
}
}()
go func() {
defer func() {
sign <- struct{}{}
}()
for k := 1; k <= 20; k++ {
time.Sleep(time.Millisecond * 300)
log.Printf("The number in counter: %d [%d-%d]",
c.number(), 2, k)
}
}()
<-sign
<-sign
<-sign
}
func redundantUnlock() {
var rwMu sync.RWMutex
// 示例1。
//rwMu.Unlock() // 這裡會引發panic。
// 示例2。
//rwMu.RUnlock() // 這裡會引發panic。
// 示例3。
rwMu.RLock()
//rwMu.Unlock() // 這裡會引發panic。
rwMu.RUnlock()
// 示例4。
rwMu.Lock()
//rwMu.RUnlock() // 這裡會引發panic。
rwMu.Unlock()
}
另外,對於同一個讀寫鎖來說有如下規則。
1、在寫鎖已被鎖定的情況下再試圖鎖定寫鎖,會阻塞當前的 goroutine。
2、在寫鎖已被鎖定的情況下試圖鎖定讀鎖,也會阻塞當前的 goroutine。
3、在讀鎖已被鎖定的情況下試圖鎖定寫鎖,同樣會阻塞當前的 goroutine。
4、在讀鎖已被鎖定的情況下再試圖鎖定讀鎖,並不會阻塞當前的 goroutine。
換一個角度來說,對於某個受到讀寫鎖保護的共享資源,多個寫操作不能同時進行,寫操作和讀操作也不能同時進行,但多個讀操作卻可以同時進行。
當然了,只有在我們正確使用讀寫鎖的情況下,才能達到這種效果。還是那句話,我們需要讓每一個鎖都只保護一個臨界區,或者一組相關臨界區,並以此儘量減少誤用的可能性。順便說一句,我們通常把這種不能同時進行的操作稱為互斥操作。
再來看另一個方面。對寫鎖進行解鎖,會喚醒“所有因試圖鎖定讀鎖,而被阻塞的 goroutine”,並且,這通常會使它們都成功完成對讀鎖的鎖定。
然而,對讀鎖進行解鎖,只會在沒有其他讀鎖鎖定的前提下,喚醒“因試圖鎖定寫鎖,而被阻塞的 goroutine”;並且,最終只會有一個被喚醒的 goroutine 能夠成功完成對寫鎖的鎖定,其他的 goroutine 還要在原處繼續等待。至於是哪一個 goroutine,那就要看誰的等待時間最長了。
除此之外,讀寫鎖對寫操作之間的互斥,其實是通過它內含的一個互斥鎖實現的。因此,也可以說,Go 語言的讀寫鎖是互斥鎖的一種擴充套件。
最後,需要強調的是,與互斥鎖類似,解鎖“讀寫鎖中未被鎖定的寫鎖”,會立即引發 panic,對於其中的讀鎖也是如此,並且同樣是不可恢復的。
總之,讀寫鎖與互斥鎖的不同,都源於它把對共享資源的寫操作和讀操作區別對待了。這也使得它實現的互斥規則要更復雜一些。
不過,正因為如此,我們可以使用它對共享資源的操作,實行更加細膩的控制。另外,由於這裡的讀寫鎖是互斥鎖的一種擴充套件,所以在有些方面它還是沿用了互斥鎖的行為模式。比如,在解鎖未鎖定的寫鎖或讀鎖時的表現,又比如,對寫操作之間互斥的實現方式。
總結
我們今天討論了很多與多執行緒、共享資源以及同步有關的知識。其中涉及了不少重要的併發程式設計概念,比如,競態條件、臨界區、互斥量、死鎖等。
雖然 Go 語言是以“用通訊的方式共享資料”為亮點的,但是它依然提供了一些易用的同步工具。其中,互斥鎖是我們最常用到的一個。
互斥鎖常常被用來:保證多個 goroutine 併發地訪問同一個共享資源時的完全序列,這是通過保護針對此共享資源的一個臨界區,或一組相關臨界區實現的。因此,我們可以把它看做是 goroutine 進入相關臨界區時,必須拿到的訪問令牌。
為了用對並且用好互斥鎖,我們需要了解它實現的互斥規則,更要理解一些關於它的注意事項。
比如,不要重複鎖定或忘記解鎖,因為這會造成 goroutine 不必要的阻塞,甚至導致程式的死鎖。
又比如,不要傳遞互斥鎖,因為這會產生它的副本,從而引起歧義並可能導致互斥操作的失效。
再次強調,我們總是應該讓每一個互斥鎖都只保護一個臨界區,或一組相關臨界區。
至於讀寫鎖,它是互斥鎖的一種擴充套件。我們需要知道它與互斥鎖的異同,尤其是互斥規則和行為模式方面的異同。一個讀寫鎖中同時包含了讀鎖和寫鎖,由此也可以看出它對於針對共享資源的讀操作和寫操作是區別對待的。我們可以基於這件事,對共享資源實施更加細緻的訪問控制。
最後,需要特別注意的是,無論是互斥鎖還是讀寫鎖,我們都不要試圖去解鎖未鎖定的鎖,因為這樣會引發不可恢復的 panic。
思考題
你知道互斥鎖和讀寫鎖的指標型別都實現了哪一個介面嗎?
怎樣獲取讀寫鎖中的讀鎖?
筆記原始碼
https://github.com/MingsonZheng/go-core-demo
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。