作 者:道哥,10+年嵌入式開發老兵,專注於:C/C++、嵌入式、Linux。
關注下方公眾號,回覆【書籍】,獲取 Linux、嵌入式領域經典書籍;回覆【PDF】,獲取所有原創文章( PDF 格式)。
別人的經驗,我們的階梯!
大家好,我是道哥,今天我為大夥兒解說的技術知識點是:【在多執行緒環境下,如何實現一個高效的日誌系統】。
在很久之前,曾經寫過一篇文章《【最佳實踐】生產者和消費者模式中的雙緩衝技術》,討論了:在一個產品級的日誌系統中,如何利用雙緩衝機制來解決生產者-消費者相關的問題。
前段時間,有位小夥伴私信給我,希望可以具體聊一下這個實現方案。
本來答應在國慶期間完成的,但是我的拖延症一犯再犯,一直拖到今天,終於把這個作業給補上了。
雙緩衝這個思路並不是我原創的,而是參考了大神陳碩老師的一本書《Linux 多執行緒服務端程式設計》。
從書名就可以看出,討論的是伺服器端的相關程式設計內容,而且是多執行緒場景下的,因此可以隱約看出,書中給出的參考程式碼的質量是很高的。
如果您的主力開發語言是 C++,強烈推薦您去研究下這本書。
很多 C++ 語言的細節問題,作者都給出了自己專業、嚴謹的思考和解決方案。
言歸正傳!
在上一篇文章中,我主要從思路、概念的角度,來描述如何利用雙緩衝機制。
這篇文章,我們就忠於書中原文,一起來學習一下作者的思考過程,並給出一些對效能起決定作用的關鍵程式碼。
先來看一下書中的效能測試結果:
微控制器中常用的環形緩衝區
一說到緩衝區,相信各位小夥伴一定看過很多關於緩衝緩衝區的文章和程式碼,在微控制器中的使用率很高。
所謂的環形緩衝區,就是一塊平整的記憶體區域,讓它的尾部連線到首部即可。
另一個類似的結構:環形佇列,本質上都是一樣的。
維護環形緩衝區的資料結構中,有head
和tail
指標。
當寫入的時候,把輸入寫入到tail
指標的位置,寫完之後,遞增tail
的指標值;
當讀取的時候,從head
指標的位置開始讀取,讀完之後,也遞增head
的指標值。
這樣的操作方式,比較適合那種簡單的單輸入、單輸出場景。
只要處理好:當 head 和 tail 這兩個指標交匯的時候如何處理即可。
但是在x86
的作業系統中,在多核 + 多執行緒的工作環境下,無論是從功能上、還是從效能上來考慮,這樣的環形緩衝區就滿足不了需求了。
還是拿日誌系統來舉例:在一個應用程式中,可能會有多個執行緒同時呼叫日誌系統的寫入API
介面函式,這就需要保證執行緒安全。
這樣的執行緒稱作 前臺/前端 執行緒。
日誌資料儲存在記憶體中之後,最終是要輸出的,比如:寫入到檔案系統、通過網路上傳到服務端、輸出到其他的監控系統等等。
實現輸出操作的也是一個執行緒,假如需要寫入到檔案系統,那麼在寫入期間,這個執行緒就需要一直持有緩衝區中的日誌資料。
這樣的執行緒稱作 後臺/後端 執行緒。
但是,檔案系統的寫入速度是很慢的(畢竟要操作硬碟啊),如果這個時候又有前臺執行緒需要寫日誌資訊了,該如何處理?
總不能暴力的說:後臺執行緒正在把現有的日誌資料儲存到硬碟上,已經持有了記憶體緩衝區,前臺執行緒你是後來的,先等著!
多執行緒非同步日誌:雙緩衝機制
在這本書中,作者對這樣的日誌系統規定了幾個關鍵的要求,都是與實際的業務需求相關的:
執行緒安全:多個執行緒可以併發寫日誌,不造成競爭,兩個執行緒的日誌資訊不會交叉出現;
吞吐量大;
日誌訊息有多種級別,格式可配置等等;
為了達到這個目的,作者提出了“雙緩衝”思路(Double Buffering)。
基本思路是:
準備兩塊 buffer: A 和 B;
前端負責往 buffer A 填資料(日誌資訊);
後端負責把 buffer B 的資料寫入檔案。
當 buffer A 寫滿之後,交換 A 和 B,讓後端將 buffer A 的資料寫入檔案,而前端則往 buffer B 填入新的日誌資訊,如此反覆。
其實還是蠻好理解的哈,我們還是來畫圖描述一下:
當 buffer A 寫滿之後,交換兩個緩衝區:
雙緩衝機制為什麼高效
使用兩個buffer
緩衝區的好處是:
在大部分的時間中,前臺執行緒和後臺執行緒不會操作同一個緩衝區,這也就意味著前臺執行緒的操作,不需要等待後臺執行緒緩慢的寫檔案操作(因為不需要鎖定臨界區)。
還有一點就是:後臺執行緒把緩衝區中的日誌資訊,寫入到檔案系統中的頻率,完全由自己的寫入策略來決定,避免了每條新日誌資訊都觸發(喚醒)後端日誌執行緒。
例如:可以根據實際使用場景,定義一個重新整理頻率,例如:3秒。
只要重新整理時間到了,即使緩衝區中的日誌資訊很少,也要把它們儲存到檔案系統中。
換言之,前端執行緒不是將一條條日誌資訊分別傳送給後端執行緒,而是將多條資訊拼成一個大的 buffer 傳送給後端,相當於是批量處理,減少了執行緒喚醒的頻率,降低開銷。
儘可能的降低 Lock 的時間
在剛才的描述中,有這麼一句話:在[大部分的時間中],前臺執行緒和後臺執行緒不會操作同一個緩衝區。
也就是是說,在小部分時間內,它們還是有可能操作同一個緩衝區的。
那就是:當前臺的寫入緩衝區 buffer A 被寫滿了,需要與 buffer B 進行交換的時候。
交換的操作,是由後臺執行緒來執行的,具體流程是:
後臺執行緒被喚醒,此時 buffer B 緩衝區是空的,因為在上一次進入睡眠之前,buffer B 中資料已經被寫入到檔案系統中了;
把 buffer A 與 buffer B 進行交換;
把 buffer B 中的資料寫入到檔案系統;
開始休眠;
在第2
個步驟中:交換緩衝區,就是把兩個指標變數的值交換一下而已,利用C++
語言中的swap
操作,效率很高。
在執行交換緩衝區的時候,可能會有前臺執行緒寫入日誌,因此這個步驟需要在 Lock 的狀態下執行。
可以看出:這個雙緩衝機制的前後臺日志系統,需要鎖定的程式碼僅僅是交換兩個緩衝區這個動作,Lock 的時間是極其短暫的!這就是它提高吞吐量的關鍵所在!
參考程式碼
在示例程式碼中,作者對雙緩衝機制進行了擴充套件,採用4
個緩衝區,這樣可以進一步減少或避免前端執行緒的等待時間。
資料結構如下:
這裡的 nextBuffer_
相當有是currentBuffer_
的“備胎”。
當前臺執行緒發現currentBuffer_
不可用時(空間已滿,或者正在被後臺執行緒操作),可以立刻寫入到這個"備胎"緩衝區中,從而降低了前臺執行緒的等待時間。
下面是前臺執行緒的寫入程式碼:
前端執行緒在生成一條日誌訊息的時候,會呼叫append()
函式。
在這個函式中,如果當前緩衝區(currentBuffer_
)剩餘的空間足夠大,直接把訊息訊息拷貝(追加)進去,這是最常見的情況。
如果當前緩衝區的剩餘空間,小於這次日誌資訊的寫入長度,就把它移動到 buffer_ 集合中(一個Vector
),此時會傳送喚醒訊號給後端執行緒,然後把 nextBuffer_
這個備胎 move 為 currentBuffer_
。
move 是 C++ 中的操作,意思是移動,而不是拷貝/複製。
當然了,如果前端的寫入速度太快,一下子就把兩塊緩衝區都用完了,那麼只好分配一塊新的 buffer 作為當前緩衝區,這是極少發生的情況。
再來看看後端的程式碼實現,這裡只貼出了最關鍵的臨界區內的程式碼,也就是前文所說的“小部分時間”的情況:
這段程式碼中最重要的就是 swap 函式,它把前後臺使用的緩衝區進行了交換。
當前後臺緩衝區交換之後,就離開了臨界區,此時後臺執行緒就可以慢慢的往檔案系統中寫入資料了。
另外,這段程式碼中還有一個地方比較有意思,就是對備胎 nextBuffer_
的操作:
當前臺中使用的備胎 nextBuffer_
已經被消耗掉時,後臺執行緒及時地為它補充一個新的備胎。
可以繼續優化的地方
在本章的最後部分,作者提出了一個更加嚴苛的情況:
非同步日誌系統中,使用了一個全域性鎖,儘管臨界區很小,但是如果執行緒數目較多,鎖爭用也可能影響效能。
一種解決方法是像 Java 的 ConCurrentHashMap 那樣使用多個桶子(bucket),前端執行緒寫日誌的時候根據執行緒id
雜湊到不同的 bucket 中,以減少競爭。
這種解決方案本質上就是提供更多的緩衝區,並且把不同的緩衝區分配給不同的執行緒(根據執行緒 id 的雜湊值)。
那些雜湊到相同緩衝區的執行緒,同樣是存在爭用的情況的,只不過爭用的概率被降低了很多。
推薦閱讀
【2】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹