寫在前面
最近在學CMU15-445。趁着實習的間隙,晚上,還有周末,看看視頻,寫寫lab。
CMU15-445的lab與MIT6.824的lab風格很不一樣。前者定義好了函數原型,提示更多,但是禁錮了思維,發揮空間變小了。后者只提供了最基礎的接口,在代碼架構上的可發揮性更高。
由於函數原型都給好了,我以為這個lab會簡單很多。結果沒成想,寫着寫着,發現B+樹這個lab給我整不會了。花了足足一個月,才把lab寫完,目前代碼在gradescope上已經通過。通關截圖:
在做這個lab的過程中,學到了不少東西。在這里簡單總結下。
整體架構
lab的整體架構很清晰。先實現internal_page、leaf_page這兩個數據結構,作為B+樹的內部節點和葉子節點。然后實現B+樹的插入、刪除,最后支持並發。
難點主要有四處:
- 內部節點和葉子節點的實現沒有專門的測試。需要自己寫測試。
- 插入和刪除的細節需要仔細思考。
- 並發部分的具體實現方式。
- debug
內部節點與葉子節點
在這一部分中,我們要實現一系列的小函數。整個過程比較繁瑣,但難度不大。
在我最初的版本中,所有的搜索都是線性時間復雜度的。這樣做實現簡單,可以確保正確性。但是在后續性能調優中,發現此處的性能瓶頸很嚴重。於是改為了二分搜索。這是后話。
在完成所有的小函數后,我自己手寫了一組測試,用來檢測內部節點的正確性。(葉子節點比較簡單,就沒有寫測試。)
本來以為,這些簡單的小函數,我是絕不可能出錯的。但是通過測試,還是發現了兩個小bug。
心得是:永遠不要過於相信自己。用測試結果說話。
插入與刪除
插入與刪除的部分需要仔細思考。
對於內部節點,在插入刪除時需要考慮middle key。這里以刪除時redistribute為例:
插入與刪除時,分裂以及合並的具體實現會影響內部節點、葉子節點的相關函數實現。
具體來說,出於性能考慮,我做了以下設計:
- 分裂時,新建一個右節點,將需要分裂的節點的后一半移動到新建的右節點中,剩下的一半保持不動。這樣與新建一個左節點相比,減少了將剩下一半前移的開銷。
- 合並時,默認將右節點合並到左節點。這樣與將左節點合並到右節點相比,減少了將右節點全部節點后移的開銷。
在理清思路后,插入與刪除部分就順理成章地完成了。
並發
並發是難度最大的點。
難點如下:
- 怎樣實現latch crabbing過程中的加鎖和解鎖。
- 怎樣實現節點的刪除,不要讓刪除與加鎖解鎖相沖突。
- 如何對根節點相關的信息加鎖,以及如何及時釋放根節點的鎖。
latch crabbing
latch crabbing的思想很簡單。但在實現時比較復雜。
讀取的情況比較簡單:加鎖過程中,我們跟蹤當前節點和它的父節點(用兩個指針實現),對它們進行加解鎖。
插入/刪除的情況相比而言更復雜:我們不僅要考慮父節點,還要考慮所有的祖先節點。
---下面這一段是記錄給自己看的,可以略過---
在實現過程中,我首先考慮的實現方式是這樣的:(后面舍棄了)
在FindLeafPage
函數中,先對所有需要加鎖的祖先進行加鎖。但並不記錄下這些祖先。在Remove
/Insert
函數中,每當要分裂/合並/重分布時,都通過GetParentId
獲取父節點(“順藤摸瓜”)。當然,這時父節點已經在FindLeafPage
中被latch住了,因此不用再獲取鎖。當使用完父節點后,解鎖之。
這樣做的優點在於,可以在祖先使用完畢后,立即及時釋放祖先的鎖。
但是采用這種方式,正確性是有問題的:若Remove
時僅進行Redistribute
,那么上溯將在Redistribute后停止。但是這一層上面可能還有已經加鎖的祖先節點。我們將無法對它們進行解鎖。這可以通過額外的丑陋機制加以解決。
最重要的時:正確性之外,代碼實現變得非常丑陋--解鎖分散在代碼的各個位置,還要考慮大量解鎖的corner case,實現和維護難度極大。
在痛苦地掙扎了一周后,我決定放棄這種思路。改為如下實現:
---結束---
最終的實現如下:
在FindLeafPage
函數中,記錄下latch crabbing過程中Fetch並加鎖的祖先(例如:用一個隊列),在
FindLeafPage
中發現安全的節點后- 整個
Insert
/Remove
函數最后
清空隊列,把所有的祖先都解鎖並Unpin。(使用隊列的原因是,可以先釋放最上層的祖先)
乍一看,似乎按照第二條的做法,並不能及時地在使用完祖先后,立即釋放祖先的鎖。而是要等到整個修改操作完成后,才能釋放祖先的鎖。
但要注意的是:Insert
/Remove
函數是從樹的底層向樹的上層遞歸的。在遞歸的最后,才會接觸到最上層的祖先。在這之前,提前解鎖下層的節點並不能帶來什么好處。由於最上層的祖先仍然被鎖住,即使下層的節點被解鎖,其他線程也無法訪問到它們。
因此,采用隊列記錄的方案,並不會對解鎖的及時性產生影響。
采用這種方案,解鎖變得異常整潔:在Insert
與Remove
函數及其調用的所有函數中,我們都不用考慮與鎖相關的事情。
心得:代碼可維護性很重要。當代碼邏輯過於復雜時,要考慮使用一些數據結構等,簡化代碼邏輯。
節點刪除
如果我們在Remove函數及其調用的子函數中,直接解鎖unpin,並調用buffer_pool_manager_->DeletePage
刪除頁,會與解鎖的流程沖突。這是因為,被刪除的頁可能會在加鎖隊列中。當Remove
函數執行到最后時,會再次試圖解鎖已經被刪除的頁。
為了解決這個問題,我引入一個unordered_map,記錄所有要被刪除的節點。在需要刪除節點時,僅將節點加入map中。在Remove函數最后解鎖所有祖先后,再真正刪除map中記錄的所有節點。
在解鎖后再刪除節點並不會引起並發問題。這是因為要被刪除的節點已經與B+樹斷開了所有的連接,其他的線程已經不再能夠訪問到它們了。
對根節點加鎖
在b_plus_tree
類中,有一個成員變量root_page_id
,它記錄了B+樹根節點的page id。任何一個線程在對B+樹進行任何操作前,都需要讀取root_page_id
;插入和刪除時,有些情況下需要修改root_page_id
。因此這個變量需要用鎖保護。
在作業要求中,老師建議使用std::mutex
進行保護。因此我引入了root_latch
鎖。
在我最初的版本中,對root_latch
的加解鎖方案是這樣的:
- 在
GetValue
/Insert
/Remove
函數開始時,對root_latch
加鎖 - 在獲取了根節點的讀鎖后,釋放
root_latch
- 在釋放了根節點的寫鎖后,釋放
root_latch
前兩條都是合理的。但是第三條對性能有一定影響:當根節點的內容需要修改時,我們會獲取根節點的寫鎖。但並不是所有需要修改根節點的情況下,都需要修改root_page_id
。例如:根節點的子節點分裂,需要在根節點中添加新項,但並不會導致根節點分裂。
因此在后續版本中,為了優化性能,在FindLeafPageComplex
中添加了一段代碼。將第三條修改為:在獲取根節點的寫鎖后,檢查其Size。若根節點“安全”,則釋放root_latch
。
其他優化
在課上Andy提到,latch crabbing有樂觀加鎖的版本。具體而言,在插入/刪除時,並不是直接一路向下添加寫鎖。而是先一路添加讀鎖,在遇到葉子節點時添加寫鎖。若葉子節點“安全”,則直接進行插入/刪除操作。若葉子節點不“安全”,則解除葉子節點的寫鎖,重頭再來,一路向下添加寫鎖。
在完成上面的部分之后,我心血來潮,想要把插入/刪除的並發控制改成樂觀的。
改成樂觀控制並不難。要想支持這個功能,需要讓每個內部節點,都能夠判斷其子節點是否為葉子節點,從而判斷對其加讀鎖還是寫鎖。因此我在內部節點類中添加了一個成員變量is_child_leaf
,並在分裂時維護這個變量。
在完成樂觀控制后,我發現這並不會提高性能。在gradescope上的leaderboard中,樂觀版本的執行時間和悲觀版本一樣。
心得:過早的優化是萬惡之源
debug
在完成上述內容后,我開始對代碼進行測試。使用的測試代碼是15-445學習群中獲得的,gradescope的測試代碼。
在測試過程中,並發插入總能通過。但是在並發刪除與混合測試中,我一直遭遇兩種錯誤:
- 鎖相關的報錯:
pthread_mutex_lock.c:62: __pthread_mutex_lock: Assertion
mutex->__data.__owner == 0' failed` - 在調用
buffer_pool_manager_->DeletePage
刪除節點時,有節點的Pin Count
不為0。在大多數錯誤情況下,這些節點的Pin Count
為1。少數情況下,Pin Count
為2。
這兩個錯誤困擾了我一周多的時間。在此期間,我對代碼進行了多次review,發現了第一個錯誤的原因:
在Remove
函數中,需要訪問當前節點的sibling時,必須要對sibling加鎖。這是因為,sibling節點可能在上一次插入/刪除操作中,是被加鎖的最古老的祖先。在執行本次刪除操作時,上一次插入/刪除操作還沒有完成,sibling仍在被使用。
但是第二個問題遲遲得不到解決。
首先,我進行了大量的單線程測試,確保單線程的Remove並不會發生任何問題。那么可以確認問題是出在多線程上。
接下來,我進行了並發測試,添加了大量log。甚至采用了從6.824助教那里學來的方法:對log加顏色。(不過加顏色真心好用!)
在這個過程中,我逐漸對問題進行定位。發現錯誤總是發生在如下場景:一個線程多次對同一個葉子節點執行刪除操作,直至該節點不再安全,需要執行合並,需要刪除該節點。在該線程執行刪除操作時,發現Pin Count
不為0。
看起來,有另一個線程也正在訪問這個葉子節點。這就很奇怪了。要想訪問某個節點,必須對其進行加鎖。既然正在執行刪除的線程可以修改葉子節點,那么其它線程必然沒有獲取到寫鎖,因此不能訪問葉子節點。
接下來,我又對代碼中負責對葉子節點加鎖的部分進行了嚴密的檢查,但並沒有發現問題。可以認為,加鎖的邏輯是正確的。錯誤隱藏的比我想象的更深。
那么我能做的,就只有再多跑測試,多打log,直到找到一次能夠揭示問題原因的測試結果為止了!
幸運的是,一個下午過后,這樣的測試結果出現了。
我發現,在執行刪除操作之間,有另一個線程,對需要被刪除的葉子節點進行了Fetch、加鎖、解鎖,但並沒有unpin。
也就是說,問題的根源在於,解鎖與Unpin不是原子的。
要想解決這個問題,方案有兩個:
- 讓解鎖與Unpin變成原子的。這需要引入一把新的鎖。
- 在刪除時,若發現
Pin Count
不為0,則sleep一段時間。等待另一個線程unpin。若蘇醒后發現Pin Count
仍不為0,則不斷循環。
方案2比較簡單,因此我選擇了方案2。在這之后,問題迎刃而解,測試通過。
性能調優
將代碼提交到gradescope上。發現無法通過memory safety測試。經查找,確認這是由於代碼太慢。
那么工作的重點就轉移到性能調優上了。
首先我引入了一個解鎖優化,即“對根節點加鎖”這一節中,對第三條的優化。但是並未對代碼速度產生什么影響。這樣一來,似乎代碼太慢不是由於多線程鎖爭用導致的。
那么我們就必須弄清楚,性能問題到底是由於單線程太慢,還是由於並發鎖沖突導致的。
首先觀察測試花費的時間:
- 對於單線程插入測試,插入1000個記錄,用時34ms。
- 對於多線程混合測試Mixtest1,兩個線程(一個插入,一個刪除),分別插入/刪除1000個記錄,循環100次,用時4367ms。
- 對於多線程混合測試Mixtest1,十個線程(五個插入,五個刪除),分別插入/刪除1000個記錄,循環100次,用時15622ms。
觀察1和2,4367
與34*100
大約在同一個數量級。考慮到兩線程必然會發生一些鎖沖突,可以認為兩個線程的沖突並不嚴重。
觀察2和3,線程數量變為五倍,用時變為3倍多。考慮到Mixtest1中插入的記錄數量不多(1000個。與之相比,節點的MaxSize
有200多。),樹較淺(應該只有兩層),這個沖突情況可以接受。
那么導致超時的主要原因,應該是單線程太慢。
恰好前段時間在The Missing Semester of Your CS Education
中,了解到了“火焰圖”這個工具,感覺很酷炫,這次正好拿來嘗試一下。
火焰圖的github鏈接
火焰圖與perf搭配使用,可以分析各個函數調用所使用的時間。火焰圖是交互式的svg圖,使用很直觀,也很方便。
但需要注意,這種方式只能分析on-cpu time,也就是說,線程等待鎖的時間是無法被計入的。
不過沒關系,我們正是要分析單線程的執行情況的。
作圖如下:(其實這里應該對單線程測試作圖。但我當時只對並發混合測試MixTest1做了圖。其實不太嚴謹,但問題解決了就好。)
圖里面有兩個MixTest1相關的部分。我不知道是為什么。但這不影響我們的分析。
放大來看:
Remove:


可以很明顯地看到,Remove中占大頭的是LookUp
函數。Insert中占大頭的是KeyIndex
函數。
這個時候我回想起來,我的這兩個函數都是線性時間復雜度的。這里一個節點中記錄的個數在200多。如果把它們改成二分查找,最多只需要8次查找(log2(200)
),應該可以大大提速。
在改為二分查找之后,成功地通過了gradescope的所有測試,用時顯示為5.16,排34位,還不錯!
寫在最后
這次lab做了超級超級久,中間一度想過放棄。但是很慶幸,自己最后還是堅持了下來。通過這次實驗,我第一次寫了測試代碼,第一次嘗試帶顏色的log,第一次用火焰圖進行了性能分析。收獲頗豐!
(不要問我為什么6.824的lab4還沒有更新。咕咕咕!