ConcurrentDictionary字典操作竟然不全是線程安全的?
好久不見,馬甲哥封閉居家半個月,記錄之前遇到的一件小事。
ConcurrentDictionary<TKey,TValue>絕大部分api都是線程安全的[1],唯二的例外是接收工廠函數(shù)的api:?AddOrUpdate、GetOrAdd,這兩個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);(注意,包括其他接收工廠委托的重載函數(shù))
整個過程中涉及與字典直接交互的都用到到精細(xì)鎖,valueFactory工廠函數(shù)在鎖定區(qū)外面被執(zhí)行,因此,這些代碼不受原子性約束。
Q1: valueFactory工廠函數(shù)不在鎖定范圍,為什么不在鎖范圍?
A: 還不是因為微軟不相信你能寫出健壯的業(yè)務(wù)代碼,未知的業(yè)務(wù)代碼可能造成死鎖。
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工廠函數(shù)可能會多次執(zhí)行
- 雖然會多次執(zhí)行, 但插入的值固定是一個,插入的值取決于哪個線程率先插入字典。
Q3: 怎么做到的隨機穩(wěn)定輸出一列值?A:源代碼做了double check[2]了,后續(xù)線程通過工廠類創(chuàng)建值后,會再次檢查字典,發(fā)現(xiàn)已有值,會丟棄自己創(chuàng)建的值。
示例代碼:
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個線程并發(fā)插入字典,每次隨機輸出,_runCount=4顯示工廠類執(zhí)行4次。
Q4:如果工廠產(chǎn)值的代價很大,不允許多次創(chuàng)建,如何實現(xiàn)?
筆者的同事之前就遇到這樣的問題,高并發(fā)請求頻繁創(chuàng)建redis連接,直接打掛了機器。
A: 有一個trick能解決這個問題: valueFactory工廠函數(shù)返回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);
}
}
上面示例,依舊會隨機穩(wěn)定輸出,但是_runOut=1表明產(chǎn)值動作只執(zhí)行了一次、
valueFactory工廠函數(shù)返回Lazy容器是一個精妙的trick。
① 工廠函數(shù)依舊沒進入鎖定過程,會多次執(zhí)行;
② 與最上面的例子類似,只會插入一個Lazy容器(后續(xù)線程依舊做double check發(fā)現(xiàn)字典key已經(jīng)有Lazy容器了,會放棄插入);
③ 線程執(zhí)行Lazy.Value, 這時才會執(zhí)行創(chuàng)建value的工廠函數(shù);
④ 多個線程嘗試執(zhí)行Lazy.Value, 但這個延遲初始化方式被默認(rèn)設(shè)置為ExecutionAndPublication:不僅以線程安全的方式執(zhí)行, 而且確保只會執(zhí)行一次構(gòu)造函數(shù)。
public Lazy(Func<T> valueFactory)
:this(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication, useDefaultConstructor: false)
{
}
控制構(gòu)造函數(shù)執(zhí)行的枚舉值 | 描述 |
ExecutionAndPublication[3] | 能確保只有一個線程能夠以線程安全方式執(zhí)行構(gòu)造函數(shù) |
None | 線程不安全 |
Publication | 并發(fā)線程都會執(zhí)行初始化函數(shù),以先完成初始化的值為準(zhǔn) |
IHttpClientFactory在構(gòu)建<命名HttpClient,活躍連接Handler>字典時, 也用到了這個技巧,大家自行欣賞DefaultHttpCLientFactory源碼[4]。
- https://andrewlock.net/making-getoradd-on-concurrentdictionary-thread-safe-using-lazy/
總結(jié)
為解決ConcurrentDictionary GetOrAdd(key, valueFactory) 工廠函數(shù)在并發(fā)場景下被多次執(zhí)行的問題:
① valueFactory工廠函數(shù)產(chǎn)生Lazy容器;
② 將Lazy容器的值初始化姿勢設(shè)定為ExecutionAndPublication(線程安全且執(zhí)行一次)。
兩姿勢缺一不可。