ConcurrentDictionary 的這兩個操作不是原子性的

好久不見,馬甲哥封閉居家半個月,記錄之前遇到的一件小事。

ConcurrentDictionary<TKey,TValue>絕大部分api都是線程安全且原子性的
唯二的例外是接收工廠委託的api:AddOrUpdateGetOrAdd這兩個api不是原子性的,需要引起重視。

All these operations are atomic and are thread-safe with regards to all other operations on the ConcurrentDictionary<TKey,TValue> class. The only exceptions are the methods that accept a delegate, that is, AddOrUpdate and GetOrAdd.

之前有個同事就因為這個case背了一個P。

AddOrUpdate(TKey, TValue, Func<TKey,TValue,TValue> valueFactory);
GetOrAdd(TKey key, Func<TKey, TValue> valueFactory);
(注意,包括其他接收工廠委託的重載函數)

Q1: valueFactory工廠函數不在鎖定範圍,為什麼不在鎖範圍?

A: 還不是因為微軟不相信你能寫出健壯的業務代碼,未知的業務代碼可能造成死鎖。

However, delegates for these methods are called outside the locks to avoid the problems that can arise from executing unknown code under a lock. Therefore, the code executed by these delegates is not subject to the atomicity of the operation.

Q2:帶來的效果?

  • valueFactory工廠函數可能會多次執行
  • 雖然會多次執行, 但插入的值永遠是一個,插入的值取決於哪個線程率先插入字典。

Q3: 怎麼做到的?
A: 源代碼做了double check了,後續線程通過工廠類創建值後,會再次檢查字典,發現已有值,會丟棄自己創建的值。

示例代碼:

using System.Collections.Concurrent;

public class Program
{
   private static int _runCount = 0;
   private static readonly ConcurrentDictionary<string, string> _dictionary
       = new ConcurrentDictionary<string, string>();

   public static void Main(string[] args)
   {
       var task1 = Task.Run(() => PrintValue("The first value"));
       var task2 = Task.Run(() => PrintValue("The second value"));
       var task3 = Task.Run(() => PrintValue("The three value"));
       var task4 = Task.Run(() => PrintValue("The four value"));
       Task.WaitAll(task1, task2, task4,task4);
       
       PrintValue("The five value");
       Console.WriteLine($"Run count: {_runCount}");
   }

   public static void PrintValue(string valueToPrint)
   {
       var valueFound = _dictionary.GetOrAdd("key",
                   x =>
                   {
                       Interlocked.Increment(ref _runCount);
                       Thread.Sleep(100);
                       return valueToPrint;
                   });
       Console.WriteLine(valueFound);
   }
}

上面4個線程並發插入字典,每次隨機輸出,_runCount=4顯示工廠類執行4次。

Q4:如果工廠產值的代價很大,不允許多次創建,如何實現?

筆者的同事之前就遇到這樣的問題,高並發請求頻繁創建redis連接,直接打掛了機器。

A: 有一個trick能解決這個問題: valueFactory工廠函數返回Lazy容器.

using System.Collections.Concurrent;

public class Program
{
   private static int _runCount2 = 0;
   private static readonly ConcurrentDictionary<string, Lazy<string>> _lazyDictionary
      = new ConcurrentDictionary<string, Lazy<string>>();

   public static void Main(string[] args)
   {
       task1 = Task.Run(() => PrintValueLazy("The first value"));
       task2 = Task.Run(() => PrintValueLazy("The second value"));
       task3 = Task.Run(() => PrintValueLazy("The three value"));
       task4 = Task.Run(() => PrintValueLazy("The four value"));    
       Task.WaitAll(task1, task2, task4, task4);

       PrintValue("The five value");
       Console.WriteLine($"Run count: {_runCount2}");
   }

   public static void PrintValueLazy(string valueToPrint)
   {
       var valueFound = _lazyDictionary.GetOrAdd("key",
                   x => new Lazy<string>(
                       () =>
                       {
                           Interlocked.Increment(ref _runCount2);
                           Thread.Sleep(100);
                           return valueToPrint;
                       }));
       Console.WriteLine(valueFound.Value);
   }
}


上面示例,依舊會穩定隨機輸出,但是_runOut=1表明產值動作只執行了一次、

valueFactory工廠函數返回Lazy容器是一個精妙的trick。

① 工廠函數依舊沒進入鎖定過程,會多次執行;

② 與最上面的例子類似,只會插入一個Lazy容器(後續線程依舊做double check發現字典key已經有Lazy容器了,會放棄插入);

③ 線程執行Lazy.Value, 這時才會執行創建value的工廠函數;

④ 多個線程嘗試執行Lazy.Value, 但這個延遲初始化方式被設置為ExecutionAndPublication
不僅以線程安全的方式執行, 而且確保只會執行一次構造函數。

public Lazy(Func<T> valueFactory)
  :this(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication, useDefaultConstructor: false)
{
}
控制構造函數執行的枚舉值 描述
ExecutionAndPublication 能確保只有一個線程能夠以線程安全方式執行構造函數
None 線程不安全
Publication 並發線程都會執行初始化函數,以先完成初始化的值為準

IHttpClientFactory在構建<命名HttpClient,活躍連接Handler>字典時, 也用到了這個技巧,大家自行欣賞DefaultHttpCLientFactory源碼


總結

為解決ConcurrentDictionary GetOrAdd(key, valueFactory) 工廠函數在並發場景下被多次執行的問題。
① valueFactory工廠函數產生Lazy容器
② 將Lazy容器的值初始化姿勢設定為ExecutionAndPublication(線程安全且執行一次)。

兩姿勢缺一不可。

Tags: