多執行緒併發程式設計“鎖”事

Andy-T發表於2020-09-29


前言

多執行緒技術是提高系統併發能力的重要技術,在應用多執行緒技術時需要注意很多問題,如執行緒退出問題、CPU及記憶體資源利用問題、執行緒安全問題等,本文主要講執行緒安全問題及如何使用“鎖”來解決執行緒安全問題。


一、相關概念

在瞭解鎖之前,首先闡述一下執行緒安全問題涉及到的相關概念:

1.執行緒安全

如果你的程式碼所在的程式中有多個執行緒在同時執行,而這些執行緒可能會同時執行這段程式碼。如果每次執行結果和單執行緒執行的結果是一樣的,而且其他變數的值也和預期的是一樣的,則是執行緒安全的。執行緒安全問題是由共享資源引起的,可以是一個全域性變數、一個檔案、一個資料庫表中的某條資料,當多個執行緒同時訪問這類資源的時候,就可能存線上程安全問題。

2.臨界資源

臨界資源是一次僅允許一個程式(執行緒)使用的共享資源,當其他程式(執行緒)訪問該共享資源時需要等待。

3.臨界區

臨界區是指一個訪問共享資源的程式碼段。

4.執行緒同步

為了解決執行緒安全問題,通常採用“序列化訪問臨界資源”的方案,或者叫“序列化訪問臨界資源”,即在同一時刻,保證只能有一個執行緒訪問臨界資源,也稱執行緒同步互斥訪問。

5.鎖

鎖是實現執行緒同步的重要手段,它將包圍的程式碼語句塊標記為臨界區,這樣一次只有一個執行緒進入臨界區執行程式碼。

二、同一個程式內多執行緒併發鎖

1.Lock

對於單程式內的多執行緒併發場景,我們可以使用語言和類庫提供的鎖,以下以C#鎖為例說明鎖是如何做到執行緒安全的。先來看一段示例程式碼。CountService為計數服務類,提供了一個引數的構造方法,引數為是否加鎖,預設為不加。

  public class CountService
  {
        private int count;
        private readonly object lockObj;
        private readonly bool withLock = true;

        public CountService(bool withLock = false)
        {
            count = 0;
            this.withLock = withLock;
            lockObj = new object();
        }

        public void Increment()
        {
            if (withLock)
            {
                lock (lockObj)
                {
                    count++;
                }
            }
            else
                count++;
        }

        public int GetCountValue()
        {
            return count;
        }
  }

然後模擬多執行緒呼叫,程式碼如下:

class Program
{
     static void Main(string[] args)
     {
         for (int i = 0; i < 10; i++)
         {
             var taskList = new List<Task>();
             CountService service = new CountService(false);
        
             for (int j = 0; j < 1000; j++)
             {
                 taskList.Add(
                     Task.Run(() =>
                     {
                         service.Increment();
                     })
                 );
             }
             Task.WaitAll(taskList.ToArray());

             Console.WriteLine(service.GetCountValue());
         }
         Console.Read();
    }
}

如果按照單執行緒執行,預期的結果會在控制檯輸出10個1000,但真實的結果卻是如下圖所示,並且可能每次輸出的結果都不一致。
在這裡插入圖片描述
如果在計數服務例項化時,引數改為true,則可以得到預期的結果,所以加鎖可以保證計數服務物件是執行緒安全的。C#中lock 語句獲取給定物件的互斥鎖(也可以叫作排它鎖),執行語句塊,然後釋放鎖。 持有鎖時,持有鎖的執行緒可以再次獲取並釋放鎖。 它可以阻止任何其他執行緒獲取鎖並等待釋放鎖。lock是一個語法糖,它的內部實現使用的是Monitor,相當於如下程式碼。

bool isGetLock = false;
    //lockObj 是私有靜態變數
Monitor.Enter(lockObj, ref isGetLock);
    try
    {
        do something…
    }
    finally
    {
        if(isGetLock == true)
            Monitor.Exit(lockObj);
}

2.原理

那Monitor.Enter和Monitor.Exit 究竟是怎麼工作的呢?CRL初始化時在堆中分配一個同步塊陣列,每當一個物件在堆中建立的時候,都有兩個額外的開銷欄位與它關聯。第一個是“型別物件指標”,值為型別的“型別物件”的記憶體地址。第二個是“同步塊索引”,值為同步塊資料組中的一個整數索引。一個物件在構造時,它的同步塊索引初始化為-1,表明不引用任何同步塊。然後,呼叫Monitor.Enter時,CLR在同步塊陣列中找到一個空白同步塊,並設定物件的同步塊索引,讓它引用該同步塊。呼叫Exit時,會檢查是否有其他任何執行緒正在等待使用物件的同步塊。如果沒有執行緒在等待它,同步塊就自由了,會將物件的同步塊索引設回-1,自由的同步塊將來可以和另一個物件關聯。下圖反映的就是物件與同步塊的關聯關係。

在這裡插入圖片描述

3.建議

  1. .NET提供了可以跨程式使用的鎖,如Mutex、Semaphore等。 Mutex、Semaphore需要先把託管程式碼轉成本地使用者模式程式碼、再轉換成本地核心程式碼。當釋放後需要重新轉換成託管程式碼,效能會有一定的損耗,所以儘量在需要跨程式的場景使用。我們的實際開發中這種場景不多,本文不再詳細介紹。可參考微軟官方文件:https://docs.microsoft.com/zh-cn/dotnet/standard/threading/threading-objects-and-features
  2. .NET提供了執行緒安全的集合,這些集合在內部實現了執行緒同步,我們可以直接使用。
  3. 對於簡單的狀態更改,如遞增、遞減、求和、賦值等,微軟官方建議使用 Interlocked 類的方法,而不是 lock 語句。雖然 lock 語句是實用的通用工具,但 Interlocked 類提升了更新(必須是原子操作)的效能。如可以實現以下程式碼的替代。
    在這裡插入圖片描述
    在這裡插入圖片描述

4.注意事項

  1. 避免鎖定可以被公共訪問的物件 lock(this)、lock(typeof(ClassName)) 、lock(public static variable) 、lock(public const variable),都存在可能被其他程式碼鎖定的情況,這樣會阻塞你自己的程式碼。
  2. 禁止鎖定字串 在編譯階段如果兩個變數的字串內容相同的話,CLR會將字串放在(Intern Pool)駐留池(暫存池)中,以此來保證相同內容的字串引用的地址是相同的。所以如果有兩個地方都在使用lock(“myLock”)的話,它們實際鎖住的是同一個物件。
  3. 禁止鎖定值型別的物件 Monitor的方法引數為object型別,所以傳遞值型別會導致值型別被裝箱,造成執行緒在已裝箱物件上獲取鎖。每次呼叫Moitor.Enter都會在一個完全不同的物件上獲取鎖,所以完全無法實現執行緒同步。
  4. 避免死鎖 如果兩個執行緒中的每個執行緒都嘗試鎖定另一個執行緒已鎖定的資源,則會發生死鎖。我們應該保證每塊程式碼鎖定物件的順序一致。儘量避免鎖定可被公共訪問的物件,因為私有物件只有我們自己用,我們可以保證鎖的正確使用。我們還可以利用Monitor.TryEnter來檢測死鎖,該方法支援設定獲取鎖的超時時間,比如,Monitor.TryEnter(lockObject,300),如果在300毫秒內沒有獲取鎖,該方法返回false。

三、分散式叢集下的多執行緒併發鎖

C#中,lock(Monitor)、Mutex、Semaphore只適用於單機環境,解決不了分散式叢集環境中,各節點多執行緒併發的執行緒安全問題。對於分散式場景,我們可以使用分散式鎖。常用的分散式鎖有:

Memcached分散式鎖

Memcached的add命令是原子性操作,只有在key不存在的情況下,才能add成功,並返回STORED,也就意味著執行緒得到了鎖,如果key存在,返回NOT_STORED ,則說明有其他執行緒已經拿到鎖。

Zookeeper分散式鎖

把ZooKeeper上的一個節點看作是一個鎖,獲得鎖就通過建立臨時節點的方式來實現。 ZooKeeper 會保證在所有客戶端中,最終只有一個客戶端能夠建立成功,那麼就可以認為該客戶端獲得了鎖。同時,所有沒有獲取到鎖的客戶端就需要到/exclusive_lock 節點上註冊一個子節點變更的Watcher監聽,以便實時監聽到lock節點的變更情況。等拿到鎖的客戶端執行完業務邏輯後,客戶端就會主動將自己建立的臨時節點刪除,釋放鎖,然後ZooKeeper 會通知所有在 /exclusive_lock 節點上註冊了節點變更 Watcher 監聽的客戶端。這些客戶端在接收到通知後,再次重新發起分散式鎖獲取請求。

Redis分散式鎖

和Memcached的方式類似,利用Redis的set命令。此命令同樣是原子性操作,只有在key不存在的情況下,才能set成功。當一個執行緒執行set返回OK,說明key原本不存在,該執行緒成功得到了鎖;當一個執行緒執行set返回-1,說明key已經存在,該執行緒搶鎖失敗。

主要講一下Redis分散式鎖及常見問題

Redis加鎖的虛擬碼:

if(set(key,value,30,NX) == “OK”)
{
    try
     {
         do something...
     }
     finally
     {
         del(key)
     }
}
  • key是鎖的唯一標識,一般是按業務來決定命名。比如要給使用者註冊程式碼加鎖,可以給key命名為 “lock_user_regist_使用者手機號”。
  • 30為鎖的超時時間,單位為秒,如果不設定超時時間,一但得到鎖的執行緒在執行任務的過程中掛掉,來不及顯式地釋放鎖,這塊資源將會永遠被鎖住,別的執行緒就再也進不來了。設定了超時時間,即使因不可控因素導致了沒有顯式的釋放鎖,最多也就只鎖定這些時間便可自動恢復。但是指定了超時時間,還會引出其他問題,後邊會講。
  • NX代表只在鍵不存在時,才對鍵進行設定操作,並返回OK。
  • 當業務處理完畢,finally中執行redis del指令將鎖刪除。

刪除鎖時可能出現一種異常的場景,比如執行緒A成功得到了鎖,並且設定的超時時間是30秒。因某些原因導致執行緒A執行了很長時間(過了30秒都沒執行完),這時候鎖過期自動釋放,執行緒B得到了鎖。隨後,執行緒A執行完了任務,執行緒A接著執行del指令來釋放鎖。但這時候執行緒B還沒執行完,執行緒A實際上刪除的是執行緒B加的鎖。如何避免這種情況呢?可以在del釋放鎖之前做一個判斷,驗證當前的鎖是不是自己加的鎖。至於具體的實現,可以在加鎖的時候生成一個隨機數,儘可能的不重複,可以用Guid生成一個隨機字串做value,並在刪除之前驗證key對應的value是不是當前執行緒生成的Guid字串。

加鎖虛擬碼:

string value = Guid.NewGuid().ToString()set(key,value30,NX);

解鎖虛擬碼:

if(value.Equals(redisClient.get(key)){
	del(key);
}

但這又引出了一個新的問題,判斷及解鎖是兩個獨立的指令,不是原子性操作,這就得需要藉助Lua指令碼實現。將解鎖的程式碼封裝為Lua指令碼,在需要解鎖的時候,傳送執行指令碼的指令。

應用上邊講到的方法,儘管我們避免了執行緒A誤刪除掉鎖的情況,但是同一時間有A、B兩個執行緒在訪問程式碼,這本身就不是執行緒安全的。如何保證執行緒安全呢?產生該現象的原因就在於我們給鎖指定了超時時間,不是說超時時間加的不對,而是我們應該想辦法能給鎖“續命”,即當過去29秒了,執行緒A還沒執行完,我們要有一種機制可以定時重置一下鎖的超時時間。思路大概為讓獲得鎖的執行緒開啟一個守護執行緒,用來重置快要過期的鎖的超時時間,如果超時時間設定為30秒,守護執行緒可以從第29秒開始,每25秒執行一次expire指令,當執行緒A執行完成後,顯式關掉守護執行緒。

還有一些程式設計師可能會出現以下寫法,不管if條件有沒有成立,finally都會執行刪除鎖的命令,即使鎖沒有過期也會出現執行緒鎖被誤刪除的情況,大家一定要注意。當然如果你已經應用上邊講的改進方案,避免了鎖被其他執行緒誤刪,但是這個也是得不償失的,沒有獲取到鎖的執行緒沒有必要去執行刪除鎖的命令。

錯誤的Redis加鎖虛擬碼:

try
{
	if(set(key,value,30,NX) == “OK”)
    {
       do something...
    }
}
finally
{
    del(key)
}

總結

本文對多執行緒併發環境中,保證執行緒安全的“鎖”方案進行了儘可能詳細的講解,平時我們在設計高效能、低延遲開發方案時,務必要考慮因併發訪問導致的資料安全性問題。

相關文章