小敏,你有看過Dictionary<string, string>的Key塞null值嗎?
薑! 薑! 薑! 薑~~
依照MSDN文件的說明,Dictionary<TKey, TValue>的Key值不接受null值(A key cannot be null),如果你想硬幹dict.Add(null, "BlahBlah"),.NET將賞你一個"System.ArgumentNullException: 值不能為 null。"
那麼,上圖的狀況是怎麼搞出來的呢?
進入多執行緒的世界,一些原本再簡單也不過的程式小片段,就像沾到火種源,頓時變身狂派機器人跑來咬你屁股 orz 原本執行好好的程式,移到多執行緒環境,開始隨機冒出稀奇古怪的錯誤,而且只在多人使用或壓測時才出現,難以預測及重現,很難追蹤除錯。(例如: 要上百個Client同時使用才出現的問題,除了加入大量Log記錄執行痕跡配合壓測抓蟲,別無捷徑)
關鍵在Thread-Safty! 當程式被單緒執行時,邏輯單純較易想像,因為永遠只有一段程式在執行,物件的狀況改變完全操之在我;一旦轉換到多執行緒場景,就得擔心這行才存取的資料,會不會下一行就變了? A方法使用到B變數,但同時可能有其他Thread執行C方法也去更改B變數,彼此會不會打架? 就像這樣,撰寫每一段Code都得考慮涉及的每項資源、物件同一時間是否可能被其他Thread所存取變更,只要有一處沒設想保護妥當,就等同埋下一顆不定時炸彈,等待未來某一天機緣成熟爆炸... 唯有一切考慮周到,有信心程式不會因多個Thread同時執行出錯,才能宣告程式是Thread-Safe!
不共用任何變數、資源是確保Thread-Safe最簡單的方式,但並非所有情境都能實現,只能靠在變數、物件加上保護並謹慎使用才能保證在多執行緒環境下不出錯。
先來看前面讓Dictionary<string, string>錯亂的程式範例:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace IEnumThreadSafty
{
class Test1
{
static Dictionary<string, string> dict =
new Dictionary<string, string>();
publicstaticvoid Run()
{
bool running = true;
Random rnd = new Random();
Task.Factory.StartNew(() =>
{
while (running)
{
try
{
dict.Add(Guid.NewGuid().ToString(), "A");
}
catch (Exception ex)
{
Console.WriteLine("Error: " + ex.ToString());
}
Thread.Sleep(rnd.Next(5));
}
});
Task.Factory.StartNew(() =>
{
while (running)
{
Console.WriteLine("Dictionary Count={0}", dict.Count);
Thread.Sleep(1000);
}
});
Thread.Sleep(1000);
Task.Factory.StartNew(() =>
{
while (running)
{
if (dict.Count > 0)
{
string key = null;
try
{
key = dict.Keys.First();
dict.Remove(key);
}
catch (Exception ex)
{
Console.WriteLine("Error: " + ex.ToString());
}
}
Thread.Sleep(rnd.Next(4));
}
});
Console.ReadLine();
running = false;
}
}
}
很簡單,只要開兩條Thread,一條不斷的Add,一條不斷的Remove(用First()取第一筆移除),就能見識許多古怪離奇的錯誤,例如:
- Add()時抱怨"索引在陣列的界限之外"
Error: System.IndexOutOfRangeException: Index was outside the bounds of the array. (索引在陣列的界限之外。)
at System.Collections.Generic.Dictionary`2.Insert(TKey key, TValue value, Boo
lean add) - First()時遇到"集合已修改; 列舉作業可能尚未執行"
(First所遇到的問題,Where、ToList、Select等LINQ操作都可能遇到)
Error: System.InvalidOperationException: Collection was modified; enumeration op
eration may not execute. (集合已修改; 列舉作業可能尚未執行。)
at System.Collections.Generic.Dictionary`2.KeyCollection.Enumerator.MoveNext()
at System.Linq.Enumerable.First[TSource](IEnumerable`1 source) - 明明Count>0才執行,First()卻冒出"序列未包含項目"
Error: System.InvalidOperationException: Sequence contains no elements. (序列未包含項目)
at System.Linq.Enumerable.First[TSource](IEnumerable`1 source)
這些只是冰山一角,實際玩一回,還能看到更多離奇狀況。看似簡單的程式碼忽然處處是雷,讓人疑神疑鬼,且需壓測模擬加上一些運氣才能重現,偵錯難度極高,除了在執行路徑加上Debug Log,事後再從數千行Log裡拼湊推敲,還原出錯情境,似乎沒有更好的方法。即便是老練的程式設計師,也常會深陷多時方能脫身,稱之"多執行緒地獄"也不為過。
所幸,比起尋找爆炸點,修正工作簡單許多。只要能重現問題找到出錯根源(通常是某個被共用的物件出現離奇錯誤),用lock機制限定同一時間只能有一個Thread存取(讀與寫都要加入保護),多半就能藥到病除。(但要注意: lock範圍的程式執行時間不可太長,否則輕則阻擋其他Thread執行傷及效能,重則增加Deadlock的發生機率)
以先前的程式為例,只需在Add、First、Remove處加上lock,程式便不致出錯!
//...省略...
lock (dict)
{
dict.Add(Guid.NewGuid().ToString(), "A");
}
//...省略...
lock (dict)
{
if (dict.Count > 0)
{
string key = dict.Keys.First();
dict.Remove(key);
}
}
雖然加上lock能有效防止多Thread存取衝突,但缺點是"所有可能發生衝突的讀寫動作都要加lock",只要有一 處漏寫照樣會出包。(跟SQL Injection一樣,只要一個漏洞就滿盤皆輸)
如果lock要保護的對象是Dictionary<T, T>或List<T>,.NET 4.0開始有更方便的選擇: ConcurrentDictionary<T, T>及ConcurrentBag<T>(除此之外System.Collections.Concurrent命名空間還有支援多執行緒存取的Queue、Stack等類別,適用不同場合),內建lock保護機制,能在多執行緒環境執行不出錯,等同Dictionary<T, T>及List<T>的Thread-Safe版本。
於是,上面的程式可用ConcurrentDictionary改寫如下: (註: 需以TryAdd及TryRemove取代Add, Remove)
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace IEnumThreadSafty
{
class Test3
{
static ConcurrentDictionary<string, string> dict =
new ConcurrentDictionary<string, string>();
publicstaticvoid Run()
{
bool running = true;
Random rnd = new Random();
Task.Factory.StartNew(() =>
{
while (running)
{
dict.TryAdd(Guid.NewGuid().ToString(), "A");
Thread.Sleep(rnd.Next(10));
}
});
Task.Factory.StartNew(() =>
{
while (running)
{
Console.WriteLine("Dictionary Count={0}", dict.Count);
Thread.Sleep(1000);
}
});
Thread.Sleep(1000);
Task.Factory.StartNew(() =>
{
while (running)
{
string key = dict.Keys.FirstOrDefault();
if (!string.IsNullOrEmpty(key))
{
stringvalue;
dict.TryRemove(key, outvalue);
}
Thread.Sleep(rnd.Next(10));
}
});
Console.ReadLine();
running = false;
}
}
}
如此不用擔心漏加lock闖禍,應可讓人在多執行緒地獄少爬幾十公尺刀山吧? 呵~