MIT6.824 spring21 Lab2D總結記錄


寫在前面

lab2D是今年新添加的部分,網上很難找到博客資源。

這一部分要求我們為raft添加log compaction功能:在運行一段時間后,raft的上層service可以生成一個snapshot,並通知raft。在這之后,raft就可以丟棄snapshot包含的log entries,起到節約空間的作用。

這部分難度不大,但是細節略多。

(測試結果:Lab2D的testcase跑50次,全部PASS)

代碼見:https://github.com/sun-lingyu/MIT6.824-spring21/tree/Raft-2D

關於CondInstallSnapshot

看過Lab2D實驗指導的人都會發現,如果follower收到了一個InstallSnapshot RPC,其處理邏輯是非常扭曲的:

首先,在InstallSnapshot Handler中,follower需要將收到的snapshot通過applyCh發送給上層service。此時follower並會安裝這個snapshot。

在一段時間后,上層service會調用CondInstallSnapshot函數,詢問raft是否應該安裝此snapshot。若在follower執行InstallSnapshot Handler到執行CondInstallSnapshot的這段時間里,raft沒有因為收到applyentries RPC導致其commitID超過該snapshot。

在什么情況下CondInstallSnapshot會拒絕安裝snapshot?

當然,出現“CondInstallSnapshot拒絕安裝snapshot”這種情況,是可以理解的。下面給出一種可能的情況:

leader的當前狀態如下。圖中的直線代表leader的log,且假設所有的log entry都已commit。

leader向其中一個落后的follower發送appendEntries RPC。其中包含了從nextIndex直到log末尾的所有entry。

 

 

 由於種種原因(不穩定的網絡或follower fail),這個包並沒有及時被follower接受。

接下來,leader的上層應用調用Snapshot(),對leader的log進行壓縮:

如圖,這個snapshot可能超過了nextIndex。因此,當leader試圖重發剛剛的appendEntries時,它將只能發送整個snapshot給follower。

若由於網絡波動,follower首先收到了snapshot,它將執行InstallSnapshot RPC。

若在它還未執行CondInstallSnapshot時,它收到了先前發送的appendEntries,並commit,那么在執行CondInstallSnapshot時,它將拒絕安裝此snapshot。

為什么InstallSnapshot RPC的處理邏輯這么扭曲?

論文中對InstallSnapshot 的描述遠比實驗指導中簡單:論文中甚至根本沒有提及過CondInstallSnapshot。

那么我們為什么需要CondInstallSnapshot?為什么不可以在InstallSnapshot RPC handler中直接安裝snapshot?

實驗指導中給出的答案是:這樣可以確保service與raft安裝snapshot的原子性。

如果你對Lab2B的實現還有印象,就會發現:這個問題的答案與我們的實現高度相關。

 

讓我從“如何向applyCh發送log entry”講起:

在我的Lab2B實現中,向applyCh發送log entry的時機有兩個:

1. appendEntries RPC handler中,發現commitIndex需要更新之后

2. leader appendEntries RPC receiver中,commit新的log entry之后

在這兩個發送時機,執行發送的goroutine都是持有鎖的。

如果在向applyCh發送任何信息時,總持有鎖,那么可以保證raft與上層service的通信是嚴格串行的。(沒有任何不確定性)

那么如果在InstallSnapshot RPC handler中,向applyCh發送snapshot時也持有鎖,就也可以保證service與raft安裝snapshot的原子性。

即:raft向上傳遞log entry和snapshot是有嚴格順序的。service和raft是嚴格同步的(雖然service可能滯后raft一段時間),其正確性可以保證。

這樣一來,似乎可以在InstallSnapshot RPC handler中直接安裝snapshot。

 

但是:這個過程涉及了一把鎖與一個blocking channel。一旦處理不當,將導致死鎖

雖然在2B/2C中,這樣的實現不會產生問題。這是因為raft的上層service一定會及時讀取applyCh。

但是在2D中引入Snapshot函數后,這種機制將導致死鎖!

在實驗提供的service代碼中:當上層service從applyCh中收到了一定數量的log entry后,它將執行Snapshot函數,使raft壓縮其log。在Snapshot函數返回前,上層service不會繼續執行。

執行Snapshot函數需要獲取鎖。若此時有goroutine正在持有鎖並試圖向applyCh中發送log entry,則會發生死鎖。

因此,Frans Kaasoek教授在講解2A/2B時,特意提到不要在向applyCh發送log entry時持有鎖。在他的實現中,向applyCh發送是采用一個專門goroutine執行來確保串行的。(我發現2B/2C的testcase都能過,就沒當回事😭😭😭)

 

在把實現改為用專門goroutine(以下稱為applier)向applyCh發送后,我發現為了避免復制導致使用額外空間,applier最好直接在rf.log上操作。但是這樣一來,applier就只能串行發送log entry,而很難把發送snapshot這件事情插入到log entry的串行發送過程中。(這里可能沒有講清楚。可以看我Lab2D中applier的代碼,一看就明白了。)

這時候,按照實驗指導的方式操作就變成了一件自然的事情:

我們不要求 發送snapshot和發送log entry這兩件事在同一個串行事件隊列中發生。即:

logEntry--logEntry--snapshot--logEntry--snapshot--logEntry

而是選擇退而求其次,允許發送log entry與發送snapshot並發執行。即:

logEntry--logEntry--logEntry--logEntry

snapshot--snapshot

也就是說,applier不負責發送snapshot。

這樣一來,service什么時候能接收到snapshot成了一件不確定的事情。我們必須引入CondInstallSnapshot函數,確定service接收到snapshot的時間。

進一步,我們就可以確保service和raft同時安裝snapshot,避免了不確定性。

一些實現細節

在理解了上面的內容后,按照實驗指導和論文的figure13,一步一步實現即可。

為了方便對之前的代碼進行改動,我建議將log由先前簡單的logEntry slice改為較復雜的struct,封裝其結構。如下:

 

 

這個struct封裝了log與snapshot的信息,並提供兩個函數:

1. 提供從log index到log.entries index的轉換方法。

2. 提供log最后一項的log index。

我建議在index函數中,在發生

index < l.LastIncludedIndex

這種情況(即:請求的index已經被包含在snapshot中)時panic。可以很快地找出潛在的bug。

 

還有一個需要注意的點是:在InstallSnapshot Handler中,若決定將此snapshot發送給applyCh,需要reset election timer。

也不要忘記在Make函數(初始化)中把rf.lastApplied 賦值為 rf.log.LastIncludedIndex。

寫在最后

總的來說,Lab2D難度不大,但是需要修改之前2B的很多邏輯和細節。只要足夠細心,就可以很快通過。

我覺得,2D最重要的事情是:理解為什么需要ConInstallSnapshot函數。

知其然,更應知其所以然。不能因為實驗指導這樣說了,就盲目服從。這樣對學習是沒有幫助的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM