C++高并發場景下讀多寫少的優化方案
概述
一談到高并發的優化方案,往往能想到模塊水平拆分、資料庫讀寫分離、分庫分表,加快取、加mq等,這些都是從系統架構上解決,單模塊作為系統的組成單元,其性能好壞也能很大的影響整體性能,本文從單模塊下讀多寫少的場景出發,探討其解決方案,以其更好的實作高并發,
不同的業務場景,讀和寫的頻率各有側重,有兩種常見的業務場景:
- 讀多寫少:典型場景如廣告檢索端、白名單更新維護、loadbalancer
- 讀少寫多:典型場景如qps統計
本文針對讀多寫少(也稱一寫多讀)場景下遇到的問題進行分析,并探討一種合適的解決方案,
分析
讀多寫少的場景,服務大部分情況下都是處于讀,而且要求讀的耗時不能太長,一般是毫秒或者更低的級別;更新的頻率就不是那么頻繁,如幾秒鐘更新一次,通過簡單的加互斥鎖,騰出一片臨界區,往往能到達預期的效果,保證資料更新正確,

但是,只要加了鎖,就會帶來競爭,即使加的是讀寫鎖,雖然讀之間不互斥,但寫一樣會影響讀,而且讀寫同時爭奪鎖的時候,鎖優先分配給寫(讀寫鎖的特性),例如,寫的時候,要求所有的讀請求阻塞住,等到寫執行緒或協程釋放鎖之后才能讀,如果寫的臨界區耗時比較大,則所有的讀請求都會受影響,從監控圖上看,這時候會有一根很尖的耗時毛刺,所有的讀請求都在佇列中等待處理,如果在下個更新周期來之前,服務能處理完這些讀請求,可能情況沒那么糟糕,但極端情況下,如果下個更新周期來了,讀請求還沒處理完,就會形成一個惡性回圈,不斷的有讀請求在佇列中等待,最終導致佇列被擠滿,服務出現假死,情況再惡劣一點的話,上游服務發現某個節點假死后,由于負載均衡策略,一般會重試請求其他節點,這時候其他節點的壓力跟著增加了,最終導致整個系統出現雪崩,
因此,加鎖在高并發場景下要盡量避免,如果避免不了,需要讓鎖的粒度盡量小,接近無鎖(lock-free)更好,簡單的對一大片臨界區加鎖,在高并發場景下不是一種合適的解決方案
雙緩沖
有一種資料結構叫雙緩沖,其這種資料結構很常見,例如顯示屏的顯示原理,顯示屏顯示的當前幀,下一幀已經在后臺的buffer準備好,等時間周期一到,就直接替換前臺幀,這樣能做到無卡頓的重繪,其實作的指導思想是空間換時間,這種資料結構的作業原理如下:
- 資料分為前臺和后臺
- 所有讀執行緒讀前臺資料,不用加鎖,通過一個指標來指向當前讀的前臺資料
- 只有一個執行緒負責更新,更新的時候,先準備好后臺資料,接著直接切指標,這之后所有新進來的讀請求都看到了新的前臺資料
- 有部分讀還落在老的前臺那里處理,因為更新還不算完成,也就不能退出寫執行緒,寫執行緒需要等待所有落在老前臺的執行緒讀完成后,才能退出,在退出之前,順便再更新一遍老前臺資料(也就當前的新后臺),可以保證前后臺資料一致,這點在做增量更新的時候有用

工程實作上需要攻克的難點
- 寫執行緒要怎么知道所有的讀執行緒在老前臺中的讀完成了呢?
一種做法是讓各個讀執行緒都維護一把鎖,讀的時候鎖住,這時候不會影響其他執行緒的讀,但會影響寫,讀完后釋放鎖(某些時候可能會有通知寫執行緒的開銷,但由于寫的頻率很低,所以這點開銷還能接受),寫執行緒只需要確認鎖有沒有釋放了,確認完了后馬上釋放,確認這個動作非常快(小于25ns,1s=1000ms=1000000us=1000000000ns),讀執行緒幾乎不會感覺到鎖的存在, - 每個執行緒都有一把自己的鎖,需要用全域的map來做執行緒id和鎖的映射嗎?
不需要,而且這樣做全域map就要加全域鎖了,又回到了剛開始分析中遇到的問題了,其實,每個執行緒可以有私有存盤(thread local storage,簡稱TLS),如果是協程,就對應這協程的TLS(但對于go語言,官方是不支持TLS的,想實作類似功能,要么就想辦法獲取到TLS,要么就不要基于協程鎖,而是用全域鎖,但盡量讓鎖粒度小,本文主要針對C++語言,暫時不深入討論其他語言的實作),這樣每個讀執行緒鎖的是自己的鎖,不會影響到其他的讀執行緒,鎖的目的僅僅是為了保證讀優先,
對于執行緒私有存盤,可以使用pthread_key_create, pthread_setspecific,pthread_getspecific系列函式
核心代碼實作
讀
template <typename T, typename TLS>
int DoublyBufferedData<T, TLS>::Read(
typename DoublyBufferedData<T, TLS>::ScopedPtr* ptr) { // ScopedPtr析構的時候,會釋放鎖
Wrapper* w = static_cast<Wrapper*>(pthread_getspecific(_wrapper_key)); //非首次讀,獲取pthread local lock
if (BAIDU_LIKELY(w != NULL)) {
w->BeginRead(); // 鎖住
ptr->_data = https://www.cnblogs.com/longbozhan/p/UnsafeRead();
ptr->_w = w;
return 0;
}
w = AddWrapper();
if (BAIDU_LIKELY(w != NULL)) {
const int rc = pthread_setspecific(_wrapper_key, w); // 首次讀,設定pthread local lock
if (rc == 0) {
w->BeginRead();
ptr->_data = UnsafeRead();
ptr->_w = w;
return 0;
}
}
return -1;
}
寫
template <typename T, typename TLS>
template <typename Fn>
size_t DoublyBufferedData<T, TLS>::Modify(Fn& fn) {
BAIDU_SCOPED_LOCK(_modify_mutex); // 加鎖,保證只有一個寫
int bg_index = !_index.load(butil::memory_order_relaxed); // 指向后臺buffer
const size_t ret = fn(_data[bg_index]); // 修改后臺buffer
if (!ret) {
return 0;
}
// 切指標
_index.store(bg_index, butil::memory_order_release);
bg_index = !bg_index;
// 等所有讀老前臺的執行緒讀結束
{
BAIDU_SCOPED_LOCK(_wrappers_mutex);
for (size_t i = 0; i < _wrappers.size(); ++i) {
_wrappers[i]->WaitReadDone();
}
}
// 確認沒有讀了,直接修改新后臺資料,對其新前臺
const size_t ret2 = fn(_data[bg_index]);
return ret2;
}
完整實作請參考brpc的DoublyBufferData
擴展實作
基于計數器的實作
基于計數器,用atomic,保證原子性,讀進入臨界區,計數器+1,退出-1,寫判斷計數器為0則切換(但在計數器為0和切換中間,不能保證沒有新的讀進來,這時候也要鎖),而且計數器是全域鎖,這種方案C++也可以采取,只是計數器畢竟也是全域鎖,性能會差那么一丟丟,即使用智能指標shared_ptr,也會面臨切換之前計數器有變成非0的問題,之所以用計數器,而不用TLS,是因為有些語言,如golang,不支持TLS,對比TLS版本和計數器版本,TLS性能更優,因為沒有搶計數器的互斥問題,大概耗費700ns,很多嗎?搶計數器本身很快,性能沒測驗過,可以試試,
golang中sync.Map的實作
也是基于計數器,只是計數器是為了讓讀前臺快取失效的概率不要太高,有抑制和收斂的作用,實作了讀的無鎖,少部分情況下,前臺快取讀不到資料的時候,會去讀后臺快取,這時候也要加鎖,同時計數器+1,計數器數值達到一定程度(超過后臺快取的元素個數),就執行切換
是否適用于讀少寫多的場景
不合適,雙緩沖優先保證讀的性能,寫多讀少的場景需要優先保證寫的性能,
相關文獻
作者:longbozhanbrpc對于雙buffer的描述:https://www.bookstack.cn/read/incubator-brpc/3c7745da34a1418b.md#DoublyBufferedData
go實作的雙buffer(但讀是互斥的,性能先對較差):http://blog.codeg.cn/2016/01/27/double-buffering/
雙buffer的三種實作方案:https://juejin.cn/post/6844904130989801479
一寫多讀:https://blog.csdn.net/lqt641/article/details/55058137
高并發下的系統設計:https://www.cnblogs.com/flame540/p/12817529.html
基于shared_ptr的實作,原理也是計數器,但感徑訓是有缺陷:https://www.cnblogs.com/gaoxingnjiagoutansuo/p/15773361.html#4998436
出處: https://www.cnblogs.com/longbozhan/p/15780194.html
如果您覺得本文對您有幫助,請點擊一下右下方的推薦按鈕, 如果您對本文有任何疑問并想和作者探討,請在本文下方評論,我看到后將第一時間回復!
著作權宣告:本文為博主原創或轉載文章,歡迎轉載,但轉載文章之后必須在文章頁面明顯位置注明出處,否則保留追究法律責任的權利,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/412823.html
標籤:C++
