小敏,你有看過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>();
 
        public static void 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()取第一筆移除),就能見識許多古怪離奇的錯誤,例如:

  1. 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)
  2. 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)
  3. 明明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>();
 
        public static void 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))
                    {    
                        string value;
                        dict.TryRemove(key, out value);
                    }
                    Thread.Sleep(rnd.Next(10));
                }
            });
 
            Console.ReadLine();
            running = false;
        }
    }
}

如此不用擔心漏加lock闖禍,應可讓人在多執行緒地獄少爬幾十公尺刀山吧? 呵~


Comments

# by Hunter

請問黑暗大在使用ConcurrentDictionary有出現記憶體暴增的問題嗎?? 我使用過程中發現ConcurrentDictionary會讓記憶體持續上升,請問黑暗大有碰到這樣子的問題嗎?

# by Jeffrey

to Hunter, ConcurrentDitionary可視為內建lock機制的Dictionary,感覺不應特別容易造成記憶體洩漏。能用段小程式重現問題嗎?

Post a comment