性能優化-使用雙buffer實現無鎖隊列
- 2022 年 1 月 17 日
- 筆記
藉助本文,實現一種在「讀多寫一」場景下的無鎖實現方式
在我們的工作中,多執行緒編程是一件太稀鬆平常的事。在多執行緒環境下操作一個變數或者一塊快取,如果不對其操作加以限制,輕則變數值或者快取內容不符合預期,重則會產生異常,導致進程崩潰。為了解決這個問題,作業系統提供了鎖、訊號量以及條件變數等幾種執行緒同步機制供我們使用。如果每次操作都使用上述機制,在某些條件下(系統調用在很多情況下不會陷入內核),系統調用會陷入內核從而導致上下文切換,這樣就會對我們的程式性能造成影響。
今天,藉助此文,分享一下去年引擎優化的一個點,最終優化結果就是在多執行緒環境下訪問某個變數,實現了無鎖(lock-free)操作。
背景
對於後端開發者來說,服務穩定性第一,性能第二,二者相輔相成,缺一不可。
作為IT開發人員,秉承著一句話:只要程式正常運行,就不要隨便動。所以程式優化就一直被擱置,因為沒有壓力,所以就沒有動力嘛😁。在去年的時候,隨著廣告訂單數量越來越多,導致服務rt上漲,光報警郵件每天都能收到上百封,於是痛定思痛,決定優化一版。
秉承小步快跑的理念,決定從各個角度逐步優化,從簡單到困難,逐個擊破。所以在分析了程式碼之後,準備從鎖這個角度入手,看看能否進行優化。
在進行具體的問題分析以及優化之前,先看下現有召回引擎的實現方案,後面的方案是針對現有方案的優化。
- 廣告訂單以HTTP方式推送給消息系統
- 消息系統收到廣告訂單消息後
- 將廣告訂單消息格式化後推送給消息隊列kafka(第1步)
- 將廣告訂單消息持久化到DB(第2步)
- 召回引擎訂閱kafka的topic
- 從kafka中實時獲取廣告訂單消息,建立並實時更建立維度索引(第3步)
- 召回引擎接收pv流量,實時計算,並返回滿足定向後的廣告候選集(第4步)
從上面圖中可以看出,召回引擎是一個多執行緒應用,一方面有個執行緒專門從kafka中獲取最新的廣告訂單消息建立維度索引(此為寫執行緒),另一方面,接收線上流量,根據流量屬性,獲取廣告候選集(此為讀執行緒)。因為召回引擎涉及到同時讀和寫同一塊變數,因此讀寫不能同時操作。
概述
在多執行緒環境下,對同一個變數訪問,大致分為以下幾種情況:
- 多個執行緒同時讀
- 多個執行緒同時寫
- 一個執行緒寫,一個執行緒讀
- 一個執行緒寫,多個執行緒讀
- 多個執行緒寫,一個執行緒讀
- 多個執行緒寫,多個執行緒讀
在上述幾種情況中,多個執行緒同時讀顯然是執行緒安全的,而對於其他幾種情況,則需要保證其_互斥排他_性,即讀寫不能同時進行,管他幾個執行緒讀幾個執行緒寫,程式碼走起。
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)
}
在上述程式碼中,每一個執行緒對共享變數的訪問,都會通過mutex來加鎖操作,這樣完全就避免了共享變數競爭的問題。
如果對於性能要求不是很高的業務,上述實現完全滿足需求,但是對於性能要求很高的業務,上述實現就不是很好,所以可以考慮通過其他方式來實現。
我們設想一個場景,假如某個業務,寫操作次數遠遠小於讀操作次數,例如我們的召回引擎,那麼我們完全可以使用讀寫鎖來實現該功能,換句話說_讀寫鎖適合於讀多寫少的場景_。
讀寫鎖其實還是一種鎖,是給一段臨界區程式碼加鎖,但是此加鎖是在進行寫操作的時候才會互斥,而在進行讀的時候是可以共享的進行訪問臨界區的,其本質上是一種自旋鎖。
程式碼實現也比較簡單,如下:
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);
}
在此,說下讀寫鎖的特性:
- 讀和讀指針沒有競爭關係
- 寫和寫之間是互斥關係
- 讀和寫之間是同步互斥關係(這裡的同步指的是寫優先,即讀寫都在競爭鎖的時候,寫優先獲得鎖)
那麼,對於一寫多讀的場景,還有沒有可能進行再次優化呢?
答案是:有的。
下面,我們將針對一寫多讀,讀多寫少的場景,進行優化。
方案
在上一節中,我們提到對於多執行緒訪問,可以使用mutex對共享變數進行加鎖訪問。對於一寫多讀的場景,使用讀寫鎖進行優化,使用讀寫鎖,在讀的時候,是不進行加鎖操作的,但是當有寫操作的時候,就需要加鎖,這樣難免也會產生性能上的影響,在本節,我們提供終極優化版本,目的是在寫少讀多的場景下實現lock-free。
如何在讀寫都存在的場景下實現lock-free呢?假設如果有兩個共享變數,一個變數用來專供寫執行緒來寫,一個共享變數用來專供讀執行緒來讀,這樣就不存在讀寫同步的問題了,如下所示:
在上節中,我們有提到,多個執行緒對一個變數同時進行讀操作,是執行緒安全的。一個執行緒對一個變數進行寫操作也是執行緒安全的(這不廢話么,都沒人跟它競爭),那麼結合上述兩點,上圖就是執行緒安全的(多個執行緒讀一個資源,一個執行緒寫另外一個資源)。
好了,截止到現在,我們lock-free的雛形已經出來了,就是_使用雙變數_來實現lock-free的目標。那麼reader執行緒是如何第一時間能夠訪問writer更新後的數據呢?
假設有兩個共享資源A和B,當前情況下,讀執行緒正在讀資源A。突然在某一個時刻,寫執行緒需要更新資源,寫執行緒發現資源A正在被訪問,那麼其更新資源B,更新完資源B後,進行切換,讓讀執行緒讀資源B,然後寫執行緒繼續寫資源A,這樣就能完全實現了lock-free的目標,此種方案也可以成為雙buffer方式。
實現
在上節中,我們提出了使用雙buffer來實現lock-free的目標,那麼如何實現讀寫buffer無損切換呢?
指針互換
假設有兩個資源,其指針分別為ptrA和ptrB,在某一時刻,ptrA所指向的資源正在被多個執行緒讀,而ptrB所指向的資源則作為備份資源,此時,如果有寫執行緒進行寫操作,按照我們之前的思路,寫完之後,馬上啟用ptrA作為讀資源,然後寫執行緒繼續寫ptrB所指向的資源,這樣會有什麼問題呢?
我們就以std::vector
在上圖左半部分,假設ptr指向讀對象的指針,也就是說讀操作只能訪問ptr所指向的對象。
某一時刻,需要對對象進行寫操作(刪除對象Obj4),因為此時ptr = ptrA,因此寫操作只能操作ptrB所指向的對象,在寫操作執行完後,將ptr賦值為ptrB(保證後面所有的讀操作都是在ptrB上),即保證當前ptr所指向的對象永遠為最新操作,然後寫操作去刪除ptrA中的Obj4,但是此時,有個執行緒正在訪問ptrA的Obj4,自然而然會輕則當前執行緒獲取的數據為非法數據,重則程式崩潰。
此方案不可行,主要是因為在寫操作的時候,沒有判斷當前是否還有讀操作。
原子性
在上述方案中,簡單的變數交換,最終仍然可能存在讀寫同一個變數,進而導致崩潰。那麼如果保證在寫的時候,沒有讀是不是就能解決上述問題了呢?如果是的話,那麼應該如何做呢?
顯然,此問題就轉換成如何判斷一個對象上存在執行緒讀操作。
用過std::shared_ptr的都知道,其內部有個成員函數use_count()來判斷當前智慧指針所指向變數的訪問個數,程式碼如下:
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>> obj_buffers;
obj_buffers.emplace_back(std::make_shared<Obj>(...));
obj_buffers.emplace_back(std::make_shared<Obj>(...));
// 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
}
在上述程式碼中
- 首先創建一個vector,其內有兩個Obj的智慧指針,這倆智慧指針所指向的Obj對象一個供讀執行緒進行讀操作,一個供寫執行緒進行寫操作
- curr_idx代表當前可供讀操作對象在obj_buffers的索引,即obj_buffers[curr_idx.load()]所指對象供讀執行緒進行讀操作
- 那麼相應的,obj_buffers[1- curr_idx.load()]所指對象供寫執行緒進行寫操作
- 在讀執行緒中
- 通過auto tmp = obj_buffers[curr_idx.load()];獲取一個拷貝,由於obj_buffers中存儲的是shared_ptr那麼,該對象的引用計數+1
- 在tmp上進行讀操作
- 在寫執行緒中
- prepare = 1 – curr_idx.load();在上面我有提到curr_idx指向可讀對象在obj_buffers的索引,換句話說,1 – curr_idx.load()就是另外一個對象即可寫對象在obj_buffers中的索引
- 通過while循環判斷另外一個對象的引用計數是否大於1(如果大於1證明還有讀執行緒正在進行讀操作)
好了,截止到此,lock-free的實現目標基本已經完成。實現原理也也相對來說比較簡單,重點是要保證_寫的時候沒有讀操作_即可。

上圖是召回引擎做了lock-free優化後的效果圖,從圖上來看,效果還是很明顯的。
擴展
雙buffer方案在「一寫多讀」的場景下能夠實現lock-free的目標,那麼對於「多寫一讀」或者「多寫多讀」場景,是否也能夠滿足呢?
答案是不太適合,主要是以下兩個原因:
-
在多寫的場景下,多個寫之間需要通過鎖來進行同步,雖然避免了對讀寫互斥情況加鎖,但是多執行緒寫時通常對數據的實時性要求較高,如果使用雙buffer,所有新數據必須要等到索引切換時候才能使用,很可能達不到實時性要求
-
多執行緒寫時若用雙buffer模式,則在索引切換時候也需要給對應的對象加鎖,並且也要用類似於上面的while循環保證沒有現成在執行寫入操作時才能進行指針切換,而且此時也要等待讀操作完成才能進行切換,這時候就對備用對象的鎖定時間過長,在數據更新頻繁的情況下是不合適的。
缺點
通過前面的章節,我們知道通過雙buffer方式可以實現在一寫多讀場景下的lock-free,該方式要求兩個對象或者buffer最終持有的數據是完全一致的,也就是說在單buffer情況下,只需要一個buffer持有數據就行,但是雙buffer情況下,需要持有兩份數據,所以存在記憶體浪費的情況。
其實說白了,雙buffer實現lock-free,就是採用的空間換時間的方式。
結語
雙buffer方案在多執行緒環境下能較好的解決 「一寫多讀」 時的數據更新問題,特別是適用於數據需要定期更新,且一次更新數據量較大的情形。
性能優化是一個漫長的不斷自我提升的過程,項目中的一點點優化往往就可以使得性能得到質的提升。
好了,今天的文章就到這,我們下期見。
作者:高性能架構探索
關注公眾號【高性能架構探索】,第一時間獲取乾貨內容;回復【pdf】免費獲取電腦必備經典書籍