亞寵展、全球?qū)櫸锂a(chǎn)業(yè)風(fēng)向標(biāo)——亞洲寵物展覽會(huì)深度解析
850
2022-05-29
作 者:道哥,10+年嵌入式開發(fā)老兵,專注于:
C/C++、嵌入式、Linux
。
文章目錄
單片機(jī)中常用的環(huán)形緩沖區(qū)
多線程異步日志:雙緩沖機(jī)制
雙緩沖機(jī)制為什么高效
盡可能的降低 Lock 的時(shí)間
參考代碼
可以繼續(xù)優(yōu)化的地方
大家好,我是道哥,今天我為大伙兒解說的技術(shù)知識(shí)點(diǎn)是:
【在多線程環(huán)境下,如何實(shí)現(xiàn)一個(gè)高效的日志系統(tǒng)】
。
在很久之前,曾經(jīng)寫過一篇文章
《【最佳實(shí)踐】生產(chǎn)者和消費(fèi)者模式中的雙緩沖技術(shù)》
,討論了:在一個(gè)
產(chǎn)品級(jí)
的日志系統(tǒng)中,如何利用雙緩沖機(jī)制來解決生產(chǎn)者-消費(fèi)者相關(guān)的問題。
前段時(shí)間,有位小伙伴私信給我,希望可以具體聊一下這個(gè)實(shí)現(xiàn)方案。
本來答應(yīng)在國慶期間完成的,但是我的拖延癥一犯再犯,一直拖到今天,終于把這個(gè)作業(yè)給補(bǔ)上了。
雙緩沖這個(gè)思路并不是我原創(chuàng)的,而是參考了大神
陳碩
老師的一本書
《Linux 多線程服務(wù)端編程》
。
從書名就可以看出,討論的是
服務(wù)器端
的相關(guān)編程內(nèi)容,而且是多線程場(chǎng)景下的,因此可以隱約看出,書中給出的參考代碼的質(zhì)量是很高的。
如果您的主力開發(fā)語言是 C++,強(qiáng)烈推薦您去研究下這本書。
言歸正傳!
在上一篇文章中,我主要從
思路、概念
的角度,來描述如何利用雙緩沖機(jī)制。
先來看一下書中的性能測(cè)試結(jié)果:
單片機(jī)中常用的環(huán)形緩沖區(qū)
一說到緩沖區(qū),相信各位小伙伴一定看過很多關(guān)于緩沖緩沖區(qū)的文章和代碼,在單片機(jī)中的使用率很高。
所謂的環(huán)形緩沖區(qū),就是一塊平整的內(nèi)存區(qū)域,讓它的尾部連接到首部即可。
另一個(gè)類似的結(jié)構(gòu):環(huán)形隊(duì)列,本質(zhì)上都是一樣的。
維護(hù)環(huán)形緩沖區(qū)的數(shù)據(jù)結(jié)構(gòu)中,有head和tail指針。
當(dāng)寫入的時(shí)候,把輸入寫入到tail指針的位置,寫完之后,遞增tail的指針值;
當(dāng)讀取的時(shí)候,從head指針的位置開始讀取,讀完之后,也遞增head的指針值。
這樣的操作方式,比較適合那種簡(jiǎn)單的
單輸入、單輸出
場(chǎng)景。
只要處理好:
當(dāng) head 和 tail 這兩個(gè)指針交匯的時(shí)候如何處理
即可。
但是在x86的操作系統(tǒng)中,在
多核 + 多線程
的工作環(huán)境下,無論是從功能上、還是從性能上來考慮,這樣的環(huán)形緩沖區(qū)就滿足不了需求了。
還是拿日志系統(tǒng)來舉例:在一個(gè)應(yīng)用程序中,可能會(huì)有多個(gè)線程
同時(shí)
調(diào)用日志系統(tǒng)的寫入API接口函數(shù),這就需要保證
線程安全
。
這樣的線程稱作 前臺(tái)/前端 線程。
日志數(shù)據(jù)存儲(chǔ)在內(nèi)存中之后,最終是要輸出的,比如:寫入到文件系統(tǒng)、通過網(wǎng)絡(luò)上傳到服務(wù)端、輸出到其他的監(jiān)控系統(tǒng)等等。
實(shí)現(xiàn)輸出操作的也是一個(gè)線程,假如需要寫入到文件系統(tǒng),那么在寫入期間,這個(gè)線程就需要
一直持有
緩沖區(qū)中的日志數(shù)據(jù)。
這樣的線程稱作 后臺(tái)/后端 線程。
但是,文件系統(tǒng)的
寫入速度是很慢的
(畢竟要操作硬盤啊),如果這個(gè)時(shí)候又有
前臺(tái)線程
需要寫日志信息了,該如何處理?
總不能暴力的說:后臺(tái)線程正在把現(xiàn)有的日志數(shù)據(jù)存儲(chǔ)到硬盤上,已經(jīng)持有了內(nèi)存緩沖區(qū),前臺(tái)線程你是后來的,先等著!
多線程異步日志:雙緩沖機(jī)制
線程安全:多個(gè)線程可以并發(fā)寫日志,不造成競(jìng)爭(zhēng),兩個(gè)線程的日志信息不會(huì)交叉出現(xiàn);
吞吐量大;
日志消息有多種級(jí)別,格式可配置等等;
基本思路是:
準(zhǔn)備兩塊
buffer: A 和 B
;
前端負(fù)責(zé)往
buffer A
填數(shù)據(jù)(日志信息);
后端負(fù)責(zé)把
buffer B
的數(shù)據(jù)寫入文件。
當(dāng) buffer A 寫滿之后,交換 A 和 B,讓后端將 buffer A 的數(shù)據(jù)寫入文件,而前端則往 buffer B 填入新的日志信息,如此反復(fù)。
其實(shí)還是蠻好理解的哈,我們還是來畫圖描述一下:
當(dāng)
buffer A
寫滿之后,交換兩個(gè)緩沖區(qū):
雙緩沖機(jī)制為什么高效
使用兩個(gè)buffer緩沖區(qū)的
好處
是:
在
大部分的時(shí)間
中,前臺(tái)線程和后臺(tái)線程
不會(huì)
操作同一個(gè)緩沖區(qū),這也就意味著前臺(tái)線程的操作,不需要等待后臺(tái)線程緩慢的寫文件操作(因?yàn)椴恍枰i定臨界區(qū))。
還有一點(diǎn)就是:后臺(tái)線程把緩沖區(qū)中的日志信息,寫入到文件系統(tǒng)中的
頻率
,完全由自己的寫入策略來決定,避免了每條新日志信息都觸發(fā)(喚醒)后端日志線程。
例如:可以根據(jù)實(shí)際使用場(chǎng)景,定義一個(gè)刷新頻率,例如:3秒。
只要刷新時(shí)間到了,即使緩沖區(qū)中的日志信息很少,也要把它們存儲(chǔ)到文件系統(tǒng)中。
換言之,前端線程
不是
將一條條日志信息分別傳送給后端線程,而是將多條信息
拼成一個(gè)大的 buffer
傳送給后端,相當(dāng)于是批量處理,減少了線程喚醒的頻率,降低開銷。
盡可能的降低 Lock 的時(shí)間
在剛才的描述中,有這么一句話:在
[大部分的時(shí)間中]
,前臺(tái)線程和后臺(tái)線程不會(huì)操作同一個(gè)緩沖區(qū)。
也就是是說,在
小部分時(shí)間內(nèi)
,它們還是有可能操作同一個(gè)緩沖區(qū)的。
那就是:
當(dāng)前臺(tái)的寫入緩沖區(qū) buffer A 被寫滿了,需要與 buffer B 進(jìn)行交換的時(shí)候
。
交換的操作,是由后臺(tái)線程來執(zhí)行的,具體流程是:
后臺(tái)線程被喚醒,此時(shí) buffer B 緩沖區(qū)是空的,因?yàn)樵谏弦淮芜M(jìn)入睡眠之前,buffer B 中數(shù)據(jù)已經(jīng)被寫入到文件系統(tǒng)中了;
把 buffer A 與 buffer B 進(jìn)行交換;
把 buffer B 中的數(shù)據(jù)寫入到文件系統(tǒng);
開始休眠;
在第2個(gè)步驟中:交換緩沖區(qū),就是把
兩個(gè)指針變量的值交換一下而已
,利用C++語言中的swap操作,效率很高。
在執(zhí)行交換緩沖區(qū)的時(shí)候,可能會(huì)有
前臺(tái)
線程寫入日志,因此這個(gè)步驟需要在
Lock
的狀態(tài)下執(zhí)行。
可以看出:這個(gè)雙緩沖機(jī)制的前后臺(tái)日志系統(tǒng),
需要鎖定的代碼僅僅是交換兩個(gè)緩沖區(qū)這個(gè)動(dòng)作
,Lock 的時(shí)間是極其短暫的!這就是它提高吞吐量的關(guān)鍵所在!
參考代碼
數(shù)據(jù)結(jié)構(gòu)如下:
這里的 nextBuffer_ 相當(dāng)有是currentBuffer_的
“備胎”
。
當(dāng)前臺(tái)線程發(fā)現(xiàn)currentBuffer_不可用時(shí)(空間已滿,或者正在被后臺(tái)線程操作),可以立刻寫入到這個(gè)"備胎"緩沖區(qū)中,從而
降低了前臺(tái)線程的等待時(shí)間
。
下面是前臺(tái)線程的寫入代碼:
[外鏈圖片轉(zhuǎn)存失敗,源站可能有防盜鏈機(jī)制,建議將圖片保存下來直接上傳(img-hixdHl01-1635726591835)(http://iottown.sewain100.cn/iot1027_code2.png)]
前端線程在生成一條日志消息的時(shí)候,會(huì)調(diào)用append()函數(shù)。
在這個(gè)函數(shù)中,如果當(dāng)前緩沖區(qū)(currentBuffer_)剩余的空間足夠大,直接把消息消息拷貝(追加)進(jìn)去,這是
最常見
的情況。
如果當(dāng)前緩沖區(qū)的剩余空間,
小于
這次日志信息的寫入長(zhǎng)度,就把它
移動(dòng)到 buffer_ 集合中
(一個(gè)Vector),此時(shí)會(huì)發(fā)送喚醒信號(hào)給后端線程,然后把 nextBuffer_ 這個(gè)備胎
move
為 currentBuffer_。
move 是 C++ 中的操作,意思是移動(dòng),而不是拷貝/復(fù)制。
當(dāng)然了,如果前端的寫入速度太快,一下子就把兩塊緩沖區(qū)都用完了,那么只好
分配一塊新的 buffer 作為當(dāng)前緩沖區(qū)
,這是極少發(fā)生的情況。
再來看看后端的代碼實(shí)現(xiàn),這里只貼出了最關(guān)鍵的臨界區(qū)內(nèi)的代碼,也就是前文所說的
“小部分時(shí)間”
的情況:
這段代碼中最重要的就是
swap 函數(shù)
,它把前后臺(tái)使用的緩沖區(qū)進(jìn)行了
交換
。
當(dāng)前后臺(tái)緩沖區(qū)交換之后,就離開了臨界區(qū),此時(shí)后臺(tái)線程就可以慢慢的往文件系統(tǒng)中寫入數(shù)據(jù)了。
另外,這段代碼中還有一個(gè)地方比較有意思,就是對(duì)備胎 nextBuffer_ 的操作:
當(dāng)前臺(tái)中使用的備胎 nextBuffer_ 已經(jīng)被消耗掉時(shí),后臺(tái)線程及時(shí)地為它
補(bǔ)充
一個(gè)新的備胎。
可以繼續(xù)優(yōu)化的地方
異步日志系統(tǒng)中,使用了一個(gè)
全局鎖
,盡管臨界區(qū)很小,但是如果線程數(shù)目較多,鎖爭(zhēng)用也可能影響性能。
一種解決方法是像
Java 的 ConCurrentHashMap
那樣使用多個(gè)桶子(
bucket
),前端線程寫日志的時(shí)候根據(jù)線程id哈希到不同的
bucket
中,以減少競(jìng)爭(zhēng)。
這種解決方案本質(zhì)上就是
提供更多的緩沖區(qū),并且把不同的緩沖區(qū)分配給不同的線程
(根據(jù)線程 id 的哈希值)。
那些哈希到相同緩沖區(qū)的線程,同樣是存在爭(zhēng)用的情況的,只不過爭(zhēng)用的
概率被降低了很多
。
推薦閱讀
【1】《Linux 從頭學(xué)》系列文章
【2】C語言指針-從底層原理到花式技巧,用圖文和代碼幫你講解透徹
【3】原來gdb的底層調(diào)試原理這么簡(jiǎn)單
【4】?jī)?nèi)聯(lián)匯編很可怕嗎?看完這篇文章,終結(jié)它!
其他系列專輯:精選文章、應(yīng)用程序設(shè)計(jì)、物聯(lián)網(wǎng)、 C語言。
任務(wù)調(diào)度 多線程
版權(quán)聲明:本文內(nèi)容由網(wǎng)絡(luò)用戶投稿,版權(quán)歸原作者所有,本站不擁有其著作權(quán),亦不承擔(dān)相應(yīng)法律責(zé)任。如果您發(fā)現(xiàn)本站中有涉嫌抄襲或描述失實(shí)的內(nèi)容,請(qǐng)聯(lián)系我們jiasou666@gmail.com 處理,核實(shí)后本網(wǎng)站將在24小時(shí)內(nèi)刪除侵權(quán)內(nèi)容。