多執行緒非同步日誌系統,高效、強悍的實現方式:雙緩衝!

IOT物聯網小鎮發表於2021-11-01

作 者:道哥,10+年嵌入式開發老兵,專注於:C/C++、嵌入式、Linux

關注下方公眾號,回覆【書籍】,獲取 Linux、嵌入式領域經典書籍;回覆【PDF】,獲取所有原創文章( PDF 格式)。

目錄

別人的經驗,我們的階梯!

大家好,我是道哥,今天我為大夥兒解說的技術知識點是:【在多執行緒環境下,如何實現一個高效的日誌系統】

在很久之前,曾經寫過一篇文章《【最佳實踐】生產者和消費者模式中的雙緩衝技術》,討論了:在一個產品級的日誌系統中,如何利用雙緩衝機制來解決生產者-消費者相關的問題。

前段時間,有位小夥伴私信給我,希望可以具體聊一下這個實現方案。

本來答應在國慶期間完成的,但是我的拖延症一犯再犯,一直拖到今天,終於把這個作業給補上了。

雙緩衝這個思路並不是我原創的,而是參考了大神陳碩老師的一本書《Linux 多執行緒服務端程式設計》

從書名就可以看出,討論的是伺服器端的相關程式設計內容,而且是多執行緒場景下的,因此可以隱約看出,書中給出的參考程式碼的質量是很高的。

如果您的主力開發語言是 C++,強烈推薦您去研究下這本書。

很多 C++ 語言的細節問題,作者都給出了自己專業、嚴謹的思考和解決方案。

言歸正傳!

在上一篇文章中,我主要從思路、概念的角度,來描述如何利用雙緩衝機制。

這篇文章,我們就忠於書中原文,一起來學習一下作者的思考過程,並給出一些對效能起決定作用的關鍵程式碼。

先來看一下書中的效能測試結果:

微控制器中常用的環形緩衝區

一說到緩衝區,相信各位小夥伴一定看過很多關於緩衝緩衝區的文章和程式碼,在微控制器中的使用率很高。

所謂的環形緩衝區,就是一塊平整的記憶體區域,讓它的尾部連線到首部即可。

另一個類似的結構:環形佇列,本質上都是一樣的。

維護環形緩衝區的資料結構中,有headtail指標。

當寫入的時候,把輸入寫入到tail指標的位置,寫完之後,遞增tail的指標值;

當讀取的時候,從head指標的位置開始讀取,讀完之後,也遞增head的指標值。

這樣的操作方式,比較適合那種簡單的單輸入、單輸出場景。

只要處理好:當 head 和 tail 這兩個指標交匯的時候如何處理即可。

但是在x86的作業系統中,在多核 + 多執行緒的工作環境下,無論是從功能上、還是從效能上來考慮,這樣的環形緩衝區就滿足不了需求了。

還是拿日誌系統來舉例:在一個應用程式中,可能會有多個執行緒同時呼叫日誌系統的寫入API介面函式,這就需要保證執行緒安全

這樣的執行緒稱作 前臺/前端 執行緒。

日誌資料儲存在記憶體中之後,最終是要輸出的,比如:寫入到檔案系統、通過網路上傳到服務端、輸出到其他的監控系統等等。

實現輸出操作的也是一個執行緒,假如需要寫入到檔案系統,那麼在寫入期間,這個執行緒就需要一直持有緩衝區中的日誌資料。

這樣的執行緒稱作 後臺/後端 執行緒。

但是,檔案系統的寫入速度是很慢的(畢竟要操作硬碟啊),如果這個時候又有前臺執行緒需要寫日誌資訊了,該如何處理?

總不能暴力的說:後臺執行緒正在把現有的日誌資料儲存到硬碟上,已經持有了記憶體緩衝區,前臺執行緒你是後來的,先等著!

多執行緒非同步日誌:雙緩衝機制

在這本書中,作者對這樣的日誌系統規定了幾個關鍵的要求,都是與實際的業務需求相關的:

  1. 執行緒安全:多個執行緒可以併發寫日誌,不造成競爭,兩個執行緒的日誌資訊不會交叉出現;

  2. 吞吐量大;

  3. 日誌訊息有多種級別,格式可配置等等;

為了達到這個目的,作者提出了“雙緩衝”思路(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 進行交換的時候

交換的操作,是由後臺執行緒來執行的,具體流程是:

  1. 後臺執行緒被喚醒,此時 buffer B 緩衝區是空的,因為在上一次進入睡眠之前,buffer B 中資料已經被寫入到檔案系統中了;

  2. 把 buffer A 與 buffer B 進行交換;

  3. 把 buffer B 中的資料寫入到檔案系統;

  4. 開始休眠;

在第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 的雜湊值)。

那些雜湊到相同緩衝區的執行緒,同樣是存在爭用的情況的,只不過爭用的概率被降低了很多


------ End ------

推薦閱讀

【1】《Linux 從頭學》系列文章

【2】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹

【3】原來gdb的底層除錯原理這麼簡單

【4】內聯彙編很可怕嗎?看完這篇文章,終結它!

其他系列專輯:精選文章應用程式設計物聯網C語言

星標公眾號,第一時間看文章!

相關文章