前言
最近,看到一篇文章,講到《ConcurrentDictionary字典操作竟然不全是線程安全的?》。
首先,這個結論是正確的,但文中給出的一個證明例子,我覺得是有問題的。
相關代碼如下:
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);}
}
那這個例子是不是能夠說明 ConcurrentDictionary 字典操作不是線程安全的呢?
首先,讓我們看看什么是“線程安全”。
線程安全
線程安全:當多個線程同時訪問時,保證實現沒有爭用條件。
這里的“爭用條件”又是什么呢?下面舉個例子來說明。
假設兩個線程各自將全局整數變量的值遞增 1。理想情況下,將發生以下操作序列:
線程 1 | 線程 2 | 整數值 | |
---|---|---|---|
0 | |||
讀取值 | ← | 0 | |
增加值 | 0 | ||
回寫 | → | 1 | |
讀取值 | ← | 1 | |
增加值 | 1 | ||
回寫 | → | 2 |
在上面顯示的情況下,最終值為 2,如預期的那樣。但是,如果兩個線程在沒有鎖定或同步的情況下同時運行,則操作的結果可能是錯誤的。下面的替代操作序列演示了此方案:
線程 1 | 線程 2 | 整數值 | |
---|---|---|---|
0 | |||
讀取值 | ← | 0 | |
讀取值 | ← | 0 | |
增加值 | 0 | ||
增加值 | 0 | ||
回寫 | → | 1 | |
回寫 | → | 1 |
在這種情況下,最終值為 1,而不是預期的結果 2。發生這種情況是因為此處的增量操作不是互斥的。互斥操作是在訪問某些資源(如內存位置)時無法中斷的操作。
如果用那篇文章的例子,演示是否線程安全的代碼應該是這樣的:
using?System.Collections.Concurrent;public?class?Program
{private?static?int?_runCount?=?0;private?static?int?_notsafeCount?=?0;public?static?void?Main(string[]?args){var?tasks?=?new?Task[100];for?(int?i?=?0;?i?<?tasks.Length;?i++){tasks[i]?=?Task.Run(()?=>?PrintValue($"The?{i}?value"));}Task.WaitAll(tasks);Console.WriteLine($"Run?count:?{_runCount}");Console.WriteLine($"Not?Safe?Count:?{_notsafeCount}");}public?static?void?PrintValue(string?valueToPrint){Interlocked.Increment(ref?_runCount);_notsafeCount++;Thread.Sleep(100);}
}
我們把 Task 數量加大到 100,便于查看效果。
執行 3 次,_runCount 始終等于 100,因為Interlocked是線程安全的,而 _notsafeCount 的值卻是隨機的,說明 PrintValue 方法不是線程安全的。
GetOrAdd
讓我們再把 PrintValue 方法改成使用 GetOrAdd:
public?static?void?PrintValue(string?valueToPrint)
{var?valueFound?=?_dictionary.GetOrAdd("key",x?=>{Interlocked.Increment(ref?_runCount);_notsafeCount++;Thread.Sleep(100);return?valueToPrint;});Console.WriteLine(valueFound);
}
再執行 3 次,我們發現,_notsafeCount 的值始終和 _runCount 的值相同,貌似沒出現線程爭用。
大家看到這是不是有點懵逼,這不反而證明了,
ConcurrentDictionary字典操作是線程安全的!
真是這樣嗎?
這也正是我認為原文的例子不太恰當的原因:它只證明了有多個線程進入,而沒證明出現了線程爭用,無法得到線程不安全的結論。
從上面線程不安全的例子我們看到,一共 100 個 Task 執行而?_notsafeCount 的值都是 90 多,這說明線程爭用很難被觸發。而上面的操作只執行了 8 次,也許是還沒觸發線程爭用呢?
我們修改代碼,每進入 1 次 valueFactory 就執行 10 次 _notsafeCount++:
public?static?void?PrintValue(string?valueToPrint)
{var?valueFound?=?_dictionary.GetOrAdd("key",x?=>{Interlocked.Increment(ref?_runCount);for?(int?i?=?0;?i?<?10;?i++){_notsafeCount++;Thread.Sleep(100);}return?valueToPrint;});Console.WriteLine(valueFound);
}
理論上,_notsafeCount 應該等于 90(9*10),而實際上輸出 88,這說明出現了線程爭用。
也就是說,ConcurrentDictionary 的 GetOrAdd(TKey key, Func<TKey, TValue> valueFactory) 方法不是線程安全的。
這個結論從?GetOrAdd 方法的源碼也可以得到驗證,執行 valueFactory(key)?時是沒加鎖的:
public?TValue?GetOrAdd(TKey?key,?Func<TKey,?TValue>?valueFactory)
{if?(key?is?null){ThrowHelper.ThrowKeyNullException();}if?(valueFactory?is?null){ThrowHelper.ThrowArgumentNullException(nameof(valueFactory));}IEqualityComparer<TKey>??comparer?=?_comparer;int?hashcode?=?comparer?is?null???key.GetHashCode()?:?comparer.GetHashCode(key);if?(!TryGetValueInternal(key,?hashcode,?out?TValue??resultingValue)){TryAddInternal(key,?hashcode,?valueFactory(key),?updateIfExists:?false,?acquireLock:?true,?out?resultingValue);}return?resultingValue;
}
總結
如果你想驗證某個方法是否線程安全,都可以用上面這種觸發線程爭用方式。
還不趕緊試試?!?
添加微信號【MyIO666】,邀你加入技術交流群