相比于Go語言宣揚的“用通訊的方式共享資料”,通過共享資料的方式來傳遞資訊和協調線程運作的做法其實更加主流。本篇就是讨論一些與多線程、共享資源以及同步有關的知識。
sync包,就是一個與并發程式設計關系緊密的代碼包。這裡“sync”的中文意思就是“同步”。
這裡會講一些重要的并發程式設計概念:競态條件、臨界區、互斥量、死鎖。死鎖會在互斥鎖裡引出。
一旦資料被多個線程共享,那麼就很可能會産生争用和沖突的情況。這種情況也被稱為競态條件(race condition),這往往會破幻共享資料的一緻性。
概括來講,同步的用途有兩個:
避免多個線程在同一時刻操作同一個資料塊
協調多個線程,避免它們在同一時刻執行同一個代碼塊
由于這樣的資料塊和代碼塊的背後都隐含着一種或多種資源,可以把他們看作是共享資源。
同步就是在控制多個線程對共享資源的通路。針對某個資源的通路,同一時刻隻能有一個線程通路到該資源。那麼可以說,多個并發進行的線程對這個共享資源的通路是完全串行的。隻要一個代碼片段需要實作對共享資源的串行化通路,就可以被視為一個臨界區(critical section)。也就是說,要通路到資源就必須進入到這個區域。如果針對一個共享資源,這樣的代碼片段有多個,那麼它們就可以被稱為相關臨界區。
應對競态條件的問題,就需要施加一些保護的手段。方法之一就是使用實作了某種同步機制的工具,也稱為同步工具。在Go語言中,可供我們選擇的同步工具并不少。其中,最重要且最常用的同步工具當屬互斥量(mutual exclusion,簡稱 mutex)。sync包中的Mutex就是與其對應的類型,該類型的值可以被稱為互斥量或者互斥鎖。
雖然Go語言是以“用通訊的方式共享資料”為亮點,但是依然提供了一些易用的同步工具。而互斥鎖就是最常用到的一個。
一個互斥鎖可以被用來保護一個臨界區或者一組相關臨界區。保證同一時刻隻有一個goroutine處于改臨界區之内。每當有goroutine想進入臨界區是,需要對它進行鎖定,并且在離開臨界區時進行解鎖。
使用互斥鎖時,鎖定操作可以通過調用互斥鎖的Lock方法實作,而解鎖是調用Unlock方法。示例如下:
這個示例提供了一個指令行參數-lock,可以選擇加鎖或者不加鎖來運作這個程式。這樣可以友善的比較在代碼中加鎖的作用。
使用互斥鎖時的注意事項:
不要重複加鎖
不要忘記解鎖,最好是使用defer語句
不要對尚未加鎖或者已經解鎖的互斥鎖解鎖
不要在多個函數之間直接傳遞互斥鎖
對一個已經被鎖定的互斥鎖進行鎖定,是會立即阻塞目前goroutine的。會一直等到該互斥鎖在别的goroutine裡被解鎖,并且這裡的鎖定操作完成為止。如果那邊解鎖後又被别的goroutine鎖定了,那就繼續等,一直到搶到鎖完成鎖定操作。
雖然沒有任何的強制規定,你是可以用同一個互斥鎖保護多個無關的臨界區的。但是這樣做,一定會使你的程式變的複雜,就是說不要這麼做,需要的話,就多搞幾把鎖。如果真的把一個互斥鎖同時用在了多個地方,必然會有更多的goroutine征用這把鎖。這不但會使得程式變慢,還會打打增加死鎖(deadlock)的可能性。
死鎖
所謂死鎖,就是目前程式中的主goroutine,以及啟用的那個goroutine都已經被阻塞。這些goroutine可以被統稱為使用者級的goroutine。就是說整個程式都停滞不前了。
Go語言運作時,系統是不允許死鎖的情況出現的。隻要發現所有的使用者級goroutine都處于等待狀态,就會自行抛出panic。随便寫個函數,連續上2次鎖就死鎖了:
抛出的資訊如下,主要就看第一行<code>fatal error: all goroutines are asleep - deadlock!</code>:
這種在Go運作時系統自行抛出的panic都屬于緻命錯誤,是無法被恢複的。調用recover函數也不起作用。就是說,一旦死鎖,程式必然崩潰。
要避免這種情況,最有效的做法就是,讓每一個互斥鎖隻保護一個臨界區或一組相關的臨界區。
用defer語句解鎖
還要注意,對同一個goroutine而言,既不要重複鎖定一個互斥鎖,也不要忘記進行解鎖。這裡不要忘記解鎖的一個很重要的原因就是為了避免重複鎖定。在很多時候,一個函數執行的流程并不是單一的,流程中間可能會有分叉、也可能會被中斷。最保險的做法就是使用defer語句來進行解鎖,并且這樣的defer語句應該緊跟在鎖定操作的後面。
上面的那個示例,沒有按這裡說的來做,因為整個寫操作是在for循環裡的。解鎖操作後還有其他語句要執行,這裡是for循環裡的其他疊代要處理。而defer語句是隻有程式退出後才會執行的。不過這都不是借口,要按這裡最保險的做法來做,隻需要把for循環裡的語句再寫一個函數或匿名函數就可以用defer了:
解鎖未鎖定的互斥鎖也會立即引發panic。并且與死鎖一樣,也是無法被恢複的。從這一定看,也是需要保證對于沒一個鎖定操作,都必須且隻能由一個對應的解鎖操作。就是要讓他們成對出現,這也算是互斥鎖一個很重要的使用原則。而利用defer語句進行解鎖就可以很容易的做到這一點。
互斥鎖是結構體、值類型
Go語言中的互斥鎖時開箱即用的,就是一旦聲明了一個sync.Mutex類型的變量,就可以直接使用它。不過要注意,該類型是一個結構體,屬于值類型:
對于值類型,把它傳遞給一個函數、将他從函數中傳回、把它指派給其他變量、讓它進入某個通道都會導緻它的副本的産生。這裡,原值和副本以及多個副本之間都是完全獨立的,是不同的互斥鎖。舉例說明,如果你把一個互斥鎖作為參數值傳給了一個函數,那麼在這個函數中對傳入的鎖的所有操作,都不會對存在于該函數之外的那個原鎖産生任何影響。
這就是為什麼“不要在多個函數之間直接傳遞互斥鎖”。避免歧義,即使你希望的是在這個函數中使用另外一個互斥鎖也不要這樣做。
學習了上面的注意事項和建議,就來看看如何更好的使用互斥鎖。下面是一個使用互斥鎖的示例:
這個示例中,分别有讀和寫的兩個處理函數。而處理函數裡做的事情就是:加鎖、defer解鎖,完成讀或寫操作然後傳回。這裡就做到了加鎖和解鎖操作成對出現,并且把鎖和要保護的共享資源放在一起了。
示例中還有一個互斥鎖在handlerConfig結構體中,要保護的共享資源也是handlerConfig結構體中的counter字段。并且寫了一個方法count實作對counter字段的鎖定和修改。
讀寫鎖是讀/寫互斥鎖的簡稱。在Go語言中,讀寫鎖有sync.RWMutex類型的值代表。與sync.Mutex一樣,這個類型也是開箱即用的。開箱即用,應該就是指不用指派,定義了之後直接就能用了。就是讓它的零值也具有意義。
讀寫鎖就是把共享資源的“讀操作”和“寫操作”差別對待了。為兩種操作施加了不同程度的保護。相比于互斥鎖,讀寫鎖可以實作更加細膩的通路控制。
一個讀寫鎖中實際包含了兩個鎖,讀鎖和寫鎖:
寫鎖,它的Lock方法和Unlock方法分别用于對寫鎖進行鎖定和解鎖
讀鎖,它的RLock方法和RUnlock方法分别用于對讀鎖進行鎖定和解鎖
對于同一個讀寫鎖,有如下的規則:
在寫鎖已被鎖定的情況下,再視圖鎖定寫鎖,會阻塞目前goroutine
在寫鎖已被鎖定的情況下,試圖鎖定讀鎖,也會阻塞目前goroutine
在讀鎖已被鎖定的情況下,試圖鎖定寫鎖,同樣會阻塞目前goroutine
在讀寫已被鎖定的情況下,再視圖鎖定讀鎖,并不會阻塞目前的goroutine
總結一下,就是可以有多個讀操作,讀鎖鎖定的情況下,别的goroutine也可以讀。其他的情況下要操作,隻能等之前鎖定的操作完成釋放鎖,并且搶到鎖了。再換個角度說,就是多個讀操作可以同時進行,多個寫操作不能同時進行,讀和寫操作也不能同時進行。
讀寫鎖對寫操作之間的互斥,其實是通過它内含的一個互斥鎖實作的。是以,讀寫鎖是互斥鎖的一種擴充。是以無論是互斥鎖還是讀寫鎖,都不要試圖去解鎖未鎖定的鎖,因為這樣會引發不可恢複的panic。
之前互斥鎖的示例中,使用互斥鎖保護了對緩沖區的讀寫操作,而這裡又講了讀寫鎖,不要被這裡讀和寫的說法鎖迷惑。對緩沖區的讀操作是會把讀到的内容從緩沖區裡去除的,是以是有類似寫的操作在裡面的,使用互斥鎖時正确的做法,并且不能使用這裡的讀寫鎖。
而這個示例中的讀操作,就僅僅隻是去擷取到值而已了,在讀操作的時候加個讀鎖正合适: