[入門] .Net 非同步處理與同步機制全解析 (一)

不管你學的是何種程式語言, 非同步及平行處理總是最令人費解的部份之一。如果你沒有過人的邏輯觀念, 那麼要把非同步程式寫好, 恐怕有一定的困難度。不過最難的部份不在技術上, 而是到底在何種場合下可以採用非同步或平行處理。在日常生活中, 我們採用的邏輯大多是循序進行的, 也就是說, 等到前一件事情完成之後, 才去進行下一件。但是在某些情況下, 非同步處理是比較合乎情理的做法...

不管你學的是何種程式語言, 非同步及平行處理總是最令人費解的部份之一。如果你沒有過人的邏輯觀念, 那麼要把非同步程式寫好, 恐怕有一定的困難度。不過最難的部份不在技術上, 而是到底在何種場合下可以採用非同步或平行處理。在日常生活中, 我們採用的邏輯大多是循序進行的, 也就是說, 等到前一件事情完成之後, 才去進行下一件。但是在某些情況下, 非同步處理是比較合乎情理的做法。

Why? When?

舉一個日常生活中時常發生的事情做為例子吧。當我們一早起床後, 可以先打開咖啡機, 讓它開始沖泡咖啡; 但用不著等它泡好, 我們就可以接著把吐司放進烤麵包機, 但是也不必等到麵包烤好, 就可以先去刷牙或做其它事情。等到刷牙、烤麵包和沖泡咖啡三件事情全部都做好之後, 再來吃早餐。以上, 我們把工作分配給不同的執行單位(人、咖啡機、烤麵包機), 我們可以把它視為平行處理; 把工作分派出去分頭執行, 我們可以把它視為非同步處理; 而等到各項工作完成之後再來做下一件事情(吃飯), 我們可以把它視為同步處理。在電腦中當然沒有咖啡機或烤麵包機, 但大致上的概念也差不多是如此。

當我們在電腦中進行類似的非同步及平行運算時, 如果在硬體/作業系統層級上我們採用單執行緒的分時多工環境, 那麼這種「平行處理」並不能討到任何便宜, 反而可能會在某種程度上因平行處理作業所導致的 Overhead 而造成效能上的損失(其實若沒有硬體的支援, 我們在分時多工系統中只能做「平行處理」的「模擬」, 並不是真正的「平行處理」)。但是, 如果我們所發出去的程序可以成功分配到 N 個(至少兩個) CPU 或硬體處理緒, 那麼效能就可以達到小於 N 的倍數。或者, 就算我們沒有多重 CPU 或處理緒, 但我們發出去的非同步工作是透過 IO 分配給其它電腦或 IO 裝置(例如磁碟機、RS-232、網路裝置等等)去進行, 那麼我們仍然可以享受到非同步/平行處理的好處。

但是對於一般的程式設計師而言, 我們會面臨的最大挑戰, 也許並不在技術層面上, 而是要去哪裡找到需要(或可以)進行非同步處理的情境, 並套用適當的非同步架構。要知道, 並不是每件事情都可以以非同步處理來提高效能或獲得任何好處; 如果沒有妥善的規劃, 你只會使得效能更加低落。

若論平行處理的應用, 最常被引用的例子大概就像是計算質數、編碼、解碼、破解、計算天文數字般的氣象資料等等。以計算質數這件事為例, 我們可以事先切割需要運算的整數範圍, 再把個別的範圍丟給不同的 CPU 或處理緒去進行運算, 最後再把結果予以匯整。不過, 如果你以為其它事情可以像計算質數一樣幾近完美的把日常工作進行等分切割的話, 你最後只會失望而已。在真實世界中, 我們真的很難得踫到可以完美進行切割的工作。因此, 在我們尚未開始介紹非同步機制之前, 我希望你能充分理解這個技術的侷限, 而不要寄予不切實際的期望。

可能有人會說,非同步處理與平行運算是不同的兩件事情。我認為這種說法是缺乏常識的。為什麼呢?

請問你,何謂「平行處理」?不就是為了要在同一時間處理兩件以上的事情嗎?那麼,為什麼又需要「非同步處理」?不就是因為你要在一件事已經在處理中的時候,同時又要處理其它事情嗎?請問以上兩件事情到底哪裡不一樣?如果一味地要在文字上打轉,就等於是論「白馬非馬」,那也未免太失之偏頗了。

「平行處理」和「非同步處理」只是在手法上不太一樣而已。我們可以說前者通常是把後者包裝成比較高階的形式以方便大量資訊的處理,而前者經常被使用於 IO 處理;事實上二者雖然在應用的方向不太一樣,在本質上並無不同。因此,我並不打算在本文中將二者予以明確的區分。

其次, 當你判定一件事情可以使用非同步、多執行緒處理來做的時候, 要如何規劃、使用何種手法, 這些才是最重要的, 而且也有一定的難度。.Net 提供了很多種可以相互取代的非同步機制, 我將在以下的文章中把它們逐一介紹; 至於如何妥善運用, 就得靠你自己了。

此外, 由於這是入門文章, 我會盡量不要把它寫成字典型式; 如果你希望查詢各個命名空間下的各個方法, 那麼你應該去查閱 MSDN

幾個不可不知的前題

關於 .Net 中的非同步處理, 有幾個觀念和用語, 我必須在這裡稍做說明。

當我們啟動一個 Windows 應用程式時, 作業系統已經安排給這個應用程式一個執行緒 (thread), 我們通常稱為主執行緒 (Main Thread); 而當我們在程式中再建立一個 thread 物件時, 作業系統將會安排另一個執行緒來執行其程式碼。到此為止, 這個應用程式中已經有兩個執行緒了。同一個應用程式的不同執行緒都要受到作業系統排程器 (Scheduler) 的控管, 而彼此的地位在某種程度上是相等的, 也有個別的存取限制 - 這是我們需要特別注意的一件事。

其次, 請記得, 像 Windows 這種多工 (Multi-tasking) 的作業系統, 平常都有許多個執行緒在交替執行, 所以並不是只有你的應用程式所發出的執行緒存在。在 Windows 中, 每隔 25ms, CPU 就會強制切換不同的執行緒, 而所有執行緒都會互相爭奪系統資源 (包括記憶體、檔案等等)。如果你的程式有多個執行緒, 恭喜你, 它們彼此也會爭奪資源。因此, 在本系列文章中, 我可能會花一點篇幅在探討如何讓不同執行緒間不會衝突打架。

在我們的程式中, 我們不必擔心硬體層次的問題。透過前面提到的排程器, 作業系統 (和硬體本身) 會自動為每個程式做規劃。你只需要知道, 如果你的硬體愈好, 那麼你做非同步/同行處理的效能大致上也會愈好。換句話說, 四核八緒的 CPU 一定優於四核四緒, 再優於二核四緒, 依此類推 -- 除非你的程式寫得太爛, 或者情境套用錯誤。事實上, 我們在程式中發出的執行緒 (Thread) 都是邏輯上的。換句話說, 同一個執行緒, 在它的生命週期中, 可能運作於不同的 CPU、Core 或者 Hyper Thread, 但是我們可以完全不必考慮此種細節。

還有, 依照 Windows 的規劃, 所有執行緒可以劃分為 0 到 31 共 32 個等級的執行優先權 (Priority, 數字愈高優先權愈高); 我們寫的使用者應用程式則可以為自己的執行緒分配 22 ~ 26 共五種等級:

  1. Lowest (22)
  2. Below Normal (23)
  3. Normal (24)
  4. Above Normal (25)
  5. Highest (26)

此外, 我在這裡都把 thread 翻譯成「執行緒」, 但是你可能會在其它地方看到把人把它稱為「處理緒」, 中國術語則是「线程」。我不會使用這個些用語, 不過我倒是會使用「處理序」用來稱呼 Process 這個字 (中國術語為「进程」); Process 代表的不只是應用程式的「主執行緒」(Main Thread), 通常也總括代表了該應用程式的記憶體等資源。因此, 對於這幾個用語, 各位請仔細分辨, 才不會引起誤會。

不過, 在開始進入非同步處理這個主題之前, 我們必須先從執行緒本身談起:

System.Threading.Thread 類別

在 .Net 中建立一個非同步執行的執行緒是極為簡單的。在以下的程式範例中, 我們將使用 System.Threading.Thread 類別來建立多個執行緒。

在說明這個範例程式的寫法之前, 我們先來說明一下程式的邏輯: 當這個程式執行後, 它會自動建立一個新的執行緒, 虛擬的 ID 為 0; 如果你任意按下一個鍵 (唯獨不能按 ESC 鍵, 否則程式會結束), 程式會再建立一個新的執行緒。所以, 如果你瘋狂的在鍵盤上亂按一通, 程式也會瘋狂的建立很多個新執行緒, 並且賦予它們流水編號。

這些執行緒會執行一段不等的時間 (最長 6000ms), 然後自動結束。我們在輸出視窗中可以看到執行緒陸續被建立、陸續自動結束的訊息, 如以下畫面所示:

建立 Thread 物件的方式至少有兩種, 我在這裡展示的是帶入 Parameterized ThreadStart 的做法。寫法相當簡單, 基本上就是以下幾行指令而已:

using System.Threading; // 匯入命名空間
Thread t = new Thread(CreateThread); // 宣告並建立 Thread 變數物件 t, 並宣告其 ThreadStart 委派所代理的方法名稱
t.Start(xx); // 啟動這個 thread 物件並帶入將傳給執行方法的參數

所以, 如果去掉 using 命名空間這行不算的話, 要建立一個 Thread 物件並啟動其執行, 總共只有兩道指令而已。

以下是完整的程式碼:


// 程式一
using System.Threading;
…
class Program
{
    static Random rnd = new Random(DateTime.Now.Millisecond);
    static int threadCount = 0;

    static void Main(string[] args)
    {
        Console.WriteLine("Press <esc> to stop or any other key to create a new thread... ");
        int id = 0; ConsoleKey k = ConsoleKey.Spacebar;
        while (k != ConsoleKey.Escape)
        {
            Thread t = new Thread(CreateThread); 
            t.IsBackground = true;
            t.Start(id++); 
            k = Console.ReadKey(true).Key;
        }
    }

    static void CreateThread(object obj)
    {
        threadCount++;
        Console.WriteLine("Incoming thread #{0}.", obj);
        DoSlicedJob();
        threadCount--;
        Console.WriteLine("Thread #{0} stopped. {1} others running.", obj, threadCount);
    }

    private static void DoSlicedJob()
    {
        Thread.Sleep(rnd.Next(25, 6000));
    }
}

如果你想一招半式闖江湖的話, 光靠這個 Thread 類別其實也夠你應付很多常見的狀況了。不過, 很不幸, 現實生活中事情總是沒有那麼簡單的。以上這個範例程式看似方便好用, 裡面其實隱藏了一些陷阱, 只有內行人才知道應該如何應付。其中最大的陷阱, 就在執行緒難免會面臨的資源搶奪的問題。

在這個範例中, 我們在 CreateThread 方法中修改了 threadCount 的值; 雖然 threadCount 只是一個 int 變數, 但也是一個共用的資源。如果你的 thread 數目非常多 (例如幾百個), 或者這個資源被佔用的時間很長 (例如這個共用資源不是一個整數, 而是一個大檔案), 那麼你就會面臨一個很令人頭疼的問題, 也就是非同步處理中最常見的資源衝突的問題。.Net 提供了很多解決的方法, 我會在後面的文章裡陸續說明。

至於 Thread.IsBackground 這個屬性, 則是用來控制建立的執行緒是否以背景方式執行。把這個值設定為 True 的話, 我們可以在當這些背景執行緒還沒有執行完畢時, 把處理序關閉; 設定為 False 的話, 我們必須等到所有前景執行緒都執行完畢之後, 才能把處理序關閉。在這個範例程式中, 因為我已經把這個屬性都設定為 True, 所以你可以在還有執行緒尚在執行時按下 ESC 將程式立即關閉。但如果你把這個值設定為 False, 你會發現程式會在所有執行緒都完成之後才會關閉。

此外, 我們難免會懷疑, .Net 的 Thread 類別會不會判別系統的硬體組態, 並且自動把新建的執行緒分派到不同的處理器/核心呢? 我也很好奇, 所以我先趁著當未執行此範例程式時的工作管理員畫面剪下來:

然後執行程式, 並且瘋狂的在鍵盤上亂敲, 當建立了上百個進行中的執行緒時, 再把工作管理員畫面剪下來:

雖然這個程式因為過於簡單而無法耗用多少 CPU, 但我們的確可以看到四個核心的使用率都有波動, 這表示工作確實被分派到多個核心去執行了。但是如果切換到工作管理員的資源監視器去觀察的話, 我發現我無論怎麼瘋狂的敲打鍵盤, CPU 的數字最大只能到達 6, 而無法到達最大的 8。我必須寫一些更會耗用運算能力的程式, 才能真的讓我這台號稱是「四核八緒」的電腦滿載; 光靠 Thread.Sleep 指令是沒用的。因此,我在本文最後面會補上一個使用 .Net GZipStream 函式庫所提供的 Compress 方法來壓縮一個檔案的範例程式。

System.Threading.Tasks.Parallel.For 方法

從 .Net 4.0 開始加入了對平行運算的支援。雖然在上一個例子中透過 Thread 類別我們已經可以很簡單的建立多執行緒的工作, 但是新的 System.Threading.Tasks.Parallel 類別可以更進一步的幫你少打幾行指令。

Parallel 類別是屬於 Task Parallel Library (TPL) 裡面極為重要的一部份, 而 TPL 是 Microsoft Research、微軟 CLR 團隊和平行運算平台 (Parallel Computing Platform) 等等三個團隊的綜合成果, 也是微軟次世代 .Net 平行支援計劃 - Parallel FX library 的主要元件。其實 TPL 早在 2008 年就發表 CTP 了, 而且當時也提供了 Parallel Extensions 給 .Net 3.5 使用者, 讓他們可以在 VS2008 上進行開發。但想不到的是, 現在再回頭去找這個 Parallel Extensions 下載點, 竟然都已經連不上去了。如果你還在使用 .Net 3.5, 那麼你想用上 TPL 的機會或許並不大。

Parallel 類別共有三個主要的方法:

  • Parallel.For
  • Parallel.ForEach
  • Parallel.Invoke

這三個方法的使用方法其實都差不多。基本上, 經由 Parallel  類別, 你可以比較方便的在程式中加入一個區段, 而區段中的程式碼會自動以平行運算的方式規劃執行。不過憑良心來講, 當你看過程式碼之後, 再比較一下上一個範例, 你會發生使用 Parallel 類別所能省下的工夫其實並不多; 所以我們也無須對這項新的技術抱持過多的期望。如果你的執行環境並不是 .Net 4.0 以上, 你仍然可以很安心的回頭使用之前的寫法 (例如程式一) -- 只是多寫幾行而已, 你並沒有損失太多。你必須有更為複雜的應用情境, 才能真正發揮到 TPL 所能為你帶來的好處。

Parallel.For 的寫法真的超級簡單, 大致上只有一行而已:

Parallel.For(0, 100, i => { // 你要執行的程式 });

你應該可以看出來, 上一行程式中 i => { .. } 是 Lambda 運算式的寫法。如果你沒有那麼懶, 或者你很懼怕 Lambda, 那麼你也可以把 { .. } 裡面的程式另外寫成一個方法 (例如 MyJob(int i)), 上述程式就可以改成如下:

Parallel.For(0, 100, MyJob);

很可惜的, Parallel 類別偏偏就沒有提供 .While 這種方法, 所以我無法把上一個範例原封不動的改過來。我只能把原始邏輯稍為改寫, 把 While 迴圈改做 For 迴圈:

Parallel.For(0, 100, i =>
{
    threadCount++;
    Console.WriteLine("Incoming thread #{0}. {1} running threads in total", i, threadCount);
    DoSlicedJob();
    threadCount--;
    Console.WriteLine("Thread #{0} stopped, {1} are still running", i, threadCount);
});

從範例中可以看出來 Parallel.For 和一般的 for 迴圈沒有太大的不同, 但是在 Parallel.For 迴圈裡的程式碼, 會被 .Net 4.0 自動的建立執行緒去執行, 也不必使用 Join 指令。所以在程式中, 你看不到 Thread 這個字, 但是它實際上確實是以多執行緒方式在執行的。有趣的是, 當你實際執行這個程式時, 你將會發現在程式中的 i 這個數字並不一定是以 0, 1, 2, 3… 的順序出現的, 你也許會看到 0, 85, 4, 32, 5, 99... 這種數字序列。

完整的範例程式碼如下 (這裡我使用了另一種多載寫法):


// 程式二
using System.Threading;
using System.Threading.Tasks;
…
class Program
{
    static Random rnd = new Random(DateTime.Now.Millisecond);
    static int threadCount = 0;

    static void Main(string[] args)
    {

        Parallel.For(0, 100, MyJob);

        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
    private static void MyJob(int i)
    {
        threadCount++;
        Console.WriteLine("Incoming thread #{0}. {1} running threads in total", i, threadCount);
        DoSlicedJob();
        threadCount--;
        Console.WriteLine("Thread #{0} stopped. {1} others running", i, threadCount);
    }
    private static void DoSlicedJob()
    {
        Thread.Sleep(rnd.Next(25, 6000));
    }
}

至於 Parallel.ForEachParallel.Invoke, 它們的寫法實在是大同小異; 為了不讓本文的篇輻暴增, 就請讀者自己參考 MSDN 了。

效能評估

為了證明 Parallel 類別所提供的平行處理功能確實可以榨乾你的 CPU,我把上一個程式改寫了一下,把原本的 Thread.Sleep 方法改成了使用 GZipStream 類別來壓縮一個大約 5M 大小的 bitmap 圖檔。由於檔案壓縮一定會用到密集的電腦運算,所以我們能觀察到的數據一定是紮紮實實的,沒有任何灌水的空間。

經過觀察,我發現如果我把壓縮好的檔案寫回硬碟的話,CPU 的負載量會從一開始的滿載情況,很快地跳回非常低的程度 (如下圖)。我想這是由於檔案系統無法處理太大量的 IO 寫入動作所致。

但如果我換個方式,把壓縮後的結果寫入記憶體而不寫入檔案的話 (也就是避掉 IO 寫入的動作),那麼 CPU 負載的情況就是線性的了,如下圖所示:

上面這個圖是在我電腦上壓縮圖檔 100 次、1,000 次、2,000 次及 3,000 次的 CPU 負載比率。除了壓縮 100 次時,因為速度太快,表示負載的波形不太清楚之外,其它三個較為明顯的波形寬度看起來剛好就是呈現 1:2:3 的比例。

以下是完整的範例程式:


// 程式三
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.IO;
using System.IO.Compression;

namespace TestCsConsole
{
    class Program
    {
        const int LOOP = 100;
        static int threadCount = 0;

        static void Main(string[] args)
        {
            Stopwatch sw = new Stopwatch();
            GZip.GetMemoryStream("E:\\temp\\Picture.bmp");
            sw.Start();
            Parallel.For(0, LOOP, doParellelJobs);
            sw.Stop();
            Console.WriteLine("\nProgram finished. Elapsed {0} seconds in total.", sw.Elapsed.TotalSeconds);
            Console.WriteLine("\nPress any key to exit.");
            Console.ReadKey();
        }

        private static void doParellelJobs(int i)
        {
            Console.WriteLine("Incoming thread #{0}. {1} running threads in total", i, threadCount);
            threadCount++;
            doCompression(i);
            threadCount--;
            Console.WriteLine("Thread #{0} stopped, {1} are still running", i, threadCount);
        }

        private static void doCompression(int serial)
        {
            //string fileToOutput = string.Format("E:\\temp\\Test{0:0000}.zip", serial);
            //GZip.Compress(fileToOutput);
            GZip.Compress();
        }
    }

    public class GZip
    {
        static byte[] input;

        public static void GetMemoryStream(string inputFile)
        {
            using (FileStream fs = new FileStream(inputFile, FileMode.Open))
            using (MemoryStream ms = new MemoryStream())
            {
                ms.SetLength(fs.Length);
                fs.Read(ms.GetBuffer(), 0, (int)fs.Length);
                fs.Flush();
                input = ms.ToArray();
            }
        }

        public static void Compress(String outputFile)
        {
            using (FileStream fsOutput = new FileStream(outputFile, FileMode.Create, FileAccess.Write))
            using (GZipStream zip = new GZipStream(fsOutput, CompressionMode.Compress))
                zip.Write(input, 0, input.Length);
        }

        public static void Compress()
        {
            byte[] buffer = new byte[input.Length];
            using (MemoryStream ms = new MemoryStream(buffer))
            using (GZipStream zip = new GZipStream(ms, CompressionMode.Compress))
                zip.Write(buffer, 0, input.Length);
        }
    }
}

要測試這個程式,你首先必須先找到一個大小適合的檔案 (可以是任何檔案,並不一定是 bitmap 圖檔),再把檔案路徑改掉 (除非你剛好也想把檔案都放在 E:\temp 資料夾裡)。接著,在 doCompression 方法中執行 GZip.Compress(fileToOutput) 就表示要寫入硬碟;執行 GZip.Compress() 則寫入記憶體。接著,你就可以享受把自已 CPU 效能完全榨乾的快感了!

使用 VS 內建的效能分析工具

我們在上面使用了包括工作管理員在內的效能記錄來觀察平行程式的運作。但 Visual Studio 事實上已內建了更好用、更詳細的效能分析工具。由於全部都要使用畫面來示範,我乾脆把操作畫面錄起來,各位讀者請自行參考:

繼續閱讀: 

  1. [入門] .Net 非同步處理與同步機制全解析 (一)
  2. [入門] .Net 非同步處理與同步機制全解析 (二)
  3. [入門] .Net 非同步處理與同步機制全解析 (三)


Dev 2Share @ 點部落