在.NET下使用Task Parallel Library提高程式效能

weixin_33816946發表於2012-02-16

.NET 4.0中的Task Parallel Library(TPL)已經不是什麼新鮮事了,相信很多朋友也閱讀過不少有關TPL的書籍資料。而另一方面,能夠將TPL合理地運用在實際專案開發過程中,以提高程式的執行效率,這種情況也並不多見。本文就以實際專案中的一個程式功能為例,簡要討論一下TPL的應用。在此我不打算對TPL的相關基礎知識做過多討論,這些內容在網上應該有不少的文章資料可供參考;同時讀者朋友還可以閱讀一些有關TPL的經典書籍,以便加深對TPL的理解。文章最後我會推薦幾本不錯的有關.NET 4.0下TPL的書籍資料。

案例:批量物件的XML序列化

在某個專案中,需要對一大批相同型別的物件進行XML序列化操作,在序列化工作完成後,程式會把序列化所得的XML字串根據物件的ID值儲存到一個字典(Dictionary)的物件中,以便後續的程式邏輯能夠使用這些序列化後的XML。為了簡化起見,我定義了一個Customer類來模擬這些物件的型別(實際專案中的物件型別要比這個Customer複雜一些),這個Customer類僅包含兩個屬性:ID和Name。下圖大致描述了這個處理過程:

image

現在讓我們先定義這個Customer類,以便為接下來的實驗作準備。Customer類的定義如下:

public class Customer
{
    public long ID { get; set; }
    public string Name { get; set; }

    public override string ToString()
    {
        return Name;
    }
}

 

下面,我們分別使用傳統的方式和基於TPL的並行處理方式來實現這個程式,然後比較一下這兩種方式產生的效果差異。

傳統的實現方式

傳統的實現方式很簡單,基本思路就是對每一個Customer物件,使用XmlSerializer對其進行序列化操作,然後把產生的XML字串儲存到字典中。程式碼如下:

static IEnumerable<KeyValuePair<long, string>> SerializeCustomers(Customer[] customers)
{
    var dict = new Dictionary<long, string>();
    var xmlSerializer = new XmlSerializer(typeof(Customer));
    foreach (var customer in customers)
    {
        using (var ms = new MemoryStream())
        {
            xmlSerializer.Serialize(ms, customer);
            dict.Add(customer.ID, Encoding.ASCII.GetString(ms.ToArray()));
        }
    }
    return dict;
}

 

基於TPL的並行處理方式

在採用這種方式之前,需要對我們的應用場景進行分析。今後在專案中打算使用TPL之前,都應該進行這樣的分析。主要目的就是為了討論目前我們所面對的場景,是否可以使用平行計算。目前我們的應用場景是可以採用TPL的並行處理方式的。因為首先,針對每個Customer物件的序列化操作都相對獨立,沒有先後順序之分,即各操作之間是可替換的,比如計算a+b+c,可以先計算a+b(也就是(a+b)+c),也可以先計算b+c(也就是a+(b+c));其次,雖然在最後整合結果的時候需要訪問跨執行緒的共享資源,也就是在最後整合結果的時候產生了資源的依賴關係,但對於整個計算的過程,各個任務都是可以互不干擾地執行的。在運用TPL的時候,我覺得應該儘可能地降低各個任務之間的依賴關係,因為TPL中的任務有可能會被分配到不同的執行緒去執行,如果任務之間有資源的相互依賴的話,執行緒同步將降低任務執行的效率。

以下是此案例的TPL版本:

static IEnumerable<KeyValuePair<long, string>> ParallelSerializeCustomers(Customer[] customers)
{
    var dict = new Dictionary<long, string>();
    var xmlSerializer = new XmlSerializer(typeof(Customer));
    object lockObj = new object();
    Parallel.ForEach(customers, () => new Dictionary<long, string>(),
        (customer, loopState, single) =>
            {
                using (var ms = new MemoryStream())
                {
                    xmlSerializer.Serialize(ms, customer);
                    single.Add(customer.ID, Encoding.ASCII.GetString(ms.ToArray()));
                }
                return single;
            }, 
        (single) =>
            {
                lock (lockObj)
                {
                    single.ToList().ForEach(p => dict.Add(p.Key, p.Value));
                }
            });
    return dict;
}

 

在ParallelSerializeCustomers方法中,採用了foreach迴圈的並行版本:Parallel.ForEach方法。這個方法與foreach類似,會逐個輪詢給定的IEnumerable物件中的沒一個值,不過Parallel.ForEach方法會將這個輪詢的過程分配到多個Task上執行,因此對於Parallel.ForEach,執行過程的中斷(break)以及異常處理都與foreach完全不同。在這個例子中,我們使用的是Parallel.ForEach方法的其中一個過載版本,在這個方法過載中,首先我們將需要輪詢的IEnumerable物件(也就是這裡的customers陣列)傳遞給該方法;之後有一個Func<TLocal>的委託引數,這個委託引數的作用是為了對Task執行執行緒範圍內的區域性變數進行初始化,在這裡我們直接使用Lambda表示式返回了一個新建的Dictionary<long, string>物件,表示需要對執行緒範圍內的區域性變數(其實就是第三個引數中的那個single變數)初始化成一個新的Dictionary<long, string>例項;第三個引數也是一個委託,用於對當前的列舉物件執行真正的處理邏輯,然後將處理結果返回;第四個引數則是用來整合每個任務的處理結果,以得到最終結果。不難看出,在整合最終結果的時候,多個執行緒需要同時訪問dict變數,因此需要使用lock關鍵字以保證執行緒同步。

執行效果對比

以下是在一臺具有4核CPU的計算機上,處理十萬(100000)個Customer物件的執行效果,可見基於TPL的實現效率要比傳統的實現方式高很多。值得一提的是,傳統方式所產生的dict是有序的,而基於TPL的方式所產生的dict則是無序的,但這並不影響結果,因為程式並不會關心dict中的值是否有序。

image

 

以下是傳統實現方式下,CPU的利用率。我們可以看到,基本上CPU的利用率只能達到20%-30%左右,大部分CPU資源都沒有利用到:

image

 

以下是基於TPL方式下,CPU的利用率,基本上能達到85%以上(估計剩下的部分由於IO的原因,所以沒有達到更高的CPU利用率):

image

 

參考書籍

案例程式碼下載

【請單擊此處下載本文案例原始碼】

相關文章