如何設計並實現一個執行緒安全的 Map ?(下篇)

一縷殤流化隱半邊冰霜發表於2017-10-07

在上篇中,我們已經討論過如何去實現一個 Map 了,並且也討論了諸多優化點。在下篇中,我們將繼續討論如何實現一個執行緒安全的 Map。說到執行緒安全,需要從概念開始說起。

執行緒安全就是如果你的程式碼塊所在的程式中有多個執行緒在同時執行,而這些執行緒可能會同時執行這段程式碼。如果每次執行結果和單執行緒執行的結果是一樣的,而且其他的變數的值也和預期的是一樣的,就是執行緒安全的。

如果程式碼塊中包含了對共享資料的更新操作,那麼這個程式碼塊就可能是非執行緒安全的。但是如果程式碼塊中類似操作都處於臨界區之中,那麼這個程式碼塊就是執行緒安全的。

通常有以下兩類避免競爭條件的方法來實現執行緒安全:

第一類 —— 避免共享狀態

  1. 可重入 Re-entrancy)

通常線上程安全的問題中,最常見的程式碼塊就是函式。讓函式具有執行緒安全的最有效的方式就是使其可重入。如果某個程式中所有執行緒都可以併發的對函式進行呼叫,並且無論他們呼叫該函式的實際執行情況怎麼樣,該函式都可以產生預期的結果,那麼就可以說這個函式是可重入的。

如果一個函式把共享資料作為它的返回結果或者包含在它返回的結果中,那麼該函式就肯定不是一個可重入的函式。任何內含了操作共享資料的程式碼的函式都是不可重入的函式。

為了實現執行緒安全的函式,把所有程式碼都置放於臨界區中是可行的。但是互斥量的使用總會耗費一定的系統資源和時間,使用互斥量的過程總會存在各種博弈和權衡。所以請合理使用互斥量保護好那些涉及共享資料操作的程式碼。

注意:可重入只是執行緒安全的充分不必要條件,並不是充要條件。這個反例在下面會講到。

  1. 執行緒本地儲存

如果變數已經被本地化,所以每個執行緒都有自己的私有副本。這些變數通過子程式和其他程式碼邊界保留它們的值,並且是執行緒安全的,因為這些變數都是每個執行緒本地儲存的,即使訪問它們的程式碼可能被另一個執行緒同時執行,依舊是執行緒安全的。

  1. 不可變數

物件一旦初始化以後就不能改變。這意味著只有只讀資料被共享,這也實現了固有的執行緒安全性。可變(不是常量)操作可以通過為它們建立新物件,而不是修改現有物件的方式去實現。 Java,C#和
Python 中的字串的實現就使用了這種方法。

第二類 —— 執行緒同步

第一類方法都比較簡單,通過程式碼改造就可以實現。但是如果遇到一定要進行執行緒中共享資料的情況,第一類方法就解決不了了。這時候就出現了第二類解決方案,利用執行緒同步的方法來解決執行緒安全問題。

今天就從執行緒同步開始說起。


一. 執行緒同步理論

在多執行緒的程式中,多以共享資料作為執行緒之間傳遞資料的手段。由於一個程式所擁有的相當一部分虛擬記憶體地址都可以被該程式中所有執行緒共享,所以這些共享資料大多是以記憶體空間作為載體的。如果兩個執行緒同時讀取同一塊共享記憶體但獲取到的資料卻不同,那麼程式很容易出現一些 bug。

為了保證共享資料一致性,最簡單並且最徹底的方法就是使該資料成為一個不變數。當然這種絕對的方式在大多數情況下都是不可行的。比如函式中會用到一個計數器,記錄函式被呼叫了幾次,這個計數器肯定就不能被設為常量。那這種必須是變數的情況下,還要保證共享資料的一致性,這就引出了臨界區的概念。

臨界區的出現就是為了使該區域只能被序列的訪問或者執行。臨界區可以是某個資源,也可以是某段程式碼。保證臨界區最有效的方式就是利用執行緒同步機制。

先介紹2種共享資料同步的方法。

1. 互斥量

在同一時刻,只允許一個執行緒處於臨界區之內的約束稱為互斥,每個執行緒在進入臨界區之前,都必須先鎖定某個物件,只有成功鎖定物件的執行緒才能允許進入臨界區,否則就會阻塞。這個物件稱為互斥物件或者互斥量。

一般我們日常說的互斥鎖就能達到這個目的。

互斥量可以有多個,它們所保護的臨界區也可以有多個。先從簡單的說起,一個互斥量和一個臨界區。

(一) 一個互斥量和一個臨界區

上圖就是一個互斥量和一個臨界區的例子。當執行緒1先進入臨界區的時候,當前臨界區處於未上鎖的狀態,於是它便先將臨界區上鎖。執行緒1獲取到臨界區裡面的值。

這個時候執行緒2準備進入臨界區,由於執行緒1把臨界區上鎖了,所以執行緒2進入臨界區失敗,執行緒2由就緒狀態轉成睡眠狀態。執行緒1繼續對臨界區的共享資料進行寫入操作。

當執行緒1完成所有的操作以後,執行緒1呼叫解鎖操作。當臨界區被解鎖以後,會嘗試喚醒正在睡眠的執行緒2。執行緒2被喚醒以後,由睡眠狀態再次轉換成就緒狀態。執行緒2準備進入臨界區,當臨界區此處處於未上鎖的狀態,執行緒2便將臨界區上鎖。

經過 read、write 一系列操作以後,最終在離開臨界區的時候會解鎖。

執行緒在離開臨界區的時候,一定要記得把對應的互斥量解鎖。這樣其他因臨界區被上鎖而導致睡眠的執行緒還有機會被喚醒。所以對同一個互斥變數的鎖定和解鎖必須成對的出現。既不可以對一個互斥變數進行重複的鎖定,也不能對一個互斥變數進行多次的解鎖。

如果對一個互斥變數鎖定多次可能會導致臨界區最終永遠阻塞。可能有人會問了,對一個未鎖定的互斥變成解鎖多次會出現什麼問題呢?

在 Go 1.8 之前,雖然對互斥變數解鎖多次不會引起任何 goroutine 的阻塞,但是它可能引起一個執行時的恐慌。Go 1.8 之前的版本,是可以嘗試恢復這個恐慌的,但是恢復以後,可能會導致一系列的問題,比如重複解鎖操作的 goroutine 會永久的阻塞。所以 Go 1.8 版本以後此類執行時的恐慌就變成了不可恢復的了。所以對互斥變數反覆解鎖就會導致執行時操作,最終程式異常退出。

(二) 多個互斥量和一個臨界區

在這種情況下,極容易產生執行緒死鎖的情況。所以儘量不要讓不同的互斥量所保護的臨界區重疊。

上圖這個例子中,一個臨界區中存在2個互斥量:互斥量 A 和互斥量
B。

執行緒1先鎖定了互斥量 A ,接著執行緒2鎖定了互斥量 B。當執行緒1在成功鎖定互斥量 B 之前永遠不會釋放互斥量 A。同樣,執行緒2在成功鎖定互斥量 A 之前永遠不會釋放互斥量 B。那麼這個時候執行緒1和執行緒2都因無法鎖定自己需要鎖定的互斥量,都由 ready 就緒狀態轉換為 sleep 睡眠狀態。這是就產生了執行緒死鎖了。

執行緒死鎖的產生原因有以下幾種:

    1. 系統資源競爭
    1. 程式推薦順序非法
    1. 死鎖必要條件(必要條件中任意一個不滿足,死鎖都不會發生)
      (1). 互斥條件
      (2). 不剝奪條件
      (3). 請求和保持條件
      (4). 迴圈等待條件

想避免執行緒死鎖的情況發生有以下幾種方法可以解決:

    1. 預防死鎖
      (1). 資源有序分配法(破壞環路等待條件)
      (2). 資源原子分配法(破壞請求和保持條件)
    1. 避免死鎖
      銀行家演算法
    1. 檢測死鎖
      死鎖定理(資源分配圖化簡法),這種方法雖然可以檢測,但是無法預防,檢測出來了死鎖還需要配合解除死鎖的方法才行。

徹底解決死鎖有以下幾種方法:

    1. 剝奪資源
    1. 撤銷程式
    1. 試鎖定 — 回退
      如果在執行一個程式碼塊的時候,需要先後(順序不定)鎖定兩個變數,那麼在成功鎖定其中一個互斥量之後應該使用試鎖定的方法來鎖定另外一個變數。如果試鎖定第二個互斥量失敗,就把已經鎖定的第一個互斥量解鎖,並重新對這兩個互斥量進行鎖定和試鎖定。

如上圖,執行緒2在鎖定互斥量 B 的時候,再試鎖定互斥量 A,此時鎖定失敗,於是就把互斥量 B 也一起解鎖。接著執行緒1會來鎖定互斥量 A。此時也不會出現死鎖的情況。

    1. 固定順序鎖定

這種方式就是讓執行緒1和執行緒2都按照相同的順序鎖定互斥量,都按成功鎖定互斥量1以後才能去鎖定互斥量2 。這樣就能保證在一個執行緒完全離開這些重疊的臨界區之前,不會有其他同樣需要鎖定那些互斥量的執行緒進入到那裡。

(三) 多個互斥量和多個臨界區

多個臨界區和多個互斥量的情況就要看是否會有衝突的區域,如果出現相互交集的衝突區域,後進臨界區的執行緒就會進入睡眠狀態,直到該臨界區的執行緒完成任務以後,再被喚醒。

一般情況下,應該儘量少的使用互斥量。每個互斥量保護的臨界區應該在合理範圍內並儘量大。但是如果發現多個執行緒會頻繁出入某個較大的臨界區,並且它們之間經常存在訪問衝突,那麼就應該把這個較大的臨界區劃分的更小一點,並使用不同的互斥量保護起來。這樣做的目的就是為了讓等待進入同一個臨界區的執行緒數變少,從而降低執行緒被阻塞的概率,並減少它們被迫進入睡眠狀態的時間,這從一定程度上提高了程式的整體效能。

在說另外一個執行緒同步的方法之前,回答一下文章開頭留下的一個疑問:可重入只是執行緒安全的充分不必要條件,並不是充要條件。這個反例在下面會講到。

這個問題最關鍵的一點在於:mutex 是不可重入的

舉個例子:

在下面這段程式碼中,函式 increment_counter 是執行緒安全的,但不是可重入的。


#include <pthread.h>

int increment_counter ()
{
    static int counter = 0;
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

    pthread_mutex_lock(&mutex);

    // only allow one thread to increment at a time
    ++counter;
    // store value before any other threads increment it further
    int result = counter;    

    pthread_mutex_unlock(&mutex);

    return result;
}複製程式碼

上面的程式碼中,函式 increment_counter 可以在多個執行緒中被呼叫,因為有一個互斥鎖 mutex 來同步對共享變數 counter 的訪問。但是如果這個函式用在可重入的中斷處理程式中,如果在
pthread_mutex_lock(&mutex) 和 pthread_mutex_unlock(&mutex)
之間產生另一個呼叫函式 increment_counter 的中斷,則會第二次執行此函式,此時由於 mutex 已被 lock,函式會在 pthread_mutex_lock(&mutex) 處阻塞,並且由於 mutex 沒有機會被
unlock,阻塞會永遠持續下去。簡言之,問題在於 pthread 的 mutex 是不可重入的。

解決辦法是設定 PTHREAD_MUTEX_RECURSIVE 屬性。然而對於給出的問題而言,專門使用一個 mutex 來保護一次簡單的增量操作顯然過於昂貴,因此 c++11 中的 原子變數&action=edit&redlink=1) 提供了一個可使此函式既執行緒安全又可重入(而且還更簡潔)的替代方案:


#include <atomic>

int increment_counter ()
{
    static std::atomic<int> counter(0);

    // increment is guaranteed to be done atomically
    int result = ++counter;

    return result;
}複製程式碼

在 Go 中,互斥量在標準庫程式碼包 sync 中的 Mutex 結構體表示的。sync.Mutex 型別只有兩個公開的指標方法,Lock 和 Unlock。前者用於鎖定當前的互斥量,後者則用於對當前的互斥量進行解鎖。

2. 條件變數

線上程同步的方法中,還有一個可以與互斥量相提並論的同步方法,條件變數。

條件變數與互斥量不同,條件變數的作用並不是保證在同一時刻僅有一個執行緒訪問某一個共享資料,而是在對應的共享資料的狀態發生變化時,通知其他因此而被阻塞的執行緒。條件變數總是與互斥變數組合使用的。

這類問題其實很常見。先用生產者消費者的例子來舉例。

如果不用條件變數,只用互斥量,來看看會發生什麼後果。

生產者執行緒在完成新增操作之前,其他的生產者執行緒和消費者執行緒都無法進行操作。同一個商品也只能被一個消費者消費。

如果只用互斥量,可能會出現2個問題。

    1. 生產者執行緒獲得了互斥量以後,卻發現商品已滿,無法再新增新的商品了。於是該執行緒就會一直等待。新的生產者也進入不了臨界區,消費者也無法進入。這時候就死鎖了。
    1. 消費者執行緒獲得了互斥量以後,卻發現商品是空的,無法消費了。這個時候該執行緒也是會一直等待。新的生產者和消費者也都無法進入。這時候同樣也死鎖了。

這就是隻用互斥量無法解決的問題。在多個執行緒之間,急需一套同步的機制,能讓這些執行緒都協作起來。

條件變數就是大家熟悉的 P - V 操作了。這塊大家應該比較熟悉,所以簡單的過一下。

P 操作就是 wait 操作,它的意思就是阻塞當前執行緒,直到收到該條件變數發來的通知。

V 操作就是 signal 操作,它的意思就是讓該條件變數向至少一個正在等待它通知的執行緒傳送通知,以表示某個共享資料的狀態已經變化。

Broadcast 廣播通知,它的意思就是讓條件變數給正在等待它通知的所有執行緒傳送通知,以表示某個共享資料的狀態已經發生改變。

signal 可以操作多次,如果操作3次,就代表發了3次訊號通知。如上圖。

P - V 操作設計美妙之處在於,P 操作的次數與 V 操作的次數是相同的。wait 多少次,signal 對應的有多少次。看上圖,這個迴圈就是這麼的奇妙。

生產者消費者問題

這個問題可以形象的描述成像上圖這樣,門衛守護著臨界區的安全。售票廳記錄著當前 semaphone 的值,它也控制著門衛是否開啟臨界區。

臨界區只允許一個執行緒進入,當已經有一個執行緒了,再來一個執行緒,就會被 lock 住。售票廳也會記錄當前阻塞的執行緒數。

當之前的執行緒離開以後,售票廳就會告訴門衛,允許一個執行緒進入臨界區。

用 P-V 虛擬碼來描述生產者消費者:

初始變數:


semaphore  mutex = 1; // 臨界區互斥訊號量
semaphore  empty = n; // 空閒緩衝區個數
semaphore  full = 0; // 緩衝區初始化為空複製程式碼

生產者執行緒:


producer()
{
  while(1) {
    produce an item in nextp;
    P(empty);
    P(mutex);
    add nextp to buffer;
    V(mutex);
    V(full);
  }
}複製程式碼

消費者執行緒:



consumer()
{
  while(1) {
    P(full);
    P(mutex);
    remove an item from buffer;
    V(mutex);
    V(empty);
    consume the item;
  }
}複製程式碼

雖然在生產者和消費者單個程式裡面 P,V 並不是成對的,但是整個程式裡面 P,V 還是成對的。

讀者寫者問題——讀者優先,寫者延遲

讀者優先,寫程式被延遲。只要有讀者在讀,後來的讀者都可以隨意進來讀。

讀者要先進入 rmutex ,檢視 readcount,然後修改 readcout 的值,最後再去讀資料。對於每個讀程式都是寫者,都要進去修改 readcount 的值,所以還要單獨設定一個 rmutex 互斥訪問。

初始變數:


int readcount = 0;     // 讀者數量
semaphore  rmutex = 1; // 保證更新 readcount 互斥
semaphore  wmutex = 1; // 保證讀者和寫著互斥的訪問檔案複製程式碼

讀者執行緒:


reader()
{
  while(1) {
    P(rmutex);              // 準備進入,修改 readcount,“開門”
    if(readcount == 0) {    // 說明是第一個讀者
      P(wmutex);            // 拿到”鑰匙”,阻止寫執行緒來寫
    }
    readcount ++;
    V(rmutex);
    reading;
    P(rmutex);              // 準備離開
    readcount --;
    if(readcount == 0) {    // 說明是最後一個讀者
      V(wmutex);            // 交出”鑰匙”,讓寫執行緒來寫
    }
    V(rmutex);              // 離開,“關門”
  }
}複製程式碼

寫者執行緒:


writer()
{
  while(1) {
    P(wmutex);
    writing;
    V(wmutex);
  }
}複製程式碼

讀者寫者問題——寫者優先,讀者延遲

有寫者寫,禁止後面的讀者來讀。在寫者前的讀者,讀完就走。只要有寫者在等待,禁止後來的讀者進去讀。

初始變數:


int readcount = 0;     // 讀者數量
semaphore  rmutex = 1; // 保證更新 readcount 互斥
semaphore  wmutex = 1; // 保證讀者和寫著互斥的訪問檔案
semaphore  w = 1;      // 用於實現“寫者優先”複製程式碼

讀者執行緒:


reader()
{
  while(1) {
    P(w);                   // 在沒有寫者的時候才能請求進入
    P(rmutex);              // 準備進入,修改 readcount,“開門”
    if(readcount == 0) {    // 說明是第一個讀者
      P(wmutex);            // 拿到”鑰匙”,阻止寫執行緒來寫
    }
    readcount ++;
    V(rmutex);
    V(w);
    reading;
    P(rmutex);              // 準備離開
    readcount --;
    if(readcount == 0) {    // 說明是最後一個讀者
      V(wmutex);            // 交出”鑰匙”,讓寫執行緒來寫
    }
    V(rmutex);              // 離開,“關門”
  }
}複製程式碼

寫者執行緒:


writer()
{
  while(1) {
    P(w);
    P(wmutex);
    writing;
    V(wmutex);
    V(w);
  }
}複製程式碼

哲學家進餐問題

假設有五位哲學家圍坐在一張圓形餐桌旁,做以下兩件事情之一:吃飯,或者思考。吃東西的時候,他們就停止思考,思考的時候也停止吃東西。餐桌中間有一大碗義大利麵,每兩個哲學家之間有一隻餐叉。因為用一隻餐叉很難吃到義大利麵,所以假設哲學家必須用兩隻餐叉吃東西。他們只能使用自己左右手邊的那兩隻餐叉。哲學家就餐問題有時也用米飯和筷子而不是義大利麵和餐叉來描述,因為很明顯,吃米飯必須用兩根筷子。

初始變數:


semaphore  chopstick[5] = {1,1,1,1,1}; // 初始化訊號量
semaphore  mutex = 1;                  // 設定取筷子的訊號量複製程式碼

哲學家執行緒:


Pi()
{
  do {
    P(mutex);                     // 獲得取筷子的互斥量
    P(chopstick[i]);              // 取左邊的筷子
    P(chopstick[ (i + 1) % 5 ]);  // 取右邊的筷子
    V(mutex);                     // 釋放取筷子的訊號量
    eat;
    V(chopstick[i]);              // 放回左邊的筷子
    V(chopstick[ (i + 1) % 5 ]);  // 放回右邊的筷子
    think;
  }while(1);
}複製程式碼

綜上所述,互斥量可以實現對臨界區的保護,並會阻止競態條件的發生。條件變數作為補充手段,可以讓多方協作更加有效率。

在 Go 的標準庫中,sync 包裡面 sync.Cond 型別代表了條件變數。但是和互斥鎖和讀寫鎖不同的是,簡單的宣告無法建立出一個可用的條件變數,還需要用到 sync.NewCond 函式。


func NewCond( l locker) *Cond複製程式碼

*sync.Cond 型別的方法集合中有3個方法,即 Wait、Signal 和 Broadcast 。

二. 簡單的執行緒鎖方案

實現執行緒安全的方案最簡單的方法就是加鎖了。

先看看 OC 中如何實現一個執行緒安全的字典吧。

在 Weex 的原始碼中,就實現了一套執行緒安全的字典。類名叫 WXThreadSafeMutableDictionary。


/**
 *  @abstract Thread safe NSMutableDictionary
 */
@interface WXThreadSafeMutableDictionary<KeyType, ObjectType> : NSMutableDictionary
@property (nonatomic, strong) dispatch_queue_t queue;
@property (nonatomic, strong) NSMutableDictionary* dict;
@end複製程式碼

具體實現如下:


- (instancetype)initCommon
{
    self = [super init];
    if (self) {
        NSString* uuid = [NSString stringWithFormat:@"com.taobao.weex.dictionary_%p", self];
        _queue = dispatch_queue_create([uuid UTF8String], DISPATCH_QUEUE_CONCURRENT);
    }
    return self;
}複製程式碼

該執行緒安全的字典初始化的時候會新建一個併發的 queue。


- (NSUInteger)count
{
    __block NSUInteger count;
    dispatch_sync(_queue, ^{
        count = _dict.count;
    });
    return count;
}

- (id)objectForKey:(id)aKey
{
    __block id obj;
    dispatch_sync(_queue, ^{
        obj = _dict[aKey];
    });
    return obj;
}

- (NSEnumerator *)keyEnumerator
{
    __block NSEnumerator *enu;
    dispatch_sync(_queue, ^{
        enu = [_dict keyEnumerator];
    });
    return enu;
}

- (id)copy{
    __block id copyInstance;
    dispatch_sync(_queue, ^{
        copyInstance = [_dict copy];
    });
    return copyInstance;
}複製程式碼

讀取的這些方法都用 dispatch_sync 。


- (void)setObject:(id)anObject forKey:(id<NSCopying>)aKey
{
    aKey = [aKey copyWithZone:NULL];
    dispatch_barrier_async(_queue, ^{
        _dict[aKey] = anObject;
    });
}

- (void)removeObjectForKey:(id)aKey
{
    dispatch_barrier_async(_queue, ^{
        [_dict removeObjectForKey:aKey];
    });
}

- (void)removeAllObjects{
    dispatch_barrier_async(_queue, ^{
        [_dict removeAllObjects];
    });
}複製程式碼

和寫入相關的方法都用 dispatch_barrier_async。

再看看 Go 用互斥量如何實現一個簡單的執行緒安全的 Map 吧。

既然要用到互斥量,那麼我們封裝一個包含互斥量的 Map 。


type MyMap struct {
    sync.Mutex
    m map[int]int
}

var myMap *MyMap

func init() {
    myMap = &MyMap{
        m: make(map[int]int, 100),
    }
}複製程式碼

再簡單的實現 Map 的基礎方法。


func builtinMapStore(k, v int) {
    myMap.Lock()
    defer myMap.Unlock()
    myMap.m[k] = v
}

func builtinMapLookup(k int) int {
    myMap.Lock()
    defer myMap.Unlock()
    if v, ok := myMap.m[k]; !ok {
        return -1
    } else {
        return v
    }
}

func builtinMapDelete(k int) {
    myMap.Lock()
    defer myMap.Unlock()
    if _, ok := myMap.m[k]; !ok {
        return
    } else {
        delete(myMap.m, k)
    }
}複製程式碼

實現思想比較簡單,在每個操作前都加上 lock,在每個函式結束 defer 的時候都加上 unlock。

這種加鎖的方式實現的執行緒安全的字典,優點是比較簡單,缺點是效能不高。文章最後會進行幾種實現方法的效能對比,用數字說話,就知道這種基於互斥量加鎖方式實現的效能有多差了。

在語言原生就自帶執行緒安全 Map 的語言中,它們的原生底層實現都不是通過單純的加鎖來實現執行緒安全的,比如 Java 的 ConcurrentHashMap,Go 1.9 新加的 sync.map。

三. 現代執行緒安全的 Lock - Free 方案 CAS

在 Java 的 ConcurrentHashMap 底層實現中大量的利用了 volatile,final,CAS 等 Lock-Free 技術來減少鎖競爭對於效能的影響。

在 Go 中也大量的使用了原子操作,CAS 是其中之一。比較並交換即 “Compare And Swap”,簡稱 CAS。


func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)

func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)

func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)

func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)

func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)複製程式碼

CAS 會先判斷引數 addr 指向的被操作值與引數 old 的值是否相等。如果相當,相應的函式才會用引數 new 代表的新值替換舊值。否則,替換操作就會被忽略。

這一點與互斥鎖明顯不同,CAS 總是假設被操作的值未曾改變,並一旦確認這個假設成立,就立即進行值的替換。而互斥鎖的做法就更加謹慎,總是先假設會有併發的操作修改被操作的值,並需要使用鎖將相關操作放入臨界區中加以保護。可以說互斥鎖的做法趨於悲觀,CAS 的做法趨於樂觀,類似樂觀鎖。

CAS 做法最大的優勢在於可以不建立互斥量和臨界區的情況下,完成併發安全的值替換操作。這樣大大的減少了執行緒同步操作對程式效能的影響。當然 CAS 也有一些缺點,缺點下一章會提到。

接下來看看原始碼是如何實現的。以下以64位為例,32位類似。


TEXT ·CompareAndSwapUintptr(SB),NOSPLIT,$0-25
    JMP    ·CompareAndSwapUint64(SB)

TEXT ·CompareAndSwapInt64(SB),NOSPLIT,$0-25
    JMP    ·CompareAndSwapUint64(SB)

TEXT ·CompareAndSwapUint64(SB),NOSPLIT,$0-25
    MOVQ    addr+0(FP), BP
    MOVQ    old+8(FP), AX
    MOVQ    new+16(FP), CX
    LOCK
    CMPXCHGQ    CX, 0(BP)
    SETEQ    swapped+24(FP)
    RET複製程式碼

上述實現最關鍵的一步就是 CMPXCHG。

查詢 Intel 的文件

文件上說:

比較 eax 和目的運算元(第一個運算元)的值,如果相同,ZF 標誌被設定,同時源運算元(第二個操作)的值被寫到目的運算元,否則,清
ZF 標誌,並且把目的運算元的值寫回 eax。

於是也就得出了 CMPXCHG 的工作原理:

比較 _old 和 (*__ptr) 的值,如果相同,ZF 標誌被設定,同時
_new 的值被寫到 (*__ptr),否則,清 ZF 標誌,並且把 (*__ptr) 的值寫回 _old。

在 Intel 平臺下,會用 LOCK CMPXCHG 來實現,這裡的 LOCK 是 CPU 鎖。

Intel 的手冊對 LOCK 字首的說明如下:

    1. 確保對記憶體的讀-改-寫操作原子執行。在 Pentium 及 Pentium 之前的處理器中,帶有 LOCK 字首的指令在執行期間會鎖住匯流排,使得其他處理器暫時無法通過匯流排訪問記憶體。很顯然,這會帶來昂貴的開銷。從 Pentium 4,Intel Xeon 及 P6 處理器開始,Intel 在原有匯流排鎖的基礎上做了一個很有意義的優化:如果要訪問的記憶體區域(area of memory)在 LOCK 字首指令執行期間已經在處理器內部的快取中被鎖定(即包含該記憶體區域的快取行當前處於獨佔或以修改狀態),並且該記憶體區域被完全包含在單個快取行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該快取行會一直被鎖定,其它處理器無法讀/寫該指令要訪問的記憶體區域,因此能保證指令執行的原子性。這個操作過程叫做快取鎖定(cache locking),快取鎖定將大大降低 LOCK 字首指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的記憶體地址未對齊時,仍然會鎖住匯流排。
    1. 禁止該指令與之前和之後的讀和寫指令重排序。
    1. 把寫緩衝區中的所有資料重新整理到記憶體中。

看完描述,可以看出,CPU 鎖主要分兩種,匯流排鎖和快取鎖。匯流排鎖用在老的 CPU 中,快取鎖用在新的 CPU 中。

所謂匯流排鎖就是使用 CPU 提供的一個LOCK#訊號,當一個處理器在匯流排上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該 CPU 可以獨佔使用共享記憶體。匯流排鎖的這種方式,在執行期間會鎖住匯流排,使得其他處理器暫時無法通過匯流排訪問記憶體。所以匯流排鎖定的開銷比較大,最新的處理器在某些場合下使用快取鎖定代替匯流排鎖定來進行優化。

所謂“快取鎖定”就是如果快取在處理器快取行中記憶體區域在 LOCK 操作期間被鎖定,當它執行鎖操作回寫記憶體時,處理器不在匯流排上產生
LOCK#訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域資料,當其他處理器回寫已被鎖定的快取行的資料時會對快取行無效。

有兩種情況處理器無法使用快取鎖。

  • 第一種情況是,當操作的資料不能被快取在處理器內部,或操作的資料跨多個快取行(cache line),則處理器會呼叫匯流排鎖定。

  • 第二種情況是:有些處理器不支援快取鎖定。一些老的 CPU 就算鎖定的記憶體區域在處理器的快取行中也會呼叫匯流排鎖定。

雖然快取鎖可以大大降低 CPU 鎖的執行開銷,但是如果遇到多處理器之間的競爭程度很高或者指令訪問的記憶體地址未對齊時,仍然會鎖住匯流排。所以快取鎖和匯流排鎖相互配合,效果更佳。

綜上,用 CAS 方式來保證執行緒安全的方式就比用互斥鎖的方式效率要高很多。

四. CAS 的缺陷

雖然 CAS 的效率高,但是依舊存在3大問題。

1. ABA 問題

執行緒1準備用 CAS 將變數的值由 A 替換為 B ,在此之前,執行緒2將變數的值由 A 替換為 C ,又由 C 替換為 A,然後執行緒1執行 CAS 時發現變數的值仍然為 A,所以 CAS 成功。但實際上這時的現場已經和最初不同了。圖上也為了分開兩個 A 不同,所以用不同的顏色標記了。最終執行緒2把 A 替換成了 B 。這就是經典的 ABA 問題。但是這會導致專案出現什麼問題呢?

設想存在這樣一個鏈棧,棧裡面儲存了一個連結串列,棧頂是 A,A 的 next 指標指向 B。線上程1中,要將棧頂元素 A 用 CAS 把它替換成 B。接著執行緒2來了,執行緒2將之前包含 A,B 元素的連結串列都 pop 出去。然後 push 進來一個 A - C - D 連結串列,棧頂元素依舊是 A。這時執行緒1發現 A 沒有發生變化,於是替換成 B。這個時候 B 的 next 其實為 nil。替換完成以後,執行緒2操作的連結串列 C - D 這裡就與表頭斷開連線了。也就是說執行緒1 CAS 操作結束,C - D 就被丟失了,再也找不回來了。棧中只剩下 B 一個元素了。這很明顯出現了 bug。

那怎麼解決這種情況呢?最通用的做法就是加入版本號進行標識。

每次操作都加上版本號,這樣就可以完美解決 ABA 的問題了。

2. 迴圈時間可能過長

自旋 CAS 如果長時間不成功,會給 CPU 帶來非常大的執行開銷。如果能支援 CPU 提供的 Pause 指令,那麼 CAS 的效率能有一定的提升。Pause 指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使 CPU 不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出迴圈的時候因記憶體順序衝突(memory order violation)而引起 CPU 流水線被清空(CPU pipeline flush),從而提高 CPU 的執行效率。

3. 只能保證一個共享變數的原子操作

CAS 操作只能保證一個共享變數的原子操作,但是保證多個共享變數操作的原子性。一般做法可能就考慮利用鎖了。

不過也可以利用一個結構體,把兩個變數合併成一個變數。這樣還可以繼續利用 CAS 來保證原子性操作。

五. Lock - Free 方案舉例

在 Lock - Free方案舉例之前,先來回顧一下互斥量的方案。上面我們用互斥量實現了 Go 的執行緒安全的 Map。至於這個 Map 的效能如何,接下來對比的時候可以看看資料。

1. NO Lock - Free 方案

如果不用 Lock - Free 方案也不用簡單的互斥量的方案,如何實現一個執行緒安全的字典呢?答案是利用分段鎖的設計,只有在同一個分段內才存在競態關係,不同的分段鎖之間沒有鎖競爭。相比於對整個
Map 加鎖的設計,分段鎖大大的提高了高併發環境下的處理能力。



type ConcurrentMap []*ConcurrentMapShared


type ConcurrentMapShared struct {
    items        map[string]interface{}
    sync.RWMutex // 讀寫鎖,保證進入內部 map 的執行緒安全
}複製程式碼

分段鎖 Segment 存在一個併發度。併發度可以理解為程式執行時能夠同時更新 ConccurentMap 且不產生鎖競爭的最大執行緒數,實際上就是 ConcurrentMap 中的分段鎖個數。即陣列的長度。


var SHARD_COUNT = 32複製程式碼

如果併發度設定的過小,會帶來嚴重的鎖競爭問題;如果併發度設定的過大,原本位於同一個 Segment 內的訪問會擴散到不同的 Segment 中,CPU cache 命中率會下降,從而引起程式效能下降。

ConcurrentMap 的初始化就是對陣列的初始化,並且初始化陣列裡面每個字典。


func New() ConcurrentMap {
    m := make(ConcurrentMap, SHARD_COUNT)
    for i := 0; i < SHARD_COUNT; i++ {
        m[i] = &ConcurrentMapShared{items: make(map[string]interface{})}
    }
    return m
}複製程式碼

ConcurrentMap 主要使用 Segment 來實現減小鎖粒度,把 Map 分割成若干個 Segment,在 put 的時候需要加讀寫鎖,get 時候只加讀鎖。

既然分段了,那麼針對每個 key 對應哪一個段的邏輯就由一個雜湊函式來定。


func fnv32(key string) uint32 {
    hash := uint32(2166136261)
    const prime32 = uint32(16777619)
    for i := 0; i < len(key); i++ {
        hash *= prime32
        hash ^= uint32(key[i])
    }
    return hash
}複製程式碼

上面這段雜湊函式會根據每次傳入的 string ,計算出不同的雜湊值。


func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
    return m[uint(fnv32(key))%uint(SHARD_COUNT)]
}複製程式碼

根據雜湊值對陣列長度取餘,取出 ConcurrentMap 中的 ConcurrentMapShared。在 ConcurrentMapShared 中儲存對應這個段的 key - value。



func (m ConcurrentMap) Set(key string, value interface{}) {
    // Get map shard.
    shard := m.GetShard(key)
    shard.Lock()
    shard.items[key] = value
    shard.Unlock()
}複製程式碼

上面這段就是 ConcurrentMap 的 set 操作。思路很清晰:先取出對應段內的 ConcurrentMapShared,然後再加讀寫鎖鎖定,寫入 key - value,寫入成功以後再釋放讀寫鎖。


func (m ConcurrentMap) Get(key string) (interface{}, bool) {
    // Get shard
    shard := m.GetShard(key)
    shard.RLock()
    // Get item from shard.
    val, ok := shard.items[key]
    shard.RUnlock()
    return val, ok
}複製程式碼

上面這段就是 ConcurrentMap 的 get 操作。思路也很清晰:先取出對應段內的 ConcurrentMapShared,然後再加讀鎖鎖定,讀取 key - value,讀取成功以後再釋放讀鎖。

這裡和 set 操作的區別就在於只需要加讀鎖即可,不用加讀寫鎖。


func (m ConcurrentMap) Count() int {
    count := 0
    for i := 0; i < SHARD_COUNT; i++ {
        shard := m[i]
        shard.RLock()
        count += len(shard.items)
        shard.RUnlock()
    }
    return count
}複製程式碼

ConcurrentMap 的 Count 操作就是把 ConcurrentMap 陣列的每一個分段元素裡面的每一個元素都遍歷一遍,計算出總數。


func (m ConcurrentMap) Keys() []string {
    count := m.Count()
    ch := make(chan string, count)
    go func() {
        // 遍歷所有的 shard.
        wg := sync.WaitGroup{}
        wg.Add(SHARD_COUNT)
        for _, shard := range m {
            go func(shard *ConcurrentMapShared) {
                // 遍歷所有的 key, value 鍵值對.
                shard.RLock()
                for key := range shard.items {
                    ch <- key
                }
                shard.RUnlock()
                wg.Done()
            }(shard)
        }
        wg.Wait()
        close(ch)
    }()

    // 生成 keys 陣列,儲存所有的 key
    keys := make([]string, 0, count)
    for k := range ch {
        keys = append(keys, k)
    }
    return keys
}複製程式碼

上述是返回 ConcurrentMap 中所有 key ,結果裝在字串陣列中。



type UpsertCb func(exist bool, valueInMap interface{}, newValue interface{}) interface{}

func (m ConcurrentMap) Upsert(key string, value interface{}, cb UpsertCb) (res interface{}) {
    shard := m.GetShard(key)
    shard.Lock()
    v, ok := shard.items[key]
    res = cb(ok, v, value)
    shard.items[key] = res
    shard.Unlock()
    return res
}複製程式碼

上述程式碼是 Upsert 操作。如果已經存在了,就更新。如果是一個新元素,就用 UpsertCb 函式插入一個新的。思路也是先根據 string 找到對應的段,然後加讀寫鎖。這裡只能加讀寫鎖,因為不管是 update 還是 insert 操作,都需要寫入。讀取 key 對應的 value 值,然後呼叫 UpsertCb 函式,把結果更新到 key 對應的 value 中。最後釋放讀寫鎖即可。

UpsertCb 函式在這裡值得說明的是,這個函式是回撥返回待插入到 map 中的新元素。這個函式當且僅當在讀寫鎖被鎖定的時候才會被呼叫,因此一定不允許再去嘗試讀取同一個 map 中的其他 key 值。因為這樣會導致執行緒死鎖。死鎖的原因是 Go 中 sync.RWLock 是不可重入的。

完整的程式碼見concurrent_map.go

這種分段的方法雖然比單純的加互斥量好很多,因為 Segment 把鎖住的範圍進一步的減少了,但是這個範圍依舊比較大,還能再進一步的減少鎖麼?

還有一點就是併發量的設定,要合理,不能太大也不能太小。

2. Lock - Free 方案

在 Go 1.9 的版本中預設就實現了一種執行緒安全的 Map,摒棄了Segment(分段鎖)的概念,而是啟用了一種全新的方式實現,利用了 CAS 演算法,即 Lock - Free 方案。

採用 Lock - Free 方案以後,能比上一個分案,分段鎖更進一步縮小鎖的範圍。效能大大提升。

接下來就讓我們來看看如何用 CAS 實現一個執行緒安全的高效能 Map 。

官方是 sync.map 有如下的描述:

這個 Map 是執行緒安全的,讀取,插入,刪除也都保持著常數級的時間複雜度。多個 goroutines 協程同時呼叫 Map 方法也是執行緒安全的。該 Map 的零值是有效的,並且零值是一個空的 Map 。執行緒安全的 Map 在第一次使用之後,不允許被拷貝。

這裡解釋一下為何不能被拷貝。因為對結構體的複製不但會生成該值的副本,還會生成其中欄位的副本。如此一來,本應施加於此的併發執行緒安全保護也就失效了。

作為源值賦給別的變數,作為引數值傳入函式,作為結果值從函式返回,作為元素值通過通道傳遞等都會造成值的複製。正確的做法是用指向該型別的指標型別的變數。

Go 1.9 中 sync.map 的資料結構如下:



type Map struct {

    mu Mutex

    // 併發讀取 map 中一部分的內容是執行緒安全的,這是不需要
    // read 這部分自身讀取就是執行緒安全的,因為是原子性的。但是儲存的時候還是需要 Mutex
    // 儲存在 read 中的 entry 在併發讀取過程中是允許更新的,即使沒有 Mutex 訊號量,也是執行緒安全的。但是更新一個以前刪除的 entry 就需要把值拷貝到 dirty Map 中,並且必須要帶上 Mutex
    read atomic.Value // readOnly

    // dirty 中包含 map 中必須要互斥量 mu 保護才能執行緒安全的部分。為了使 dirty 能快速的轉化成 read map,dirty 中包含了 read map 中所有沒有被刪除的 entries
    // 已經刪除過的 entries 不儲存在 dirty map 中。在 clean map 中一個已經刪除的 entry 一定是沒有被刪除過的,並且當新值將要被儲存的時候,它們會被新增到 dirty map 中。
    // 當 dirty map 為 nil 的時候,下一次寫入的時候會通過 clean map 忽略掉舊的 entries 以後的淺拷貝副本來初始化 dirty map。
    dirty map[interface{}]*entry

    // misses 記錄了 read map 因為需要判斷 key 是否存在而鎖住了互斥量 mu 進行了 update 操作以後的載入次數。
    // 一旦 misses 值大到足夠去複製 dirty map 所需的花費的時候,那麼 dirty map 就被提升到未被修改狀態下的 read map,下次儲存就會建立一個新的 dirty map。
    misses int
}複製程式碼

在這個 Map 中,包含一個互斥量 mu,一個原子值 read,一個非執行緒安全的字典 map,這個字典的 key 是 interface{} 型別,value 是 *entry 型別。最後還有一個 int 型別的計數器。

先來說說原子值。atomic.Value 這個型別有兩個公開的指標方法,Load 和 Store 。Load 方法用於原子地的讀取原子值例項中儲存的值,它會返回一個 interface{} 型別的結果,並且不接受任何引數。Store 方法用於原子地在原子值例項中儲存一個值,它接受一個 interface{} 型別的引數而沒有任何結果。在未曾通過 Store 方法向原子值例項儲存值之前,它的 Load 方法總會返回 nil。

在這個執行緒安全的字典中,Load 和 Store 的都是一個 readOnly 的資料結構。


// readOnly 是一個不可變的結構體,原子性的儲存在 Map.read 中
type readOnly struct {
    m map[interface{}]*entry
    // 標誌 dirty map 中是否包含一些不在 m 中的 key 。
    amended bool // true if the dirty map contains some key not in m.
}複製程式碼

readOnly 中儲存了一個非執行緒安全的字典,這個字典和上面 dirty map 儲存的型別完全一致。key 是 interface{} 型別,value 是 *entry 型別。


// entry 是一個插槽,與 map 中特定的 key 相對應
type entry struct {
    p unsafe.Pointer // *interface{}
}複製程式碼

p 指標指向 *interface{} 型別,裡面儲存的是 entry 的地址。如果 p \=\= nil,代表 entry 被刪除了,並且 m.dirty \=\= nil。如果 p \=\= expunged,代表 entry 被刪除了,並且 m.dirty != nil ,那麼 entry 從 m.dirty 中丟失了。

除去以上兩種情況外,entry 都是有效的,並且被記錄在 m.read.m[key] 中,如果 m.dirty!= nil,entry 被儲存在 m.dirty[key] 中。

一個 entry 可以通過原子替換操作成 nil 來刪除它。當 m.dirty 在下一次被建立,entry 會被 expunged 指標原子性的替換為 nil,m.dirty[key] 不對應任何 value。只要 p != expunged,那麼一個 entry 就可以通過原子替換操作更新關聯的 value。如果 p \=\= expunged,那麼一個 entry 想要通過原子替換操作更新關聯的 value,只能在首次設定 m.dirty[key] = e 以後才能更新 value。這樣做是為了能在 dirty map 中查詢到它。

總結一下,sync.map 的資料結構如上。

再看看執行緒安全的 sync.map 的一些操作。


func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    // 如果 key 對應的 value 不存在,並且 dirty map 包含 read map 中沒有的 key,那麼開始讀取  dirty map 
    if !ok && read.amended {
        // dirty map 不是執行緒安全的,所以需要加上互斥鎖
        m.mu.Lock()
        // 當 m.dirty 被提升的時候,為了防止得到一個虛假的 miss ,所以此時我們加鎖。
        // 如果再次讀取相同的 key 不 miss,那麼這個 key 值就就不值得拷貝到 dirty map 中。
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // 無論 entry 是否存在,記錄這次 miss 。
            // 這個 key 將會緩慢的被取出,直到 dirty map 提升到 read map
            m.missLocked()
        }
        m.mu.Unlock()
    }
    if !ok {
        return nil, false
    }
    return e.load()
}複製程式碼

上述程式碼是 Load 操作。返回的是入參 key 對應的 value 值。如果 value 不存在就返回 nil。dirty map 中會儲存一些 read map 裡面不存在的 key,那麼就要讀取出 dirty map 裡面 key 對應的 value。注意讀取的時候需要加互斥鎖,因為 dirty map 是非執行緒安全的。


func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}複製程式碼

上面這段程式碼是記錄 misses 次數的。只有當 misses 個數大於 dirty map 的長度的時候,會把 dirty map 儲存到 read map 中。並且把 dirty 置空,misses 次數也清零。

在看 Store 操作之前,先說一個 expunged 變數。


// expunged 是一個指向任意型別的指標,用來標記從 dirty map 中刪除的 entry
var expunged = unsafe.Pointer(new(interface{}))複製程式碼

expunged 變數是一個指標,用來標記從 dirty map 中刪除的 entry。


func (m *Map) Store(key, value interface{}) {
    read, _ := m.read.Load().(readOnly)
    // 從 read map 中讀取 key 失敗或者取出的 entry 嘗試儲存 value 失敗,直接返回
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }

    m.mu.Lock()
    read, _ = m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok {
        // e 指向的是非 nil 的
        if e.unexpungeLocked() {
            // entry 先前被刪除了,這就意味著存在一個非空的 dirty map 裡面並沒有儲存這個 entry
            m.dirty[key] = e
        }
        // 使用 storeLocked 函式之前,必須保證 e 沒有被清除
        e.storeLocked(&value)
    } else if e, ok := m.dirty[key]; ok {
        // 已經儲存在 dirty map 中了,代表 e 沒有被清除
        e.storeLocked(&value)
    } else {
        if !read.amended {
            // 到這個 else 中就意味著,當前的 key 是第一次被加到 dirty map 中。
            // store 之前先判斷一下 dirty map 是否為空,如果為空,就把 read map 淺拷貝一次。
            m.dirtyLocked()
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        // 在 dirty 中儲存 value
        m.dirty[key] = newEntry(value)
    }
    m.mu.Unlock()
}複製程式碼

Store 優先從 read map 裡面去讀取 key ,然後儲存它的 value。如果 entry 是被標記為從 dirty map 中刪除過的,那麼還需要重新儲存回 dirty map中。

如果 read map 裡面沒有相應的 key,就去 dirty map 裡面去讀取。dirty map 就直接儲存對應的 value。

最後如何 read map 和 dirty map 都沒有這個 key 值,這就意味著該 key 是第一次被加入到 dirty map 中。在 dirty map 中儲存這個 key 以及對應的 value。


// 當 entry 沒有被刪除的情況下去儲存一個 value。
// 如果 entry 被刪除了,tryStore 方法返回 false,並且保留 entry 不變
func (e *entry) tryStore(i *interface{}) bool {
    p := atomic.LoadPointer(&e.p)
    if p == expunged {
        return false
    }
    for {
        if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
            return true
        }
        p = atomic.LoadPointer(&e.p)
        if p == expunged {
            return false
        }
    }
}複製程式碼

tryStore 函式的實現和 CAS 原理差不多,它會反覆的迴圈判斷 entry 是否被標記成了 expunged,如果 entry 經過 CAS 操作成功的替換成了 i,那麼就返回 true,反之如果被標記成了 expunged,就返回 false。



// unexpungeLocked 函式確保了 entry 沒有被標記成已被清除。
// 如果 entry 先前被清除過了,那麼在 mutex 解鎖之前,它一定要被加入到 dirty map 中
func (e *entry) unexpungeLocked() (wasExpunged bool) {
    return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}複製程式碼

如果 entry 的 unexpungeLocked 返回為 true,那麼就說明 entry 已經被標記成了 expunged,那麼它就會經過 CAS 操作把它置為 nil。

再來看看刪除操作的實現。


func (m *Map) Delete(key interface{}) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    if !ok && read.amended {
        // 由於 dirty map 是非執行緒安全的,所以操作前要加鎖
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            // 刪除 dirty map 中的 key
            delete(m.dirty, key)
        }
        m.mu.Unlock()
    }
    if ok {
        e.delete()
    }
}複製程式碼

delete 操作的實現比較簡單,如果 read map 中存在 key,就可以直接刪除,如果不存在 key 並且 dirty map 中有這個 key,那麼就要刪除 dirty map 中的這個 key。操作 dirty map 的時候記得先加上鎖進行保護。


func (e *entry) delete() (hadValue bool) {
    for {
        p := atomic.LoadPointer(&e.p)
        if p == nil || p == expunged {
            return false
        }
        if atomic.CompareAndSwapPointer(&e.p, p, nil) {
            return true
        }
    }
}複製程式碼

刪除 entry 具體的實現如上。這個操作裡面都是原子性操作。迴圈判斷 entry 是否為 nil 或者已經被標記成了 expunged,如果是這種情況就返回 false,代表刪除失敗。否則就 CAS 操作,將 entry 的 p 指標置為 nil,並返回 true,代表刪除成功。

至此,關於 Go 1.9 中自帶的執行緒安全的 sync.map 的實現就分析完了。官方的實現裡面基本沒有用到鎖,互斥量的 lock 也是基於 CAS的。read map 也是原子性的。所以比之前加鎖的實現版本效能有所提升。

究竟 Lock - Free 的效能有多強呢?接下來做一下效能測試。

五. 效能對比

效能測試主要針對3個方面,Insert,Get,Delete。測試物件主要針對簡單加互斥鎖的原生 Map ,分段加鎖的 Map,Lock - Free 的 Map 這三種進行效能測試。

效能測試的所有程式碼已經放在 github 了,地址在這裡,效能測試用的指令是:


go test -v -run=^$ -bench . -benchmem複製程式碼

1. 插入 Insert 效能測試


// 插入不存在的 key (粗糙的鎖)
func BenchmarkSingleInsertAbsentBuiltInMap(b *testing.B) {
    myMap = &MyMap{
        m: make(map[string]interface{}, 32),
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        myMap.BuiltinMapStore(strconv.Itoa(i), "value")
    }
}

// 插入不存在的 key (分段鎖)
func BenchmarkSingleInsertAbsent(b *testing.B) {
    m := New()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m.Set(strconv.Itoa(i), "value")
    }
}

// 插入不存在的 key (syncMap)
func BenchmarkSingleInsertAbsentSyncMap(b *testing.B) {
    syncMap := &sync.Map{}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        syncMap.Store(strconv.Itoa(i), "value")
    }
}複製程式碼

測試結果:


BenchmarkSingleInsertAbsentBuiltInMap-4          2000000           857 ns/op         170 B/op           1 allocs/op
BenchmarkSingleInsertAbsent-4                    2000000           651 ns/op         170 B/op           1 allocs/op
BenchmarkSingleInsertAbsentSyncMap-4             1000000          1094 ns/op         187 B/op           5 allocs/op複製程式碼

實驗結果是分段鎖的效能最高。這裡說明一下測試結果,-4代表測試用了4核 CPU ,2000000 代表迴圈次數,857 ns/op 代表的是平均每次執行花費的時間,170 B/op 代表的是每次執行堆上分配記憶體總數,allocs/op 代表的是每次執行堆上分配記憶體次數。

這樣看來,迴圈次數越多,花費時間越少,分配記憶體總數越小,分配記憶體次數越少,效能就越好。下面的效能圖表中去除掉了第一列迴圈次數,只花了剩下的3項,所以條形圖越短的效能越好。以下的每張條形圖的規則和測試結果代表的意義都和這裡一樣,下面就不再贅述了。


// 插入存在 key (粗糙鎖)
func BenchmarkSingleInsertPresentBuiltInMap(b *testing.B) {
    myMap = &MyMap{
        m: make(map[string]interface{}, 32),
    }
    myMap.BuiltinMapStore("key", "value")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        myMap.BuiltinMapStore("key", "value")
    }
}

// 插入存在 key (分段鎖)
func BenchmarkSingleInsertPresent(b *testing.B) {
    m := New()
    m.Set("key", "value")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m.Set("key", "value")
    }
}

// 插入存在 key (syncMap)
func BenchmarkSingleInsertPresentSyncMap(b *testing.B) {
    syncMap := &sync.Map{}
    syncMap.Store("key", "value")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        syncMap.Store("key", "value")
    }
}複製程式碼

測試結果:


BenchmarkSingleInsertPresentBuiltInMap-4        20000000            74.6 ns/op           0 B/op           0 allocs/op
BenchmarkSingleInsertPresent-4                  20000000            61.1 ns/op           0 B/op           0 allocs/op
BenchmarkSingleInsertPresentSyncMap-4           20000000           108 ns/op          16 B/op           1 allocs/op複製程式碼

從圖中可以看出,sync.map 在涉及到 Store 這一項的均比其他兩者的效能差。不管插入不存在的 Key 還是存在的 Key,分段鎖的效能均是目前最好的。

2. 讀取 Get 效能測試


// 讀取存在 key (粗糙鎖)
func BenchmarkSingleGetPresentBuiltInMap(b *testing.B) {
    myMap = &MyMap{
        m: make(map[string]interface{}, 32),
    }
    myMap.BuiltinMapStore("key", "value")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        myMap.BuiltinMapLookup("key")
    }
}

// 讀取存在 key (分段鎖)
func BenchmarkSingleGetPresent(b *testing.B) {
    m := New()
    m.Set("key", "value")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m.Get("key")
    }
}

// 讀取存在 key (syncMap)
func BenchmarkSingleGetPresentSyncMap(b *testing.B) {
    syncMap := &sync.Map{}
    syncMap.Store("key", "value")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        syncMap.Load("key")
    }
}複製程式碼

測試結果:


BenchmarkSingleGetPresentBuiltInMap-4           20000000            71.5 ns/op           0 B/op           0 allocs/op
BenchmarkSingleGetPresent-4                     30000000            42.3 ns/op           0 B/op           0 allocs/op
BenchmarkSingleGetPresentSyncMap-4              30000000            40.3 ns/op           0 B/op           0 allocs/op複製程式碼

從圖中可以看出,sync.map 在 Load 這一項的效能非常優秀,遠高於其他兩者。

3. 併發插入讀取混合效能測試

接下來的實現就涉及到了併發插入和讀取了。由於分段鎖實現的特殊性,分段個數會多多少少影響到效能,那麼接下來的實驗就會對分段鎖分1,16,32,256 這4段進行測試,分別看看效能變化如何,其他兩種執行緒安全的 Map 不變。

由於併發的程式碼太多了,這裡就不貼出來了,感興趣的同學可以看這裡

下面就直接放出測試結果:

併發插入不存在的 Key 值


BenchmarkMultiInsertDifferentBuiltInMap-4        1000000          2359 ns/op         330 B/op          11 allocs/op
BenchmarkMultiInsertDifferent_1_Shard-4          1000000          2039 ns/op         330 B/op          11 allocs/op
BenchmarkMultiInsertDifferent_16_Shard-4         1000000          1937 ns/op         330 B/op          11 allocs/op
BenchmarkMultiInsertDifferent_32_Shard-4         1000000          1944 ns/op         330 B/op          11 allocs/op
BenchmarkMultiInsertDifferent_256_Shard-4        1000000          1991 ns/op         331 B/op          11 allocs/op
BenchmarkMultiInsertDifferentSyncMap-4           1000000          3760 ns/op         635 B/op          33 allocs/op複製程式碼

從圖中可以看出,sync.map 在涉及到 Store 這一項的均比其他兩者的效能差。併發插入不存在的 Key,分段鎖劃分的 Segment 多少與效能沒有關係。

併發插入存在的 Key 值


BenchmarkMultiInsertSameBuiltInMap-4             1000000          1182 ns/op         160 B/op          10 allocs/op
BenchmarkMultiInsertSame-4                       1000000          1091 ns/op         160 B/op          10 allocs/op
BenchmarkMultiInsertSameSyncMap-4                1000000          1809 ns/op         480 B/op          30 allocs/op複製程式碼

從圖中可以看出,sync.map 在涉及到 Store 這一項的均比其他兩者的效能差。

併發的讀取存在的 Key 值


BenchmarkMultiGetSameBuiltInMap-4                2000000           767 ns/op           0 B/op           0 allocs/op
BenchmarkMultiGetSame-4                          3000000           481 ns/op           0 B/op           0 allocs/op
BenchmarkMultiGetSameSyncMap-4                   3000000           464 ns/op           0 B/op           0 allocs/op複製程式碼

從圖中可以看出,sync.map 在 Load 這一項的效能遠超多其他兩者。

併發插入讀取不存在的 Key 值


BenchmarkMultiGetSetDifferentBuiltInMap-4        1000000          3281 ns/op         337 B/op          12 allocs/op
BenchmarkMultiGetSetDifferent_1_Shard-4          1000000          3007 ns/op         338 B/op          12 allocs/op
BenchmarkMultiGetSetDifferent_16_Shard-4          500000          2662 ns/op         337 B/op          12 allocs/op
BenchmarkMultiGetSetDifferent_32_Shard-4         1000000          2732 ns/op         337 B/op          12 allocs/op
BenchmarkMultiGetSetDifferent_256_Shard-4        1000000          2788 ns/op         339 B/op          12 allocs/op
BenchmarkMultiGetSetDifferentSyncMap-4            300000          8990 ns/op        1104 B/op          34 allocs/op複製程式碼

從圖中可以看出,sync.map 在涉及到 Store 這一項的均比其他兩者的效能差。併發插入讀取不存在的 Key,分段鎖劃分的 Segment 多少與效能沒有關係。

併發插入讀取存在的 Key 值


BenchmarkMultiGetSetBlockBuiltInMap-4            1000000          2095 ns/op         160 B/op          10 allocs/op
BenchmarkMultiGetSetBlock_1_Shard-4              1000000          1712 ns/op         160 B/op          10 allocs/op
BenchmarkMultiGetSetBlock_16_Shard-4             1000000          1730 ns/op         160 B/op          10 allocs/op
BenchmarkMultiGetSetBlock_32_Shard-4             1000000          1645 ns/op         160 B/op          10 allocs/op
BenchmarkMultiGetSetBlock_256_Shard-4            1000000          1619 ns/op         160 B/op          10 allocs/op
BenchmarkMultiGetSetBlockSyncMap-4                500000          2660 ns/op         480 B/op          30 allocs/op複製程式碼

從圖中可以看出,sync.map 在涉及到 Store 這一項的均比其他兩者的效能差。併發插入讀取存在的 Key,分段鎖劃分的 Segment 越小,效能越好!

4. 刪除 Delete 效能測試


// 刪除存在 key (粗糙鎖)
func BenchmarkDeleteBuiltInMap(b *testing.B) {
    myMap = &MyMap{
        m: make(map[string]interface{}, 32),
    }
    b.RunParallel(func(pb *testing.PB) {
        r := rand.New(rand.NewSource(time.Now().Unix()))
        for pb.Next() {
            // The loop body is executed b.N times total across all goroutines.
            k := r.Intn(100000000)
            myMap.BuiltinMapDelete(strconv.Itoa(k))
        }
    })
}

// 刪除存在 key (分段鎖)
func BenchmarkDelete(b *testing.B) {
    m := New()
    b.RunParallel(func(pb *testing.PB) {
        r := rand.New(rand.NewSource(time.Now().Unix()))
        for pb.Next() {
            // The loop body is executed b.N times total across all goroutines.
            k := r.Intn(100000000)
            m.Remove(strconv.Itoa(k))
        }
    })
}

// 刪除存在 key (syncMap)
func BenchmarkDeleteSyncMap(b *testing.B) {
    syncMap := &sync.Map{}
    b.RunParallel(func(pb *testing.PB) {
        r := rand.New(rand.NewSource(time.Now().Unix()))
        for pb.Next() {
            // The loop body is executed b.N times total across all goroutines.
            k := r.Intn(100000000)
            syncMap.Delete(strconv.Itoa(k))
        }
    })
}複製程式碼

測試結果:


BenchmarkDeleteBuiltInMap-4                     10000000           130 ns/op           8 B/op           1 allocs/op
BenchmarkDelete-4                               20000000            76.7 ns/op           8 B/op           1 allocs/op
BenchmarkDeleteSyncMap-4                        30000000            45.4 ns/op           8 B/op           0 allocs/op複製程式碼

從圖中可以看出,sync.map 在 Delete 這一項是完美的超過其他兩者的。

六. 總結

本文從執行緒安全理論基礎開始講了執行緒安全中一些處理方法。其中涉及到互斥量和條件變數相關知識。從 Lock 的方案談到了 Lock - Free 的 CAS 相關方案。最後針對 Go 1.9 新加的 sync.map 進行了原始碼分析和效能測試。

採用了 Lock - Free 方案的 sync.map 測試結果並沒有想象中的那麼出色。除了 Load 和 Delete 這兩項遠遠甩開其他兩者,凡是涉及到 Store 相關操作的效能均低於其他兩者 Map 的實現。不過這也是有原因的。

縱觀 Java ConcurrentHashmap 一路的變化:

JDK 6,7 中的 ConcurrentHashmap 主要使用 Segment 來實現減小鎖粒度,把 HashMap 分割成若干個 Segment,在 put 的時候需要鎖住 Segment,get 時候不加鎖,使用 volatile 來保證可見性,當要統計全域性時(比如size),首先會嘗試多次計算 modcount 來確定,這幾次嘗試中,是否有其他執行緒進行了修改操作,如果沒有,則直接返回 size。如果有,則需要依次鎖住所有的 Segment 來計算。

JDK 7 中 ConcurrentHashmap 中,當長度過長碰撞會很頻繁,連結串列的增改刪查操作都會消耗很長的時間,影響效能,所以 JDK8 中完全重寫了concurrentHashmap,程式碼量從原來的1000多行變成了 6000多行,實現上也和原來的分段式儲存有很大的區別。

JDK 8 的 ConcurrentHashmap 主要設計上的變化有以下幾點:

  • 不採用 Segment 而採用 node,鎖住 node 來實現減小鎖粒度。
  • 設計了 MOVED 狀態 當 Resize 的中過程中執行緒2還在 put 資料,執行緒2會幫助 resize。
  • 使用3個 CAS 操作來確保 node 的一些操作的原子性,這種方式代替了鎖。
  • sizeCtl 的不同值來代表不同含義,起到了控制的作用。

可見 Go 1.9 一上來第一個版本就直接摒棄了 Segment 的做法,採取了 CAS 這種 Lock - Free 的方案提高效能。但是它並沒有對整個字典進行類似 Java 的 Node 的設計。但是整個 sync.map 在 ns/op ,B/op,allocs/op 這三個效能指標上是普通原生非執行緒安全 Map 的三倍!

不過相信 Google 應該還會繼續優化這部分吧,畢竟原始碼裡面還有幾處 TODO 呢,讓我們一起其他 Go 未來版本的發展吧,筆者也會一直持續關注的。

(在本篇文章截稿的時候,筆者又突然發現了一種分段鎖的 Map 實現,效能更高,它具有負載均衡等特點,應該是目前筆者見到的效能最好的 Go 語言實現的執行緒安全的 Map ,關於它的實現原始碼分析就只能放在下篇博文單獨寫一篇或者以後有空再分析啦)


Reference:
《Go 併發實戰程式設計》
Split-Ordered Lists: Lock-Free Extensible Hash Tables
Semaphores are Surprisingly Versatile
執行緒安全
JAVA CAS原理深度分析
Java ConcurrentHashMap 總結

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: halfrost.com/go_map_chap…

相關文章