共享變量的並發讀寫


在高性能並發服務器中,對於共享對象的讀寫是最常見的操作之一,比如全局配置類對象的並發讀取和更新,以及更復雜的如copy on write btree、堆棧等的並發讀寫,最基本的操作都可以簡化理解為通過全局共享的指針,並發讀取和更新指針所指向對象的操作。
最簡單的模型如下所示,一個包含了多個字段的結構體:

struct GConf
{
  int64_t a;
  int64_t b;
  int64_t c;
};
可以通過一個全局指針struct gConf *g_conf進行訪問。有多個線程會同時讀取或更新這個對象,要求任何時刻讀取到的內容一定是“一致的”,即讀取到的a/b/c的值一定是相同一次更新的值,而不能出現讀到的a是某一次更新的值,而b或c是另外一次更新的值。

我們假設研發平台為x86_64,指令級的原子操作最多是64位(實際intel提供了128bit的原子操作,但是本文后續的討論的方法只用到64bit的原子操作),因為gConf包含三個8字節整數,無法使用原子操作指令對a/b/c一次性賦值,因此必須通過程序算法來保證並發情況下讀取到的內容滿足“一致性”。
下面開始從易到難討論5種處理“共享對象並發讀寫”的方法。

1. 讀寫鎖
最簡單朴素的想法是為這個結構配備一個讀寫鎖,讀取操作在讀鎖保護下執行,更新操作在寫鎖保護下執行。這個方法在對象讀取和更新操作都比較簡單,且並發程度不高的情況下是一個不錯的選擇。但是由於讀鎖與寫鎖互相阻塞,在讀取或更新操作比較費時的情況下,會出現更新或讀取線程被阻塞的風險,在高並發的場景下,這種阻塞不可接受。一個實例是數據庫系統的全局schema管理器,每次事務都需要讀取schema的內容,而DDL會引起schema更新,它的內容比較復雜,更新操作比較費時,會阻塞住處理事務的線程。

2. 引用計數的問題
為了解決讀寫操作相互阻塞的問題,使用類似Copy on write的方式,每次更新時都構造新的對象,在更新過程中,讀取操作還是讀取舊的對象,更新操作完成后,原子的替換掉指針使其指向新的對象。而被替換掉的對象不能立即釋放,而是要確保這個對象不再被繼續使用的情況下,才能將其釋放掉。因此這里使用引用計數機制,在讀取前將引用計數加1,讀取完畢后將引用計數減1,當引用計數減到0的時候,表示沒人在使用這個對象,可以將其釋放掉。
引用計數在沒有並發保護情況下面臨的問題是,取得全局指針g_conf和通過這個指針操作對象的引用計數這兩個操作並非原子的。可能存在的情況是,一個線程T1取得g_conf的值保存到寄存器后,就被切換出去,另外一個線程T2將g_conf替換后,判斷舊的對象引用計數為0,則將其釋放。當T1被切換回來繼續執行時,它在寄存器中保存的g_conf指向的對象已經被釋放,訪問對象引用計數的這一次操作就已經存在非法內存訪問的風險。
下面兩節討論兩種解決並發引用計數的方案。

3. 帶鎖的引用計數
解決第2點提出的原子性問題,一個最直接的方式是使用鎖,將讀寫g_conf和修改引用計數兩個操作放在一個獨立全局鎖(g_lock)的臨界區執行,代碼示例如下:

讀取並將引用計數加1:

fetch()
{
  g_lock.rdlock();
  g_conf->inc_ref();
  ret = g_conf;
  g_lock.unlock();
}

將引用計數減1:

revert(ptr)
{
  g_lock.rdlock();
  if(0 == ptr->dec_ref())
  {
    free(ptr);
  }
  g_lock.unlock();
}
 

使用新的對象替換舊對象:

set_new_conf(new_conf)
{
  g_lock.wrlock()
  if (0 == g_conf->dec_ref())
  {
    free(g_conf);
  }
  new_conf->inc_ref();
  g_conf = new_conf;
  g_lock.unlock();
}

在實際場景中,通常讀取g_conf的操作更多且並發量大,而替換g_conf的操作不會經常發生,因此讀取g_conf時加共享鎖,替換g_conf時加互斥鎖。
使用鎖保護引用計數的方式在高並發的情況下存在一些局限:一方面可能存在讀者或寫者被餓死的情況;另一方面多個寫者無法並發,應用在copy on write btree等數據結構中時,多個寫者相互阻塞的問題會影響到整個數據結構的並發性能。

4. 無鎖的引用計數
因此,一個更進一步的設計是使用無鎖的引用計數,即無論讀取g_conf還是替換g_conf都不在鎖中進行,如下圖所示:share_obj_seq_ref

RefNode的結構包含了指向GConf對象的指針data_ptr,以及引用計數ref_cnt,自增序列號seq_id,ref_cnt和seq_id長度都為4字節且地址連續,可以經由一次8字節的原子操作進行原子性的修改和讀取。每次構造新的GConf對象的要同時分配新的RefNode來保存GConf對象的地址。
RefNode對象的分配和釋放由專用的分配器進行分配和重用,一旦分配就不會釋放回系統。引用計數的加1和減1,操作的是RefNode對象的ref_cnt字段,當引用計數減為0時,將data_ptr指向的對象釋放,並將seq_id加1,然后將RefNode對象釋放給內存分配器。已釋放回分配器的RefNode對象仍然可以被安全的訪問到。
替代全局GConf指針,我們增加了一個被稱為GNode結構體的全局對象稱為g_node,它包含了指向RefNode對象的指針稱為node_idx,和這個RefNode中seq_id字段的值node_seq。node_idx和seq_id長度都為4字節且地址連續,可以經由一次8字節的原子操作進行原子性的讀取和替換。
讀取和替換操作的代碼示例如下:
將引用計數加1並返回:

fetch()
{
  GNode tmp_g_node = atomic_load(g_node);
  while (true)
  {
    if (tmp_g_node.node_idx->atomic_check_seq_and_inc_ref(tmp_g_node.node_seq))
    {
      ret = tmp_g_node.node_idx;
      break;
    }
    else
    {
      tmp_g_node = atomic_load(g_node);
    }
  }
}

將引用計數減1:

revert(node_idx)
{
  if (node_idx->atomic_dec_ref_and_inc_seq())
  {
    free(node_idx->data_ptr);
    free_list.free(node_idx);
  }
}  

使用新的對象替換舊對象:

set_new_conf(new_conf)
{
  RefNode *new_node = free_list.alloc();
  new_node->ref_cnt = 1;
  new_node->data_ptr = new_conf;

  GNode new_g_node;
  new_g_node.node_seq = new_node->seq_id;
  new_g_node.node_idx = new_node;

  GNode old_g_node = atomic_store_and_fetch_old(&g_node, new_g_node);
  if (old_g_node.node_idx->atomic_dec_ref_and_inc_seq())
  {
    free(old_g_node.node_idx->data_ptr);
    free_list.free(old_g_node.node_idx);
  }
}

其中幾個原子操作的作用:
1. RefNode::atomic_check_seq_and_inc_ref原子性的判斷如果seq_id等於傳入參數就將ref_cnt加1,並返回true,否則返回false。
2. atomic_store_and_fetch_old原子性的將第二個參數賦值給第一個參數,並返回第一個參數的舊值。
3. RefNode::atomic_dec_ref_and_inc_seq原子性的將ref_cnt減1,並判斷ref_cnt如果已減為0,則將seq_id加1並返回true,否則返回false。
工程實現上,為了達到node_idx長度只能有4字節的要求,RefNode分配器設計為定長數組,node_idx為數組下標,使用定長無鎖隊列作為分配器來維護空閑的node指針。一般來說一個線程只會增加一次引用計數,因此對於維護GConf這樣全局只有一個的對象,定長RefNode數組的長度初始化為與線程個數相等就夠用了。

5. HazardPointer
引用計數基本可以解決共享對象並發讀寫的問題,但它仍然存在一些不足:第一,每次讀取都需要使用原子操作修改全局引用計數,高並發情況下的對同一個變量的原子操作可能成為性能瓶頸;第二,管理RefNode對象的無鎖分配器有額外的管理和維護成本,增加的實現復雜度;第三,針對每個共享對象都需要維護N個(N為線程個數)RefNode,如果共享對象數量較多(比如Btree節點),為節省空間,還需要額外的機制來避免大量使用RefNode。
因此再介紹一種被稱為HazardPointer的思路,它由Maged M. Michael在論文“Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects”中提出,基本思路是將可能要被訪問到的共享對象指針(成為hazard pointer)先保存到線程局部,然后再訪問,訪問完成后從線程局部移除。而要釋放一個共享對象時,則要先遍歷查詢所有線程的局部信息,如果尚有線程局部保存有這個共享對象的指針,說明這個線程有可能將要訪問這個對象,因此不能釋放,只有所有線程的局部信息中都沒有保存這個共享對象的指針情況下,才能將其釋放。
讀取和替換操作的代碼示例如下:

g_hp_array[N]; //保存每個線程局部信息的數組,N為線程個數

讀取操作:

while(true)
{
  ret = g_conf;
  g_hp_array[thread_id] = ret;
  if (g_conf == ref)
  {
    break;
  }
  else
  {
    g_hp_array[thread_id] = NULL;
  }
}
// 讀取邏輯代碼…
g_hp_array[thread_id] = NULL;

使用新的對象替換舊對象:

set_new_conf(new_conf)
{
  retired_ptr = atomic_store_and_fetch_old(&g_conf, new_conf);
  found = false;
  for (i = 0; i < g_hp_array.length(); i++)
  {
    if (retired_ptr == g_hp_array[i])
    {
      found = true;
      break;
    }
  }
  if (!found)
  {
    free(retired_ptr);
  }
}

HazardPointer的設計解決了引用計數原子操作的性能瓶頸,避免了實現額外RefNode無鎖分配器的復雜度。但是同時引入一個新的問題:由於回收內存的操作需要遍歷查詢全局數組g_hp_array,代價比較大,通常工程上的做法是將待回收的指針暫存在線程局部,等數量超過一個配置門限時,才進行批量回收。使得回收內存產生延遲,並且回收操作本身由於需要遍歷數組,也會消耗較多的時間。
關於並發場景的問題,由於回收操作便利g_hp_array並不能保證原子的遍歷,而HazardPointer的設計要求對象已經被放入g_hp_array后是能夠安全訪問的,因此可能存在如下時序:
1. 讀取線程拿到g_conf指針存入局部變量ret
2. 更新線程將g_conf指針替換,掃描g_hp_array沒有找到g_conf,釋放g_conf內存
3. 讀取線程將剛剛拿到的ret放入g_hp_array,然后開始使用ret,而此時ret指向的內存已經釋放
為了處理這個問題,如上述偽代碼所示,讀取線程需要增加double check邏輯,判斷如果從拿到g_conf到放入g_hp_array之間,可能有回收邏輯遍歷過g_hp_array,則要重試。
因為替換g_conf意味着要將舊的g_conf釋放,而釋放意味着要先會遍歷g_hp_array,因此讀取線程只需要在將g_conf放入g_hp_array后,判斷g_conf是否變化,如果變化了,則說明g_conf可能正好在從拿到g_conf到放入g_hp_array之間被釋放了,因此需要重試。

6. HazardVersion

HazardPointer的實現簡單,但是與引用計數法有着相似的不足:需要為每個共享變量維護O(N)個(N為線程個數)槽位,使用者需要仔細分析算法以盡量減少同時存在的hazard pointer 或者引入其他機制封裝多個共享變量稱為一個hazard pointer。這樣就給使用者帶來了比較大的難度,HazardPointer機制也與具體數據結構的實現比較緊的耦合在一起。

因此在HazardPointer基礎上發展出了被稱為HazardVersion技術,它提供類似lock一樣的acquire/release接口,支持無限制個數共享對象的管理。

與HazardPointer的實現不同:首先全局要維護一個int64_t類型的GlobalVersion;要訪問共享對象前,先將當時的GlobalVersion保存到線程局部,稱為hazard version;而每次要釋放共享對象的時候先將當前GlobalVersion保存在共享對象,然后將GlobalVersion原子的加1,然后遍歷所有線程的局部信息,找到最小的version稱為reclaim version,判斷如果待釋放的對象中保存的version小於reclaim version則可以釋放。

讀取和替換操作的代碼示例如下:

g_version; //全局GlobalVersion
g_hv_array[N]; //保存每個線程局部信息的數組,N為線程個數

讀取操作:

g_hv_array[thread_id] = g_version;
ret = g_conf;
// 讀取邏輯代碼…
g_hv_array[thread_id] = INT64_MAX;

使用新的對象替換舊對象:

set_new_conf(new_conf)
{
  retired_ptr = atomic_store_and_fetch_old(&g_conf, new_conf);
  retired_ptr->set_version(atomic_fetch_and_add(&g_version, 1));
  reclaim_version = INT64_MAX;
  for (i = 0; i < g_hv_array.length(); i++)
  {
    if (reclaim_version > g_hv_array[i])
    {
      reclaim_version = g_hv_array[i];
    }
  }
  if (reclaim_version > retired_ptr->get_version())
  {
    free(retired_ptr);
  }
}

可以看到,讀取操作先保存了GlobalVersion到線程局部,然后去讀取g_conf指針,雖然可能同時發生的回收操作遍歷g_hv_array並不保證原子性,但是即使一個更小的hazard version被放入g_hv_array而錯過了遍歷,也不會有風險。

因為回收操作是先更新g_conf指針后遍歷g_hv_array,而讀取操作是先更新g_hv_array后讀取g_conf指針,所以最壞情況下讀取操作與回收操作同時發生,且讀取操作放入g_hv_array的hazard version被回收操作遍歷時錯過,那么讀取操作讀到的g_conf一定是被回收操作更新之后的,本次不會被回收,可以安全的訪問。

HazardVersion相對HazardPointer,在用法上更加簡單,無需針對具體的數據結構進行分析,局限是需要在被管理的對象中保存version。其次,與HazardPointer一樣,由於遍歷g_hv_array比較費時,因此也需要引入類似的延遲回收機制。需要注意的是,它的缺點也比較明顯,一旦出現一個線程長時間不釋放HazardVersin時,會阻塞住后面更大Version對象的釋放。

7. 總結
本文討論了“讀寫鎖”、“帶鎖的引用計數”、“無鎖的引用計數”、“HazardPointer”、“HazardVersion”五種方法來處理共享對象的並發讀寫問題,在實際工程領域,“帶鎖的引用計數”比較常見,在特定場景下優化讀寫鎖可以得到不錯的性能,並且很易於理解和實現;“無鎖的引用計數”實現復雜但並發讀寫性能卓越,在已開源的OceanBase版本中也有實踐;“HazardPointer”實現簡單,但是理解和使用都較復雜;“HazardVersion”是一種基於“HazardPointer”的創新思路,有一定的局限性,憑借簡單易用的接口,可以在很多場景替代“HazardPointer”。

 

原文地址:http://oceanbase.org.cn/?p=82


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM