GO語言併發程式設計之互斥鎖、讀寫鎖詳解
在本節,我們對Go語言所提供的與鎖有關的API進行說明。這包括了互斥鎖和讀寫鎖。我們在第6章描述過互斥鎖,但卻沒有提到過讀寫鎖。這兩種鎖對於傳統的併發程式來說都是非常常用和重要的。
一、互斥鎖
互斥鎖是傳統的併發程式對共享資源進行訪問控制的主要手段。它由標準庫程式碼包sync中的Mutex結構體型別代表。sync.Mutex型別(確切地說,是*sync.Mutex型別)只有兩個公開方法——Lock和Unlock。顧名思義,前者被用於鎖定當前的互斥量,而後者則被用來對當前的互斥量進行解鎖。
型別sync.Mutex的零值表示了未被鎖定的互斥量。也就是說,它是一個開箱即用的工具。我們只需對它進行簡單宣告就可以正常使用了,就像這樣:
- var mutex sync.Mutex
- mutex.Lock()
複製程式碼
在我們使用其他程式語言(比如C或Java)的鎖類工具的時候,可能會犯的一個低階錯誤就是忘記及時解開已被鎖住的鎖,從而導致諸如流程執行異常、執行緒執行停滯甚至程式死鎖等等一系列問題的發生。然而,在Go語言中,這個低階錯誤的發生機率極低。其主要原因是有defer語句的存在。
我們一般會在鎖定互斥鎖之後緊接著就用defer語句來保證該互斥鎖的及時解鎖。請看下面這個函式:
- var mutex sync.Mutex
- func write() {
- mutex.Lock()
- defer mutex.Unlock()
- // 省略若干條語句
- }
複製程式碼
函式write中的這條defer語句保證了在該函式被執行結束之前互斥鎖mutex一定會被解鎖。這省去了我們在所有return語句之前以及異常發生之時重複的附加解鎖操作的工作。在函式的內部執行流程相對複雜的情況下,這個工作量是不容忽視的,並且極易出現遺漏和導致錯誤。所以,這裡的defer語句總是必要的。在Go語言中,這是很重要的一個慣用法。我們應該養成這種良好的習慣。
對於同一個互斥鎖的鎖定操作和解鎖操作總是應該成對的出現。如果我們鎖定了一個已被鎖定的互斥鎖,那麼進行重複鎖定操作的Goroutine將會被阻塞,直到該互斥鎖回到解鎖狀態。請看下面的示例:
- func repeatedlyLock() {
- var mutex sync.Mutex
- fmt.Println("Lock the lock. (G0)")
- mutex.Lock()
- fmt.Println("The lock is locked. (G0)")
- for i := 1; i <= 3; i++ {
- go func(i int) {
- fmt.Printf("Lock the lock. (G%d)n", i)
- mutex.Lock()
- fmt.Printf("The lock is locked. (G%d)n", i)
- }(i)
- }
- time.Sleep(time.Second)
- fmt.Println("Unlock the lock. (G0)")
- mutex.Unlock()
- fmt.Println("The lock is unlocked. (G0)")
- time.Sleep(time.Second)
- }
複製程式碼
我們把執行repeatedlyLock函式的Goroutine稱為G0。而在repeatedlyLock函式中,我們又啟用了3個Goroutine,並分別把它們命名為G1、G2和G3。可以看到,我們在啟用這3個Goroutine之前就已經對互斥鎖mutex進行了鎖定,並且在這3個Goroutine將要執行的go函式的開始處也加入了對mutex的鎖定操作。這樣做的意義是模擬併發地對同一個互斥鎖進行鎖定的情形。當for語句被執行完畢之後,我們先讓G0小睡1秒鐘,以使執行時系統有充足的時間開始執行G1、G2和G3。在這之後,解鎖mutex。為了能夠讓讀者更加清晰地瞭解到repeatedlyLock函式被執行的情況,我們在這些鎖定和解鎖操作的前後加入了若干條列印語句,並在列印內容中添加了我們為這幾個Goroutine起的名字。也由於這個原因,我們在repeatedlyLock函式的最後再次編寫了一條“睡眠”語句,以此為可能出現的其他列印內容再等待一小會兒。
經過短暫的執行,標準輸出上會出現如下內容:
- Lock the lock. (G0)
- The lock is locked. (G0)
- Lock the lock. (G1)
- Lock the lock. (G2)
- Lock the lock. (G3)
- Unlock the lock. (G0)
- The lock is unlocked. (G0)
- The lock is locked. (G1)
複製程式碼
從這八行列印內容中,我們可以清楚的看出上述四個Goroutine的執行情況。首先,在repeatedlyLock函式被執行伊始,對互斥鎖的第一次鎖定操作便被進行並順利地完成。這由第一行和第二行列印內容可以看出。而後,在repeatedlyLock函式中被啟用的那三個Goroutine在G0的第一次“睡眠”期間開始被執行。當相應的go函式中的對互斥鎖的鎖定操作被進行的時候,它們都被阻塞住了。原因是該互斥鎖已處於鎖定狀態了。這就是我們在這裡只看到了三個連續的Lock the lock. (G<i>)而沒有立即看到The lock is locked. (G<i>)的原因。隨後,G0“睡醒”並解鎖互斥鎖。這使得正在被阻塞的G1、G2和G3都會有機會重新鎖定該互斥鎖。但是,只有一個Goroutine會成功。成功完成鎖定操作的某一個Goroutine會繼續執行在該操作之後的語句。而其他Goroutine將繼續被阻塞,直到有新的機會到來。這也就是上述列印內容中的最後三行所表達的含義。顯然,G1搶到了這次機會併成功鎖定了那個互斥鎖。
實際上,我們之所以能夠通過使用互斥鎖對共享資源的唯一性訪問進行控制正是因為它的這一特性。這有效的對競態條件進行了消除。
互斥鎖的鎖定操作的逆操作並不會引起任何Goroutine的阻塞。但是,它的進行有可能引發執行時恐慌。更確切的講,當我們對一個已處於解鎖狀態的互斥鎖進行解鎖操作的時候,就會已發一個執行時恐慌。這種情況很可能會出現在相對複雜的流程之中——我們可能會在某個或多個分支中重複的加入針對同一個互斥鎖的解鎖操作。避免這種情況發生的最簡單、有效的方式依然是使用defer語句。這樣更容易保證解鎖操作的唯一性。
雖然互斥鎖可以被直接的在多個Goroutine之間共享,但是我們還是強烈建議把對同一個互斥鎖的成對的鎖定和解鎖操作放在同一個層次的程式碼塊中。例如,在同一個函式或方法中對某個互斥鎖的進行鎖定和解鎖。又例如,把互斥鎖作為某一個結構體型別中的欄位,以便在該型別的多個方法中使用它。此外,我們還應該使代表互斥鎖的變數的訪問權限盡量的低。這樣才能儘量避免它在不相關的流程中被誤用,從而導致程式不正確的行為。
互斥鎖是我們見到過的眾多同步工具中最簡單的一個。只要遵循前面提及的幾個小技巧,我們就可以以正確、高效的方式使用互斥鎖,並用它來確保對共享資源的訪問的唯一性。下面我們來看看稍微複雜一些的鎖實現——讀寫鎖。
二、讀寫鎖
讀寫鎖即是針對於讀寫操作的互斥鎖。它與普通的互斥鎖最大的不同就是,它可以分別針對讀操作和寫操作進行鎖定和解鎖操作。讀寫鎖遵循的訪問控制規則與互斥鎖有所不同。在讀寫鎖管轄的範圍內,它允許任意個讀操作的同時進行。但是,在同一時刻,它只允許有一個寫操作在進行。並且,在某一個寫操作被進行的過程中,讀操作的進行也是不被允許的。也就是說,讀寫鎖控制下的多個寫操作之間都是互斥的,並且寫操作與讀操作之間也都是互斥的。但是,多個讀操作之間卻不存在互斥關係。
這樣的規則對於針對同一塊資料的併發讀寫來講是非常貼切的。因為,無論讀操作的併發量有多少,這些操作都不會對資料本身造成變更。而寫操作不但會對同時進行的其他寫操作進行干擾,還有可能造成同時進行的讀操作的結果的不正確。例如,在32位的作業系統中,針對int64型別值的讀操作和寫操作都不可能只由一個CPU指令完成。在一個寫操作被進行的過程當中,針對同一個只的讀操作可能會讀取到未被修改完成的值。該值既不與舊的值相等,也不等於新的值。這種錯誤往往不易被發現,且很難被修正。因此,在這樣的場景下,讀寫鎖可以在大大降低因使用鎖而對程式效能造成的損耗的情況下完成對共享資源的訪問控制。
在Go語言中,讀寫鎖由結構體型別sync.RWMutex代表。與互斥鎖類似,sync.RWMutex型別的零值就已經是立即可用的讀寫鎖了。在此型別的方法集合中包含了兩對方法,即:
- func (*RWMutex) Lock
- func (*RWMutex) Unlock
複製程式碼
和
- func (*RWMutex) RLock
- func (*RWMutex) RUnlock
複製程式碼
前一對方法的名稱和簽名與互斥鎖的那兩個方法完全一致。它們分別代表了對寫操作的鎖定和解鎖。以下簡稱它們為寫鎖定和寫解鎖。而後一對方法則分別表示了對讀操作的鎖定和解鎖。以下簡稱它們為讀鎖定和讀解鎖。
對已被寫鎖定的讀寫鎖進行寫鎖定,會造成當前Goroutine的阻塞,直到該讀寫鎖被寫解鎖。當然,如果有多個Goroutine因此而被阻塞,那麼當對應的寫解鎖被進行之時只會使其中一個Goroutine的執行被恢復。類似的,對一個已被寫鎖定的讀寫鎖進行讀鎖定,也會阻塞相應的Goroutine。但不同的是,一旦該讀寫鎖被寫解鎖,那麼所有因欲進行讀鎖定而被阻塞的Goroutine的執行都會被恢復。另一方面,如果在進行過程中發現當前的讀寫鎖已被讀鎖定,那麼這個寫鎖定操作將會等待直至所有施加於該讀寫鎖之上的讀鎖定都被清除。同樣的,在有多個寫鎖定操作為此而等待的情況下,相應的讀鎖定的全部清除只能讓其中的某一個寫鎖定操作獲得進行的機會。
現在來關注寫解鎖和讀解鎖。如果對一個未被寫鎖定的讀寫鎖進行寫解鎖,那麼會引發一個執行時恐慌。類似的,當對一個未被讀鎖定的讀寫鎖進行讀解鎖的時候也會引發一個執行時恐慌。寫解鎖在進行的同時會試圖喚醒所有因進行讀鎖定而被阻塞的Goroutine。而讀解鎖在進行的時候則會試圖喚醒一個因進行寫鎖定而被阻塞的Goroutine。
無論鎖定針對的是寫操作還是讀操作,我們都應該儘量及時的對相應的鎖進行解鎖。對於寫解鎖,我們自不必多說。而讀解鎖的及時進行往往更容易被我們忽視。雖說讀解鎖的進行並不會對其他正在進行中的讀操作產生任何影響,但它卻與相應的寫鎖定的進行關係緊密。注意,對於同一個讀寫鎖來說,施加在它之上的讀鎖定可以有多個。因此,只有我們對互斥鎖進行相同數量的讀解鎖,才能夠讓某一個相應的寫鎖定獲得進行的機會。否則,後者會繼續使進行它的Goroutine處於阻塞狀態。由於sync.RWMutex和*sync.RWMutex型別都沒有相應的方法讓我們獲得已進行的讀鎖定的數量,所以這裡是很容易出現問題的。還好我們可以使用defer語句來儘量避免此類問題的發生。請記住,針對同一個讀寫鎖的寫鎖定和讀鎖定是互斥的。無論是寫解鎖還是讀解鎖,操作的不及時都會對使用該讀寫鎖的流程的正常執行產生負面影響。
除了我們在前面詳細講解的那兩對方法之外,*sync.RWMutex型別還擁有另外一個方法——RLocker。這個RLocker方法會返回一個實現了sync.Locker介面的值。sync.Locker介面型別包含了兩個方法,即:Lock和Unlock。細心的讀者可能會發現,*sync.Mutex型別和*sync.RWMutex型別都是該介面型別的實現型別。實際上,我們在呼叫*sync.RWMutex型別值的RLocker方法之後所得到的結果值就是這個值本身。只不過,這個結果值的Lock方法和Unlock方法分別對應了針對該讀寫鎖的讀鎖定操作和讀解鎖操作。換句話說,我們在對一個讀寫鎖的RLocker方法的結果值的Lock方法或Unlock方法進行呼叫的時候實際上是在呼叫該讀寫鎖的RLock方法或RUnlock方法。這樣的操作適配在實現上並不困難。我們自己也可以很容易的編寫出這些方法的實現。通過讀寫鎖的RLocker方法獲得這樣一個結果值的實際意義在於,我們可以在之後以相同的方式對該讀寫鎖中的“寫鎖”和“讀鎖”進行操作。這為相關操作的靈活適配和替換提供了方便。
三、鎖的完整示例
我們下面來看一個與上述鎖實現有關的示例。在Go語言的標準庫程式碼包os中有一個名為File的結構體型別。os.File型別的值可以被用來代表檔案系統中的某一個檔案或目錄。它的方法集合中包含了很多方法,其中的一些方法被用來對相應的檔案進行寫操作和讀操作。
假設,我們需要建立一個檔案來存放資料。在同一個時刻,可能會有多個Goroutine分別進行對此檔案的進行寫操作和讀操作。每一次寫操作都應該向這個檔案寫入若干個位元組的資料。這若干位元組的資料應該作為一個獨立的資料塊存在。這就意味著,寫操作之間不能彼此干擾,寫入的內容之間也不能出現穿插和混淆的情況。另一方面,每一次讀操作都應該從這個檔案中讀取一個獨立、完整的資料塊。它們讀取的資料塊不能重複,且需要按順序讀取。例如,第一個讀操作讀取了資料塊1,那麼第二個讀操作就應該去讀取資料塊2,而第三個讀操作則應該讀取資料塊3,以此類推。對於這些讀操作是否可以被同時進行,這裡並不做要求。即使它們被同時進行,程式也應該分辨出它們的先後順序。
為了突出重點,我們規定每個資料塊的長度都是相同的。該長度應該在初始化的時候被給定。若寫操作實際欲寫入資料的長度超過了該值,則超出部分將會被截掉。
當我們拿到這樣一個需求的時候,首先應該想到使用os.File型別。它為我們操作檔案系統中的檔案提供了底層的支援。但是,該型別的相關方法並沒有對併發操作的安全性進行保證。換句話說,這些方法不是併發安全的。我只能通過額外的同步手段來保證這一點。鑑於這裡需要分別對兩類操作(即寫操作和讀操作)進行訪問控制,所以讀寫鎖在這裡會比普通的互斥鎖更加適用。不過,關於多個讀操作要按順序且不能重複讀取的這個問題,我們需還要使用其他輔助手段來解決。
為了實現上述需求,我們需要建立一個型別。作為該型別的行為定義,我們先編寫了一個這樣的介面:
- // 資料檔案的介面型別。
- type DataFile interface {
- // 讀取一個數據塊。
- Read() (rsn int64, d Data, err error)
- // 寫入一個數據塊。
- Write(d Data) (wsn int64, err error)
- // 獲取最後讀取的資料塊的序列號。
- Rsn() int64
- // 獲取最後寫入的資料塊的序列號。
- Wsn() int64
- // 獲取資料塊的長度
- DataLen() uint32
- }
複製程式碼
其中,型別Data被宣告為一個[]byte的別名型別:
- // 資料的型別
- type Data []byte
複製程式碼
而名稱wsn和rsn分別是Writing Serial Number和Reading Serial Number的縮寫形式。它們分別代表了最後被寫入的資料塊的序列號和最後被讀取的資料塊的序列號。這裡所說的序列號相當於一個計數值,它會從1開始。因此,我們可以通過呼叫Rsn方法和Wsn方法得到當前已被讀取和寫入的資料塊的數量。
根據上面對需求的簡單分析和這個DataFile介面型別宣告,我們就可以來編寫真正的實現了。我們將這個實現型別命名為myDataFile。它的基本結構如下:
// 資料檔案的實現型別。
type myDataFile struct {
f *os.File // 檔案。
fmutex sync.RWMutex // 被用於檔案的讀寫鎖。
woffset int64 // 寫操作需要用到的偏移量。
roffset int64 // 讀操作需要用到的偏移量。
wmutex sync.Mutex // 寫操作需要用到的互斥鎖。
rmutex sync.Mutex // 讀操作需要用到的互斥鎖。
dataLen uint32 // 資料塊長度。
}
複製程式碼
型別myDataFile共有七個欄位。我們已經在前面說明過前兩個欄位存在的意義。由於對資料檔案的寫操作和讀操作是各自獨立的,所以我們需要兩個欄位來儲存兩類操作的進行進度。在這裡,這個進度由偏移量代表。此後,我們把woffset欄位稱為寫偏移量,而把roffset欄位稱為讀偏移量。注意,我們在進行寫操作和讀操作的時候會分別增加這兩個欄位的值。當有多個寫操作同時要增加woffset欄位的值的時候就會產生競態條件。因此,我們需要互斥鎖wmutex來對其加以保護。類似的,rmutex互斥鎖被用來消除多個讀操作同時增加roffset欄位的值時產生的競態條件。最後,由上述的需求可知,資料塊的長度應該是在初始化myDataFile型別值的時候被給定的。這個長度會被儲存在該值的dataLen欄位中。它與DataFile介面中宣告的DataLen方法是對應的。下面我們就來看看被用來建立和初始化DataFile型別值的函式NewDataFile。
關於這類函式的編寫,讀者應該已經駕輕就熟了。NewDataFile函式會返回一個DataFile型別值,但是實際上它會建立並初始化一個*myDataFile型別的值並把它作為它的結果值。這樣可以通過編譯的原因是,後者會是前者的一個實現型別。NewDataFile函式的完整宣告如下:
func NewDataFile(path string, dataLen uint32) (DataFile, error) {
f, err := os.Create(path)
if err != nil {
return nil, err
}
if dataLen == 0 {
return nil, errors.New("Invalid data length!")
}
df := &myDataFile{f: f, dataLen: dataLen}
return df, nil
}
複製程式碼
可以看到,我們在建立*myDataFile型別值的時候只需要對其中的欄位f和dataLen進行初始化。這是因為woffset欄位和roffset欄位的零值都是0,而在未進行過寫操作和讀操作的時候它們的值理應如此。對於欄位fmutex、wmutex和rmutex來說,它們的零值即為可用的鎖。所以我們也不必對它們進行顯式的初始化。
把變數df的值作為NewDataFile函式的第一個結果值體現了我們的設計意圖。但要想使*myDataFile型別真正成為DataFile型別的一個實現型別,我們還需要為*myDataFile型別編寫出已在DataFile介面型別中宣告的所有方法。其中最重要的當屬Read方法和Write方法。
我們先來編寫*myDataFile型別的Read方法。該方法應該按照如下步驟實現。
(1) 獲取並更新讀偏移量。
(2) 根據讀偏移量從檔案中讀取一塊資料。
(3) 把該資料塊封裝成一個Data型別值並將其作為結果值返回。
其中,前一個步驟在被執行的時候應該由互斥鎖rmutex保護起來。因為,我們要求多個讀操作不能讀取同一個資料塊,並且它們應該按順序的讀取檔案中的資料塊。而第二個步驟,我們也會用讀寫鎖fmutex加以保護。下面是這個Read方法的第一個版本:
func (df *myDataFile) Read() (rsn int64, d Data, err error) {
// 讀取並更新讀偏移量
var offset int64
df.rmutex.Lock()
offset = df.roffset
df.roffset += int64(df.dataLen)
df.rmutex.Unlock()
//讀取一個數據塊
rsn = offset / int64(df.dataLen)
df.fmutex.RLock()
defer df.fmutex.RUnlock()
bytes := make([]byte, df.dataLen)
_, err = df.f.ReadAt(bytes, offset)
if err != nil {
return
}
d = bytes
return
}
複製程式碼
可以看到,在讀取並更新讀偏移量的時候,我們用到了rmutex欄位。這保證了可能同時執行在多個Goroutine中的這兩行程式碼:
- offset = df.roffset
- df.roffset += int64(df.dataLen)
複製程式碼
的執行是互斥的。這是我們為了獲取到不重複且正確的讀偏移量所必需採取的措施。
另一方面,在讀取一個數據塊的時候,我們適時的進行了fmutex欄位的讀鎖定和讀解鎖操作。fmutex欄位的這兩個操作可以保證我們在這裡讀取到的是完整的資料塊。不過,這個完整的資料塊卻並不一定是正確的。為什麼會這樣說呢?
請想象這樣一個場景。在我們的程式中,有3個Goroutine來併發的執行某個*myDataFile型別值的Read方法,並有2個Goroutine來併發的執行該值的Write方法。通過前3個Goroutine的執行,資料檔案中的資料塊被依次的讀取了出來。但是,由於進行寫操作的Goroutine比進行讀操作的Goroutine少,所以過不了多久讀偏移量roffset的值就會等於甚至大於寫偏移量woffset的值。也就是說,讀操作很快就會沒有資料可讀了。這種情況會使上面的df.f.ReadAt方法返回的第二個結果值為代表錯誤的非nil且會與io.EOF相等的值。實際上,我們不應該把這樣的值看成錯誤的代表,而應該把它看成一種邊界情況。但不幸的是,我們在這個版本的Read方法中並沒有對這種邊界情況做出正確的處理。該方法在遇到這種情況時會直接把錯誤值返回給它的呼叫方。該呼叫方會得到讀取出錯的資料塊的序列號,但卻無法再次嘗試讀取這個資料塊。由於其他正在或後續執行的Read方法會繼續增加讀偏移量roffset的值,所以當該呼叫方再次呼叫這個Read方法的時候只可能讀取到在此資料塊後面的其他資料塊。注意,執行Read方法時遇到上述情況的次數越多,被漏讀的資料塊也就會越多。為了解決這個問題,我們編寫了Read方法的第二個版本:
func (df *myDataFile) Read() (rsn int64, d Data, err error) {
// 讀取並更新讀偏移量
// 省略若干條語句
//讀取一個數據塊
rsn = offset / int64(df.dataLen)
bytes := make([]byte, df.dataLen)
for {
df.fmutex.RLock()
_, err = df.f.ReadAt(bytes, offset)
if err != nil {
if err == io.EOF {
df.fmutex.RUnlock()
continue
}
df.fmutex.RUnlock()
return
}
d = bytes
df.fmutex.RUnlock()
return
}
}
複製程式碼
在上面的Read方法展示中,我們省略了若干條語句。原因在這個位置上的那些語句並沒有任何變化。為了進一步節省篇幅,我們在後面也會遵循這樣的省略原則。
第二個版本的Read方法使用for語句是為了達到這樣一個目的:在其中的df.f.ReadAt方法返回io.EOF錯誤的時候繼續嘗試獲取同一個資料塊,直到獲取成功為止。注意,如果在該for程式碼塊被執行期間一直讓讀寫鎖fmutex處於讀鎖定狀態,那麼針對它的寫鎖定操作將永遠不會成功,且相應的Goroutine也會被一直阻塞。因為它們是互斥的。所以,我們不得不在該for語句塊中的每條return語句和continue語句的前面都加入一個針對該讀寫鎖的讀解鎖操作,並在每次迭代開始時都對fmutex進行一次讀鎖定。顯然,這樣的程式碼看起來很醜陋。冗餘的程式碼會使程式碼的維護成本和出錯機率大大增加。並且,當for程式碼塊中的程式碼引發了執行時恐慌的時候,我們是很難及時的對讀寫鎖fmutex進行讀解鎖的。即便可以這樣做,那也會使Read方法的實現更加醜陋。我們因為要處理一種邊界情況而去掉了defer df.fmutex.RUnlock()語句。這種做法利弊參半。
其實,我們可以做得更好。但是這涉及到了其他同步工具。因此,我們以後再來對Read方法進行進一步的改造。順便提一句,當df.f.ReadAt方法返回一個非nil且不等於io.EOF的錯誤值的時候,我們總是應該放棄再次獲取目標資料塊的嘗試而立即將該錯誤值返回給Read方法的呼叫方。因為這樣的錯誤很可能是嚴重的(比如,f欄位代表的檔案被刪除了),需要交由上層程式去處理。
現在,我們來考慮*myDataFile型別的Write方法。與Read方法相比,Write方法的實現會簡單一些。因為後者不會涉及到邊界情況。在該方法中,我們需要進行兩個步驟,即:獲取並更新寫偏移量和向檔案寫入一個數據塊。我們直接給出Write方法的實現:
func (df *myDataFile) Write(d Data) (wsn int64, err error) {
// 讀取並更新寫偏移量
var offset int64
df.wmutex.Lock()
offset = df.woffset
df.woffset += int64(df.dataLen)
df.wmutex.Unlock()
//寫入一個數據塊
wsn = offset / int64(df.dataLen)
var bytes []byte
if len(d) > int(df.dataLen) {
bytes = d[0:df.dataLen]
} else {
bytes = d
}
df.fmutex.Lock()
df.fmutex.Unlock()
_, err = df.f.Write(bytes)
return
}
複製程式碼
這裡需要注意的是,當引數d的值的長度大於資料塊的最大長度的時候,我們會先進行截短處理再將資料寫入檔案。如果沒有這個截短處理,我們在後面計算的已讀資料塊的序列號和已寫資料塊的序列號就會不正確。
有了編寫前面兩個方法的經驗,我們可以很容易的編寫出*myDataFile型別的Rsn方法和Wsn方法:
func (df *myDataFile) Rsn() int64 {
df.rmutex.Lock()
defer df.rmutex.Unlock()
return df.roffset / int64(df.dataLen)
}
func (df *myDataFile) Wsn() int64 {
df.wmutex.Lock()
defer df.wmutex.Unlock()
return df.woffset / int64(df.dataLen)
}
複製程式碼
這兩個方法的實現分別涉及到了對互斥鎖rmutex和wmutex的鎖定操作。同時,我們也通過使用defer語句保證了對它們的及時解鎖。在這裡,我們對已讀資料塊的序列號rsn和已寫資料塊的序列號wsn的計算方法與前面示例中的方法是相同的。它們都是用相關的偏移量除以資料塊長度後得到的商來作為相應的序列號(或者說計數)的值。
至於*myDataFile型別的DataLen方法的實現,我們無需呈現。它只是簡單地將dataLen欄位的值作為其結果值返回而已。
編寫上面這個完整示例的主要目的是展示互斥鎖和讀寫鎖在實際場景中的應用。由於還沒有講到Go語言提供的其他同步工具,所以我們在相關方法中所有需要同步的地方都是用鎖來實現的。然而,其中的一些問題用鎖來解決是不足夠或不合適的。我們會在本節的後續部分中逐步的對它們進行改進。
從這兩種鎖的原始碼中可以看出,它們是同源的。讀寫鎖的內部是用互斥鎖來實現寫鎖定操作之間的互斥的。我們可以把讀寫鎖看做是互斥鎖的一種擴充套件。除此之外,這兩種鎖實現在內部都用到了作業系統提供的同步工具——訊號燈。互斥鎖內部使用一個二值訊號燈(只有兩個可能的值的訊號燈)來實現鎖定操作之間的互斥,而讀寫鎖內部則使用一個二值訊號燈和一個多值訊號燈(可以有多個可能的值的訊號燈)來實現寫鎖定操作與讀鎖定操作之間的互斥。當然,為了進行精確的協調,它們還使用到了其他一些欄位和變數。由於篇幅原因,我們就不在這裡贅述了。如果讀者對此感興趣的話,可以去閱讀sync程式碼包中的相關原始碼檔案。