2014/4/2

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

這一篇入門教學距離上一篇([入門] .Net 非同步處理與同步機制全解析 (一))已經有足足三年多的時間。之所以延宕那麼久, 主要是因為微軟已經開始提供平行處理及非同步機制的功能更新; 許多功能及用法不停地出現, 連我自己都搞不清楚, 所以我也沒辦法繼續著手這一系列文章。

直到 .Net Framework 4.5 開始, 我覺得應該可以算是一個適當時機, 可以繼續來寫這個系列文章了。在這個版本中, .Net Framework 加入了 Async/Await 指令, 可以大幅大簡化非同步程式設計。因此, 我認為本篇可以使用比以前更簡短的方式來撰寫。因為它實在太簡單了!

範例

以下我舉個例子。假設我們寫了一個 Windows Form 程式, 裡面有一道指令會載入其它網頁的資料; 因為載入資料的動作耗時較久, 我要在開始載入資料之前顯示一個訊息, 讓使用者知道動作已在進行中。在以下程式中, 我使用 Thread.Sleep(5000) 來代表這些耗時的指令:

// 程式一
private void btnLoadEvent_Click(object sender, EventArgs e)
{
    lbStatus.Text = "資料載入中, 請稍候... ";
    load();
}
private void load() 
{ 
    Thread.Sleep(5000);
    lbStatus.Text = "資料已載入。";
}

如果光從程式邏輯看, 以上程式絕不可能有任何問題, 跟非同步也沒有關係。但是當你執行程式之後, 怪事發生了。不管你執行幾次, 你只會看到「資料已載入。」這道訊息, 而「資料載入中, 請稍候...」這道訊息則是從來未曾出現。

這是為什麼呢? 這是因為上面那三行程式都在同一個 UI thread 上面, 而 Windows Form 會在 UI thread 上的指令執行完畢並且返回之後, 才會觸發它的 Paint 事件。同樣的程式, 如果你把 lbStatus.Text = 改成 Console.WriteLine, 在 Console 程式中這個問題就不會發生, 因為 Console 程式沒有像 Windows Form 那種 Paint 事件。

到了這邊, 這個問題就必須以非同步方式進行處理。

在 .Net Framework 4.5 之後引進的 Async/Await 指令可以很輕易地讓我們寫出非同步的程式。寫法也相當簡單, 把原來的程式改寫如下就行了:

// 程式二
private async void btnLoadEvent_Click(object sender, EventArgs e)
{
    lbStatus.Text = "資料載入中, 請稍候... ";
    await load();
}
private async Task load() 
{ 
    Thread.Sleep(5000);
    lbStatus.Text = "資料已載入。";
}

請留意上述程式的寫法。要把原來的程式改寫成非同步程式, 請注意包括 btnLoadEvent_Click 方法和 load() 方法都已經加上了 async 修飾詞, 同時 load() 的型別從 void 改成了 Task。被呼叫的非同步方法都必須傳回 Task 或 Task<T> 型別, 或者 void (Sub)。如果方法原本沒有回傳值 (就像本例中的 load()), 那麼把型別改成 Task 即可; 如果它原來有回傳值, 例如 int, 那麼就把它改成 Task<int>。

此外, 請注意呼叫 load() 時必須加上 await 指令。這個 await 指令會取用緊跟在後的 Task 或 Task<T> 物件; 這就是為什麼你在非同步方法中必須把回傳型別指定為 Task 或是 Task<T>。在上例中, 如果你不嫌麻煩的話, 也可以這樣寫, 意思是一樣的:

Task task = load();
await task;

有時候非同步方法回傳的型別不能是 Task, 也不能是 Task<T>, 那麼你就只能指定為 void, 就像程式範例中的 btnLoadEvent_Click() 方法。在什麼情況下程式只能宣告為 void 呢? 其實還蠻常見的, 像 btnLoadEvent_Click() 這種 Event Handler, 就不能回傳 Task 或 Task<T>, 一定只能為 void, 因為它必須符合 EventHandler 這個 delegate 所訂的規格。所以我們可以合理推斷, 應用程式在呼叫 btnLoadEvent_Click() 方法時, 鐵定沒有冠以 await 指令。

將回傳型別宣告為 void 的非同步方法還是可以呼叫的, 只要前面不冠以 await 即可。當然, 如此它也不會以非同步方式執行就是了。在本文的範疇中, 我們不會遇到必須指定 void 為回傳型別的情況, 所以以下我們都會使用 Task 或 Task<T>。

不過, 或許你會注意到, 若依程式二的寫法, 在 Visual Studio 會顯示一個 compiler warning, 但是程式仍然可以執行。結果, 當程式一執行, 「資料載入中, 請稍候...」這道訊息仍然不會出現。換句話說, 你以為你已經把程式改成非同步了, 實際上它並未以非同步方式執行。這時候, 你應該可以體會出剛才看到的 compiler warning 是什麼意思了。

Compiler Warning

問題在哪裡呢? 請記得 async/await 必須是成對出現的。所以我們可以在 btnLoadEvent_Click() 方法中找到 asyncawait, 在 load() 方法裡面, 則找不到任何 await 關鍵字, 也找不到任何隱含執行 await 指令的指令。Thread.Sleep(5000); 指令只是一道普通的指令而已, .Net 找不到它有任何非同步執行的跡象, 所以就把它當作同步程式了。連帶的, 上述程式中所有指令都以同步方式執行了。這就是為什麼你會看到那個 compiler warning 的原因, 也是為什麼程式並未以我們預期的方式執行的原因。

類似的錯誤, 你未來可能還是會反覆看到。所以每當你看到那一道 compiler warning 時, 千萬別把它忽略掉, 否則程式就不會以非同步方式執行。

那麼, 我們應該如何把它改成正確的形式呢? 我先來介紹一種最簡單、最偷懶, 但是並不正規的方法。

在很多時候, 你或許就是沒辦法把原來的程式改成非同步, 或許你只是跟我一樣, 單純地想讓「資料載入中, 請稍候...」顯示出來而已。我們可以用一種非常沒學問的方式辦到這一點, 如以下程式所示:

// 程式三
private async void btnLoadEvent_Click(object sender, EventArgs e)
{
    lbStatus.Text = "資料載入中, 請稍候... ";
    await load();
}
private async Task load() 
{ 
    await Task.Delay(5000);
    lbStatus.Text = "資料已載入。";
}

維持程式二的既有架構, 只要把 Thread.Sleep() 指令改成 Task.Delay() 指令(以及那一道 await 指令), 就可以讓 load() 變成一個真正的非同步方法。現在你就可以看到「資料載入中, 請稍候...」這道訊息了!

不過, 如果你的腦筋動得快的話, 你或許會想起來我前面明明說我只是使用 Thread.Sleep(5000) 來代表那些耗時的指令罷了; 實際上我們會執行的指令絕不可能是 Sleep() 或者 Delay() 指令。

沒錯! 如果你真正要執行的並不是 Sleep() 指令, 那麼你也可以從投機取巧地在你的程式裡加上 

await Task.Delay(20);

這道指令。這樣會讓你的程式犧牲 20 毫秒的執行時間, 但是這卻是讓你的 load() 方法立即變成非同步方法的最快方式。所以我剛才說這個是偷懶的方法。

不過, 現在我們回頭來談談正經事吧! 如果要使用「正規」的方式修改這個程式, 我們可以使用 Task.Run() 方法:

//程式四 
private async void btnLoadEvent_Click(object sender, EventArgs e)
{
    lbStatus.Text = "資料載入中, 請稍候... ";
    await Task.Run(() => { load(); });
    lbStatus.Text = "資料已載入。";
}
private void load() 
{ 
    Thread.Sleep(5000);
}

在這個程式中, 我們使用 Task.Run() 指令並傳入一個匿名函式以執行 load() 指令, 如此就可以了。這也是原來那個 Compiler Warning 提示我們採用的方法。請特別注意, 若採用這種作法, 我們不需要把 load() 方法改成 async 方法; 所以採用這種方式, 也能夠很快地把舊程式改成非同步方式執行。

不過, 你應該已經發現到, 我把顯示「資料已載入。」字樣的指令搬到了 btnLoadEvent_Click() 方法中。這是因為 Task.Run() 方法事實上就像我在前一篇文章所介紹的 Thread 功能一樣, 發出去的執行緒並不能取得 UI 執行緒中物件的控制權。因此你沒辦法在另一個執行緒上面存取 lbStatus 物件。

接下來, 我們來看看「最正式」的非同步寫法。其實 Task.Run 也算是正式的寫法, 但是我們可以在許多介紹文章中看到它們多半介紹的是以下這一種。基本上這種寫法我在上面已經介紹過了 (即第二個程式), 但是我們在那個程式中看到一個 compiler warning, 實際執行時也無法看到效果。所以, 以下我就不再使用 Thread.Sleep() 來取代真正的程式, 我將改寫程式, 讓它取出一個外部網站上的 JSON 檔案:

// 程式五
private async void btnLoadEvent_Click(object sender, EventArgs e)
{
    lbStatus.Text = "資料載入中, 請稍候... ";
    string content = await loadAsync();
}
private async Task<string><string> loadAsync() 
{ 
    using (WebClient webClient = new WebClient())
    {
        string json = await webClient.DownloadStringTaskAsync(
                "http://api.openweathermap.org/data/2.5/weather?q=Taipei,tw");
        lbStatus.Text = "資料已載入。";
        return json;
    }
}

在這裡, 當我們要載入網頁資料時, 就不能使用 webClient.DownloadString() 方法, 而必須改用 await webClient.DownloadStringTaskAsync() 方法, 而這也是那個 compiler warning 訊息中指出的真正意義。

此外, 我們也應該遵從 .Net 建議的命名規範, 所以我把 load() 改名成 loadAsync()。依照命名規範, 非同步方法應該在名稱後面加上 Async, 這樣才容易辨識。.Net 有提供了很多相同功能方法, 其中非同步的版本都會在原來的名稱之後加上 Async; 前面提到的 DownloadString / DownloadStringTaskAsync 就是其中一例。

但是有一點需要特別注意: 如果我們把程式改成非同步方法, 它就再也不能支援以 ref 和 out 修飾的參數。所以, 如果你原有的同步方法支援 ref 或 out 參數, 你就不能直接把它改成非同步方法。你可以考慮修改你的程式邏輯, 或者改用上面介紹過的 Task.Run() 方法。

再補充一點, 把 lbStatus.Text = "資料已載入。"; 這一行寫在 loadAsync() 方法中並不是很好的做法, 這會讓這個方法相依於 UI。我這樣寫只是為了示範這種做法在技術上辦得到而已, 實際上你應該把這一行移到 btnLoadEvent_Click() 方法裡去 (接在 await 那一行的下面)。

Async/Await的規則與限制

當我們要使用 Async/Await 關鍵字時, 有幾個規則與限制, 不可不知:

  • 我們不能在 catch 和 finally 區段中使用 await 關鍵字。
  • 我們不能在建構函式和屬性的 getter/setter 中使用 async/await。
  • 在標示為 async 的非同步方法中不能採用 ref 或者 out 參數。
  • 我們不能在 lock 區段中使用 await 關鍵字

由於有這些限制, 所以當我們未來在設計程式時, 或許應該先考慮使用非同步執行的可能性。如果需要, 可以同時設計同步和非同步的版本, 如此才能讓這兩個版本的呼叫方式盡量相似。而不是先設計了同步的程式, 再去修改成非同步。

此外, 從 .Net 4.5 開始, .Net Framework 程式庫中已經把多數常用的需要耗時較久的功能加上 Async 版本, 包括網路傳輸和檔案傳輸等等。所以你應該盡量採用這些 Async 版本(如程式五)。只有在找不到 Async 版本或者你有自己撰寫的某些耗時的功能時(例如大量且反覆的數學運算), 才需要以 Task.Run 方式予以呼叫(如程式四)。

增進效率的技巧

我在前一篇文章中介紹過平行運算的做法。但是如果透過 Async/Await 指令, 我們也可以簡單地辦到平行運算。現在假設在一個 Windows Form 程式中, 我打算把三個網路上擷取的圖片載入三個 PictureBox 控制項, 那麼, 透過上面介紹過的方法, 我的程式如下:

// 程式六
pb1.Image = await GetImageAsync("http://img1");
pb2.Image = await GetImageAsync("http://img1");
pb3.Image = await GetImageAsync("http://img1");

在這裡 GetImageAsync 是我寫的非同步方法。

依照這種寫法, 由於每一行都要等待 await 指令得到結果之後, 程式流程才會往下跑, 所以雖然已使用非同步方法, 基本上這種做法能夠獲得的好處十分有限。

但是如果把上述程式稍為改一下, 我們就可以獲得兩倍的效率。以下我同時把兩種做法列出來:

// 程式七
private async void btnLoad_Click(object sender, EventArgs e)
{
    lbStatus.Text = "資料載入中, 請稍候... ";

    Stopwatch sw = new Stopwatch();
    sw.Start();

    // Method 1 - the traditional way
    pb1.Image = await GetImageAsync("http://img1");
    pb2.Image = await GetImageAsync("http://img1");
    pb3.Image = await GetImageAsync("http://img1");

    // Method 2 - using await to make the loadings run simutaneously
    //Task<Image> task1 = GetImageAsync("http://img1");
    //Task<Image> task2 = GetImageAsync("http://img1");
    //Task<Image> task3 = GetImageAsync("http://img1");
    //pb1.Image = await task1;
    //pb2.Image = await task2;
    //pb3.Image = await task3;

    // Method 3 - using Task.WhenAll to await all tasks. This doesn't improve much
    //Task<Image> task1 = GetImageAsync("http://img1");
    //Task<Image> task2 = GetImageAsync("http://img1");
    //Task<Image> task3 = GetImageAsync("http://img1");
    //await Task.WhenAll(task1, task2, task3);
    //pb1.Image = task1.Result;
    //pb2.Image = task2.Result;
    //pb3.Image = task3.Result;

    sw.Stop();
    TimeSpan ts = sw.Elapsed;
    lbStatus.Text = string.Format("資料已載入。耗時 {0:0.000} 秒。", ts.TotalSeconds);
}
private async Task<Image> GetImageAsync(string url)
{
    WebClient client = new WebClient();
    byte[] imageData = await client.DownloadDataTaskAsync(url);
    return Image.FromStream(new MemoryStream(imageData));
}

在上述程式中, 我把三種做法都列了出來。第一種做法就是程式六的做法, 第二種做法則是程式六的變形, 第三種做法則是另一種變形。

如同第二種做法, 如果我們不去 await 非同步程式, 而去 await 那個 Task, 那麼這三個 Task 就不再依照它們在程式中列出的順序, 而是同時執行。而執行的結果就如預期, 足足比第一種做法快了一倍!

第三種做法則是加上了一個 Task.WhenAll 指令, 它會整合參數中的所有 Task (即範例中的 task1, task2 和 task3), 讓我們只需 await 一個 Task。不過實務上這第三種做法在效率上並不能討到任何便宜; 經我實測多次, 它的效率比第二種做法略差一點。

此外, 若採用第三種做法, 你絕對不能省略 Task.WhenAll 指令, 否則程式會因為 UI 被鎖定而從此停止回應。這是因為 UI 緒行道在等待非同步的結果, 而 Task.Result 又在等待 UI 釋放所導致的死結。你必須把所有會執行到 Task.Result 的 Task 物件放進 Task.WhenAll 裡面集中看管, 才不會導致這個死結的發生。

要執行程式七, 請開啟一個 Windows Form 專案, 在 Form 裡拉一個 Button (btnLoadEvent), 一個 Label (lbStatus) 和三個 PictureBox (pb1, pb2, pb3)。把 Method 2 或 Mehod 3 的指令的註解拿掉就可以測試那一段。

不過, 提醒大家, 我在最近的案子裡, 發現如果在 Windows Form 應用程式裡用到 Task.WhenAll 指令 (上述第三種做法), Task 數量一次不能超過 100 個。我寫了一個 非同步新增資料到 SQL 資料庫中的方法, 如果採用第三種做法把多筆資料寫入, 那麼從第 101 筆開始, 就會把這個應用程式凍住, 甚至無法關閉。但是, 同樣的做法、同樣的程式, 在 Unit Test 裡倒是一點問題也沒有; 即使幾萬筆資料都可能順利跑完。

目前我還不了解真正的原因為何。但是如果你也遇到這個問題, 或許你可以避開第三種做法, 改用第一或第二種做法。

參考資料:

繼續閱讀: 

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

1 則留言: