效能優化-使用雙buffer實現無鎖佇列

高效能架構探索發表於2022-01-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方案在多執行緒環境下能較好的解決 “一寫多讀” 時的資料更新問題,特別是適用於資料需要定期更新,且一次更新資料量較大的情形。

效能優化是一個漫長的不斷自我提升的過程,專案中的一點點優化往往就可以使得效能得到質的提升。

好了,今天的文章就到這,我們下期見。

推薦閱讀

記憶體洩漏-原因、避免和定位

strict weak ordering導致公司級故障

GDB除錯-從入門到實踐

作者:高效能架構探索,關注作者,第一時間獲取乾貨內容

相關文章