【性能優(yōu)化】lock-free在召回引擎中的實(shí)現(xiàn)
掃描二維碼
隨時(shí)隨地手機(jī)看文章
在我們的工作中,多線程編程是一件太稀松平常的事。在多線程環(huán)境下操作一個(gè)變量或者一塊緩存,如果不對(duì)其操作加以限制,輕則變量值或者緩存內(nèi)容不符合預(yù)期,重則會(huì)產(chǎn)生異常,導(dǎo)致進(jìn)程崩潰。為了解決這個(gè)問題,操作系統(tǒng)提供了鎖、信號(hào)量以及條件變量等幾種線程同步機(jī)制供我們使用。如果每次操作都使用上述機(jī)制,在某些條件下(系統(tǒng)調(diào)用在很多情況下不會(huì)陷入內(nèi)核),系統(tǒng)調(diào)用會(huì)陷入內(nèi)核從而導(dǎo)致上下文切換,這樣就會(huì)對(duì)我們的程序性能造成影響。
今天,借助此文,分享一下去年引擎優(yōu)化的一個(gè)點(diǎn),最終優(yōu)化結(jié)果就是在多線程環(huán)境下訪問某個(gè)變量,實(shí)現(xiàn)了無鎖(lock-free)操作。
背景
對(duì)于后端開發(fā)者來說,服務(wù)穩(wěn)定性第一,性能第二,二者相輔相成,缺一不可。
作為IT開發(fā)人員,秉承著一句話:只要程序正常運(yùn)行,就不要隨便動(dòng)。所以程序優(yōu)化就一直被擱置,因?yàn)闆]有壓力,所以就沒有動(dòng)力嘛?。在去年的時(shí)候,隨著廣告訂單數(shù)量越來越多,導(dǎo)致服務(wù)rt上漲,光報(bào)警郵件每天都能收到上百封,于是痛定思痛,決定優(yōu)化一版。
秉承小步快跑的理念,決定從各個(gè)角度逐步優(yōu)化,從簡單到困難,逐個(gè)擊破。所以在分析了代碼之后,準(zhǔn)備從鎖這個(gè)角度入手,看看能否進(jìn)行優(yōu)化。
在進(jìn)行具體的問題分析以及優(yōu)化之前,先看下現(xiàn)有召回引擎的實(shí)現(xiàn)方案,后面的方案是針對(duì)現(xiàn)有方案的優(yōu)化。
- 廣告訂單以HTTP方式推送給消息系統(tǒng)
- 消息系統(tǒng)收到廣告訂單消息后
- 將廣告訂單消息格式化后推送給消息隊(duì)列kafka(第1步)
- 將廣告訂單消息持久化到DB(第2步)
- 召回引擎訂閱kafka的topic
- 從kafka中實(shí)時(shí)獲取廣告訂單消息,建立并實(shí)時(shí)更建立維度索引(第3步)
- 召回引擎接收pv流量,實(shí)時(shí)計(jì)算,并返回滿足定向后的廣告候選集(第4步)
從上面圖中可以看出,召回引擎是一個(gè)多線程應(yīng)用,一方面有個(gè)線程專門從kafka中獲取最新的廣告訂單消息建立維度索引(此為寫線程),另一方面,接收線上流量,根據(jù)流量屬性,獲取廣告候選集(此為讀線程)。因?yàn)檎倩匾嫔婕暗酵瑫r(shí)讀和寫同一塊變量,因此讀寫不能同時(shí)操作。
概述
在多線程環(huán)境下,對(duì)同一個(gè)變量訪問,大致分為以下幾種情況:
- 多個(gè)線程同時(shí)讀
- 多個(gè)線程同時(shí)寫
- 一個(gè)線程寫,一個(gè)線程讀
- 一個(gè)線程寫,多個(gè)線程讀
- 多個(gè)線程寫,一個(gè)線程讀
- 多個(gè)線程寫,多個(gè)線程讀
在上述幾種情況中,多個(gè)線程同時(shí)讀顯然是線程安全的,而對(duì)于其他幾種情況,則需要保證其_互斥排他_性,即讀寫不能同時(shí)進(jìn)行,管他幾個(gè)線程讀幾個(gè)線程寫,代碼走起。
thread1
{ std::lock_guard<std::mutex> lock(mtx); // do sth(read or write) }
thread2
{ std::lock_guard<std::mutex> lock(mtx); // do sth(read or write) }
threadN
{ std::lock_guard<std::mutex> lock(mtx); // do sth(read or write) }
在上述代碼中,每一個(gè)線程對(duì)共享變量的訪問,都會(huì)通過mutex來加鎖操作,這樣完全就避免了共享變量競(jìng)爭(zhēng)的問題。
如果對(duì)于性能要求不是很高的業(yè)務(wù),上述實(shí)現(xiàn)完全滿足需求,但是對(duì)于性能要求很高的業(yè)務(wù),上述實(shí)現(xiàn)就不是很好,所以可以考慮通過其他方式來實(shí)現(xiàn)。
我們?cè)O(shè)想一個(gè)場(chǎng)景,假如某個(gè)業(yè)務(wù),寫操作次數(shù)遠(yuǎn)遠(yuǎn)小于讀操作次數(shù),例如我們的召回引擎,那么我們完全可以使用讀寫鎖來實(shí)現(xiàn)該功能,換句話說讀寫鎖適合于讀多寫少的場(chǎng)景。
?讀寫鎖其實(shí)還是一種鎖,是給一段臨界區(qū)代碼加鎖,但是此加鎖是在進(jìn)行寫操作的時(shí)候才會(huì)互斥,而在進(jìn)行讀的時(shí)候是可以共享的進(jìn)行訪問臨界區(qū)的,其本質(zhì)上是一種自旋鎖。
?
代碼實(shí)現(xiàn)也比較簡單,如下:
writer thread {
pthread_rwlock_wrlock(&rwlock); // do write operation pthread_rwlock_unlock(&rwlock);
}
reader thread2 {
pthread_rwlock_rdlock(&rwlock); // do read operation pthread_rwlock_unlock(&rwlock);
}
reader threadN {
pthread_rwlock_rdlock(&rwlock) // do read operation pthread_rwlock_unlock(&rwlock);
}
在此,說下讀寫鎖的特性:
- 讀和讀指針沒有競(jìng)爭(zhēng)關(guān)系
- 寫和寫之間是互斥關(guān)系
- 讀和寫之間是同步互斥關(guān)系(這里的同步指的是寫優(yōu)先,即讀寫都在競(jìng)爭(zhēng)鎖的時(shí)候,寫優(yōu)先獲得鎖)
那么,對(duì)于一寫多讀的場(chǎng)景,還有沒有可能進(jìn)行再次優(yōu)化呢?
答案是:有的。
下面,我們將針對(duì)一寫多讀,讀多寫少的場(chǎng)景,進(jìn)行優(yōu)化。
方案
在上一節(jié)中,我們提到對(duì)于多線程訪問,可以使用mutex對(duì)共享變量進(jìn)行加鎖訪問。對(duì)于一寫多讀的場(chǎng)景,使用讀寫鎖進(jìn)行優(yōu)化,使用讀寫鎖,在讀的時(shí)候,是不進(jìn)行加鎖操作的,但是當(dāng)有寫操作的時(shí)候,就需要加鎖,這樣難免也會(huì)產(chǎn)生性能上的影響,在本節(jié),我們提供終極優(yōu)化版本,目的是在寫少讀多的場(chǎng)景下實(shí)現(xiàn)lock-free。
如何在讀寫都存在的場(chǎng)景下實(shí)現(xiàn)lock-free呢?假設(shè)如果有兩個(gè)共享變量,一個(gè)變量用來專供寫線程來寫,一個(gè)共享變量用來專供讀線程來讀,這樣就不存在讀寫同步的問題了,如下所示:
在上節(jié)中,我們有提到,多個(gè)線程對(duì)一個(gè)變量同時(shí)進(jìn)行讀操作,是線程安全的。一個(gè)線程對(duì)一個(gè)變量進(jìn)行寫操作也是線程安全的(這不廢話么,都沒人跟它競(jìng)爭(zhēng)),那么結(jié)合上述兩點(diǎn),上圖就是線程安全的(多個(gè)線程讀一個(gè)資源,一個(gè)線程寫另外一個(gè)資源)。
好了,截止到現(xiàn)在,我們lock-free的雛形已經(jīng)出來了,就是_使用雙變量_來實(shí)現(xiàn)lock-free的目標(biāo)。那么reader線程是如何第一時(shí)間能夠訪問writer更新后的數(shù)據(jù)呢?
?假設(shè)有兩個(gè)共享資源A和B,當(dāng)前情況下,讀線程正在讀資源A。突然在某一個(gè)時(shí)刻,寫線程需要更新資源,寫線程發(fā)現(xiàn)資源A正在被訪問,那么其更新資源B,更新完資源B后,進(jìn)行切換,讓讀線程讀資源B,然后寫線程繼續(xù)寫資源A,這樣就能完全實(shí)現(xiàn)了lock-free的目標(biāo),此種方案也可以稱為雙buffer方式。
?
實(shí)現(xiàn)
在上節(jié)中,我們提出了使用雙buffer來實(shí)現(xiàn)lock-free的目標(biāo),那么如何實(shí)現(xiàn)讀寫buffer無損切換呢?
指針互換
假設(shè)有兩個(gè)資源,其指針分別為ptrA和ptrB,在某一時(shí)刻,ptrA所指向的資源正在被多個(gè)線程讀,而ptrB所指向的資源則作為備份資源,此時(shí),如果有寫線程進(jìn)行寫操作,按照我們之前的思路,寫完之后,馬上啟用ptrA作為讀資源,然后寫線程繼續(xù)寫ptrB所指向的資源,這樣會(huì)有什么問題呢?
我們就以std::vector為例,如下圖所示:
在上圖左半部分,假設(shè)ptr指向讀對(duì)象的指針,也就是說讀操作只能訪問ptr所指向的對(duì)象。
某一時(shí)刻,需要對(duì)對(duì)象進(jìn)行寫操作(刪除對(duì)象Obj4),因?yàn)榇藭r(shí)ptr = ptrA,因此寫操作只能操作ptrB所指向的對(duì)象,在寫操作執(zhí)行完后,將ptr賦值為ptrB(保證后面所有的讀操作都是在ptrB上),即保證當(dāng)前ptr所指向的對(duì)象永遠(yuǎn)為最新操作,然后寫操作去刪除ptrA中的Obj4,但是此時(shí),有個(gè)線程正在訪問ptrA的Obj4,自然而然會(huì)輕則當(dāng)前線程獲取的數(shù)據(jù)為非法數(shù)據(jù),重則程序崩潰。
?此方案不可行,主要是因?yàn)樵趯懖僮鞯臅r(shí)候,沒有判斷當(dāng)前是否還有讀操作。
?
原子性
在上述方案中,簡單的變量交換,最終仍然可能存在讀寫同一個(gè)變量,進(jìn)而導(dǎo)致崩潰。那么如果保證在寫的時(shí)候,沒有讀是不是就能解決上述問題了呢?如果是的話,那么應(yīng)該如何做呢?
顯然,此問題就轉(zhuǎn)換成如何判斷一個(gè)對(duì)象上存在線程讀操作。
用過std::shared_ptr的都知道,其內(nèi)部有個(gè)成員函數(shù)use_count()來判斷當(dāng)前智能指針?biāo)赶蜃兞康脑L問個(gè)數(shù),代碼如下:
long _M_get_use_count() const noexcept { // No memory barrier is used here so there is no synchronization // with other threads. return __atomic_load_n(&_M_use_count, __ATOMIC_RELAXED); }
那么,我們可以考慮采用智能指針的方案,代碼也比較簡單,如下:
std::atomic_size_t curr_idx = 0; std::vector<std::shared_ptr> obj_buffers; obj_buffers.emplace_back(std::make_shared(...)); obj_buffers.emplace_back(std::make_shared(...)); // write thread { size_t prepare = 1 - curr_idx.load(); while (obj_buffers[prepare].use_count() > 1) { continue; } obj_buffers[prepare]->load(); curr_idx = prepare; } // read thread { auto tmp = obj_buffers[curr_idx.load()]; // do sth }
在上述代碼中
- 首先創(chuàng)建一個(gè)vector,其內(nèi)有兩個(gè)Obj的智能指針,這倆智能指針?biāo)赶虻腛bj對(duì)象一個(gè)供讀線程進(jìn)行讀操作,一個(gè)供寫線程進(jìn)行寫操作
- curr_idx代表當(dāng)前可供讀操作對(duì)象在obj_buffers的索引,即obj_buffers[curr_idx.load()]所指對(duì)象供讀線程進(jìn)行讀操作
- 那么相應(yīng)的,obj_buffers[1- curr_idx.load()]所指對(duì)象供寫線程進(jìn)行寫操作
- 在讀線程中
- 通過auto tmp = obj_buffers[curr_idx.load()];獲取一個(gè)拷貝,由于obj_buffers中存儲(chǔ)的是shared_ptr那么,該對(duì)象的引用計(jì)數(shù)+1
- 在tmp上進(jìn)行讀操作
- 在寫線程中
- prepare = 1 - curr_idx.load();在上面我有提到curr_idx指向可讀對(duì)象在obj_buffers的索引,換句話說,1 - curr_idx.load()就是另外一個(gè)對(duì)象即可寫對(duì)象在obj_buffers中的索引
- 通過while循環(huán)判斷另外一個(gè)對(duì)象的引用計(jì)數(shù)是否大于1(如果大于1證明還有讀線程正在進(jìn)行讀操作)
好了,截止到此,lock-free的實(shí)現(xiàn)目標(biāo)基本已經(jīng)完成。實(shí)現(xiàn)原理也也相對(duì)來說比較簡單,重點(diǎn)是要保證寫的時(shí)候沒有讀操作即可。
上圖是召回引擎做了lock-free優(yōu)化后的效果圖,從圖上來看,效果還是很明顯的。
擴(kuò)展
雙buffer方案在“一寫多讀”的場(chǎng)景下能夠?qū)崿F(xiàn)lock-free的目標(biāo),那么對(duì)于“多寫一讀”或者“多寫多讀”場(chǎng)景,是否也能夠滿足呢?
答案是不太適合,主要是以下兩個(gè)原因:
-
在多寫的場(chǎng)景下,多個(gè)寫之間需要通過鎖來進(jìn)行同步,雖然避免了對(duì)讀寫互斥情況加鎖,但是多線程寫時(shí)通常對(duì)數(shù)據(jù)的實(shí)時(shí)性要求較高,如果使用雙buffer,所有新數(shù)據(jù)必須要等到索引切換時(shí)候才能使用,很可能達(dá)不到實(shí)時(shí)性要求
-
多線程寫時(shí)若用雙buffer模式,則在索引切換時(shí)候也需要給對(duì)應(yīng)的對(duì)象加鎖,并且也要用類似于上面的while循環(huán)保證沒有現(xiàn)成在執(zhí)行寫入操作時(shí)才能進(jìn)行指針切換,而且此時(shí)也要等待讀操作完成才能進(jìn)行切換,這時(shí)候就對(duì)備用對(duì)象的鎖定時(shí)間過長,在數(shù)據(jù)更新頻繁的情況下是不合適的。
缺點(diǎn)
通過前面的章節(jié),我們知道通過雙buffer方式可以實(shí)現(xiàn)在一寫多讀場(chǎng)景下的lock-free,該方式要求兩個(gè)對(duì)象或者buffer最終持有的數(shù)據(jù)是完全一致的,也就是說在單buffer情況下,只需要一個(gè)buffer持有數(shù)據(jù)就行,但是雙buffer情況下,需要持有兩份數(shù)據(jù),所以存在內(nèi)存浪費(fèi)的情況。
其實(shí)說白了,雙buffer實(shí)現(xiàn)lock-free,就是采用的空間換時(shí)間的方式。
結(jié)語
雙buffer方案在多線程環(huán)境下能較好的解決 “一寫多讀” 時(shí)的數(shù)據(jù)更新問題,特別是適用于數(shù)據(jù)需要定期更新,且一次更新數(shù)據(jù)量較大的情形。
性能優(yōu)化是一個(gè)漫長的不斷自我提升的過程,項(xiàng)目中的一點(diǎn)點(diǎn)優(yōu)化往往就可以使得性能得到質(zhì)的提升。





