有一段小時候寫的程式,運行多年無恙,卻在今天發生爆炸!

看似正常的更新Dictionary邏輯,卻在Dictionary.Add時冒出"Index was outside the bounds of the array."錯誤:

if (Cache.ContainsKey(key)) Cache.Remove(key);
Cache.Add(key,  someData);

Cache的型別為Dictionary<string, string>,程式先檢查該Key值是否存在,若存在先將該Key資料移除,接著存入新資料。由於是在網站環境,程式碼有可能被多個Request以多執行緒方式同時呼叫,而不知是年幼無知或一時糊塗,這段更新資料邏輯並沒有加上執行緒安全防護(Thread-Safe),可能就是導致爆炸的原因。但直覺上會爆出的狀況是剛Remove()還來不及Add()前,另一個Request(另一條執行緒)插隊先Add()相同Key值的資料而導致"An item with the same key has already been added"錯誤,但冒出IndexOutOfRangeException就讓人有些不解,莫非這也是多執行緒寫入失控引發的現象之一?

設計了一個驗證測試,我使用以下做法在static Dictionary<string, string>新增相同Key值的資料,用Thread.Sleep()隨機時間的方式模擬實務上自然湧入的Request。

using System;
using System.Collections.Generic;
using System.Threading;
 
public class TestRunner1
{
    public static Dictionary<string, string> Cache =
        new Dictionary<string, string>();
    //警告: 以下為未考慮Thread-Safe的寫法
    static void UpdateCache(string key, string data)
    {
        if (Cache.ContainsKey(key))
            Cache.Remove(key);
        Cache.Add(key, data);
    }
    static Random rnd = new Random();
    //進行測試,更新Cache的同一項目
    public static void Test(int i)
    {
        //產生隨機延遲,設法逼近一般實務情境
        Thread.Sleep(rnd.Next(5));
        UpdateCache("MyCache", rnd.Next().ToString());
        Console.WriteLine("Done-{0:0000}", i);
    }
}

呼叫端以ThreadPool多執行方式呼叫TestRunner1.Test(),並捕捉IndexOutOfRangeException並設定Visual Studio偵錯中斷點,再使用浮動監看視窗觀察Dictionary遭多執行緒蹂躙後的樣子。多試幾次,果然看到許多奇異現象:

Index was outside of the bounds of the array!

Count變成負數! 酷吧?

雜湊表型別中出現Key值相同的奇景!! 沒見過吧?

啊哈!! 我抓到.NET Framework Dictionary物件的Bug嗎? 才不是哩!! 本草綱目MSDN文件有記載:

只要未修改集合,Dictionary<TKey, TValue>.KeyCollection 可同時支援多個讀取器 (Reader)。 即便如此,透過集合的列舉基本上並非安全的執行緒程序。 若要確保列舉期間的執行緒安全,您可以在整個列舉期間鎖定集合。 若要允許多重執行緒存取集合以進行讀寫,您必須實作自己的同步處理。

A Dictionary<TKey, TValue>.KeyCollection can support multiple readers concurrently, as long as the collection is not modified. Even so, enumerating through a collection is intrinsically not a thread-safe procedure. To guarantee thread safety during enumeration, you can lock the collection during the entire enumeration. To allow the collection to be accessed by multiple threads for reading and writing, you must implement your own synchronization.

簡單來說,Dictionary<TKey, TValue>在多執行緒下讀取沒問題,但若打算在多個執行緒中同時更新或是跑foreach,就必須自行處理鎖定及同步議題。在這個測試案例中,最簡單的做法在UpdateCache()時加上一行lock:

    //簡易的單層鎖定機制
    static void UpdateCache(string key, string data)
    {
        lock (Cache)
        {
            if (Cache.ContainsKey(key))
                Cache.Remove(key);
            Cache.Add(key, data);
        }
    }

當所有執行緒以Cache為對象進行鎖定後,可迫使所有更新動作排隊依序執行,防止多個執行緒搶著更新同一個物件衍生的不可預期後果。(在使用複雜查詢產生快取資料的情境,若要避免鎖定及重複執行拖累效能,還要進一步考量使用雙重檢查鎖定之類的設計模式,這部分未來有機會再另文討論) 而同樣的原理也適用ASP.NET Cache物件的新增或更新操作上,一樣是全Process只有一份,一樣會有多個Request多執行緒同時執行,故最好也加上同步化或鎖定機制以防止多執行緒打架。

另外,如果你的專案使用的是.NET 4.0,則有個好消息: .NET 4.0新增了System.Collections.Concurrent命名空間中,多了ConcurrentDictionary, ConcurrentQueue, ConcurrentStack... 等等五個內建支援多緒存取的安全執行緒集合類別,省卻了自行處理lock的麻煩,可多加利用。

    public static ConcurrentDictionary<string, string> Cache =
        new ConcurrentDictionary<string, string>();
    //改用ConcurrentDictionary
    static void UpdateCache(string key, string data)
    {
        //不存在就新增、否則用更新的
        Cache.AddOrUpdate(key, data, 
          //第三個參數是當key值存在時,要更新的內容
            //本例中,則固定以data值覆寫之
            (k, v) => data);
    }

Comments

Be the first to post a comment

Post a comment