溫故之.NET 非同步

JameLee發表於2019-02-28

這篇文章包含以下內容

  • 非同步基礎
  • 基於任務的非同步模式
  • 部分 API 介紹

非同步基礎

所謂非同步,對於計算密集型的任務,是以執行緒為基礎的,而在具體使用中,使用執行緒池裡面的執行緒還是新建獨立執行緒,取決於具體的任務量;對於 I/O 密集型任務的非同步,是以 Windows 事件為基礎的

.NET 提供了執行非同步操作的三種方式:

  • 非同步程式設計模型 (APM) 模式(也稱 IAsyncResult 模式):在此模式中非同步操作需要 BeginEnd 方法(比如用於非同步寫入操作的 BeginWriteEndWrite)。不建議新的開發使用此模式
  • 基於事件的非同步模式 (EAP):這種模式需要一個或多個事件、事件處理程式委託型別和 EventArg 派生型別,以便在工作完成時觸發。不建議新的開發使用這種模式
  • 基於任務的非同步模式 (TAP):它是在 .NET 4 中引入的。C# 中的 asyncawait 關鍵字為 TAP 提供了語言支援。這是推薦使用方法

由於非同步程式設計模型 (APM) 模式與基於事件的非同步模式 (EAP)在新的開發中已經不推薦使用。故在此處我們就不介紹了,以下僅介紹基於任務的非同步模式(TAP

基於任務的非同步模式(TAP)

任務是工作的非同步抽象,而不是執行緒的抽象。即當一個方法返回了 TaskTask<T>,我們不應該認為它一定建立了一個執行緒,而是開始了一個任務。這對於我們理解 TAP 是非常重要的。

TAPTaskTask<T> 為基礎。它把具體的任務抽象成了統一的使用方式。這樣,不論是計算密集型任務,還是 I/O 密集型任務,我們都可以使用 async 、await 關鍵字來構建更加簡潔易懂的程式碼

任務分為 計算密集型任務I/O密集型任務任務兩種

  • 計算密集型任務:當我們 await 一個操作時,該操作會通過 Task.Run 方法啟動一個執行緒來處理相關的工作
    工作量大的任務,通過為 Task.Factory.StartNew 指定 TaskCreateOptions.LongRunning選項 可以使新的任務執行於獨立的執行緒上,而非使用執行緒池裡面的執行緒
  • I/O 密集型任務:當我們 await 一個操作時,它將返回 一個 TaskTask<T>
    值得注意的是,這兒並不會啟動一個執行緒

雖然計算密集型任務和 I/O 密集型任務在使用方式上沒有多大的區別,但其底層實現卻大不相同。

那我們如何區分 I/O 密集型任務和計算密集型任務呢?
比如網路操作,需要從伺服器下載我們所需的資源,它就是屬於 I/O 密集型的操作;比如我們通過排序演算法對一個陣列排序時,這時的任務就是計算密集型任務。
簡而言之,判斷一個任務是計算型還是 I/O 型,就看它佔用的 CPU 資源多,還是 I/O 資源多就可以了。

對於I/O密集型的應用,它們是以 Windows 事件為基礎的,因此不需要新建一個執行緒或使用執行緒池裡面的執行緒來執行具體工作。但我們仍然可以使用 asyncawait 來進行非同步處理,這得益於 .Net 為我們提供了一個統一的使用方式: TaskTask<T>

舉個例子,對於 I/O 密集型任務,使用方式如下

// 這是在 .NET 4.5 及以後推薦的網路請求方式
HttpClient httpClient = new HttpClient();
var result = await httpClient.GetStringAsync("https://www.baidu.com");

// 而不是以下這種方式(雖然得到的結果相同,但效能卻不一樣,並且在.NET 4.5及以後都不推薦使用)
WebClient webClient = new WebClient();
var resultStr = Task.Run(() => {
    return webClient.DownloadString("https://www.baidu.com");
});
複製程式碼

對於計算密集型應用,使用方式如下

Random random = new Random();
List<int> data = new List<int>();
for (int i = 0; i< 50000000; i++) {
    data.Add(random.Next(0, 100000));
}
// 這兒會啟動一個執行緒,來執行排序這種計算型任務
await Task.Run(() => {
    data.Sort();
});
複製程式碼

非同步方法返回 TaskTask<TResult>,具體取決於相應方法返回的是 void 還是型別 TResult。如果返回的是 void,則使用 Task,如果是 TResult,則使用 Task<TResult>

不應該使用 outref 的方式來返回值,因為這可能產生意料之外的結果。因此,我們應該儘可能的使用 Task<TResult> 中的 TResult 來組合多個返回值
另外,await不能用在返回值為 void 的方法上,否則會有編譯錯誤

針對 TAP 的編碼建議

  • asyncawait 應該搭配使用。即它們要麼都出現,要麼都不出現
  • 僅在非同步方法(即被 async 修飾的方法)中使用 await。否則會有編譯器錯誤
  • 如果一個方法內部,沒有使用 await,則該方法不應該使用 async 來修飾,否則會有編譯器警告
  • 如果一個方法為非同步方法(被 async 修飾),則它應該以 Async 結尾
  • 我們應該使用非阻塞的方式來編寫等待任務結果的程式碼:
    使用 awaitawait Task.WhenAnyawait Task.WhenAllawait Task.Delay 去等待後臺任務的結果。
    而不是 Task.WaitTask.ResultTask.WaitAnyTask.WaitAllThread.Sleep,因為這些方式會阻塞當前執行緒。

    即如果需要等待或暫停,我們應該使用 .NET 4.5 提供的 await 關鍵字,而不是使用 .NET 4.5 之前的版本提供的方式
  • 如果是計算密集型任務,則應該使用 Task.Run 來執行任務;如果是耗時比較長的任務,則應該使用 Task.Factory.StartNew 並指定 TaskCreateOptions.LongRunning 選項來執行任務
  • 如果是 I/O 密集型任務,不應該使用 Task.Run
    因為 Task.Run 會在一個單獨的執行緒中執行(執行緒池或者新建一個獨立執行緒),而對於 I/O 任務來說,啟用一個執行緒意義不大,反而會浪費執行緒資源

建立任務

要建立一個計算密集型任務,在 .NET 4.5 及以後,可採用 Task.Run 的方式來快速建立;如果需要對任務有更多的控制權,則可以使用 .NET 4.0 提供的 Task.Factory.StartNew 來建立一個任務。
對於 I/O 密集型任務,我們可以通過將 await 作用於對應的 I/O 操作方法上即可

取消任務

TAP 中,任務是可以取消的。通過 CancellationTokenSource 來管理。需要支援取消的任務,必須持有 CancellationTokenSource.Token (令牌),以便該任務可以通過 CancellationTokenSource.Cancel() 的方式來取消。

使用 CancellationTokenSource 來取消任務,有以下優點

  • 可以將令牌傳遞給多個任務,這樣可以同時取消多個任務。類似於一個老師,可以管理多個學生。
  • 可以通過 CancellationTokenSource.Token.Register 來監聽任務的取消。這樣我們可以在任務取消之後做一些其他的工作

任務處理進度

我們可以通過 IProgress<T> 介面監聽進度,如下所示

public Task ReadAsync(byte[] buffer, int offset, int count, IProgress<long> progress)
複製程式碼

.NET 4.5 提供單個 IProgress<T> 實現:Progress<T>Progress<T> 類的宣告方式如下:

// Progress<T> 類的宣告
public class Progress<T> : IProgress<T> {  
    public Progress();  
    public Progress(Action<T> handler);  
    protected virtual void OnReport(T value);  
    public event EventHandler<T> ProgressChanged;  
}   
複製程式碼

舉個例子,假設我們需要獲取並顯示下載進度,則可以按以下方式書寫

private async void btnDownload_Click(object sender, RoutedEventArgs e) {  
    btnDownload.IsEnabled = false;  
    try {  
        txtResult.Text = await DownloadStringAsync(txtUrl.Text, new Progress<int>(p => pbDownloadProgress.Value = p));  
    }  
    finally { 
        btnDownload.IsEnabled = true; 
    }  
} 
複製程式碼

部分 API 介紹

Task.WhenAll

此方法可以幫助我們同時等待多個任務,所有任務結束(正常結束、異常結束)後返回

這裡需要注意的是,如果單個任務有異常產生,這些異常會合併到 AggregateException 中。我們可以通過 AggregateException.InnerExceptions 來得到異常列表;也可以使用 AggregateException.Handle 來對每個異常進行處理,示例程式碼如下

public static async void EmailAsync() {
    List<string> addrs = new List<string>();
    IEnumerable<Task> asyncOps = addrs.Select(addr => SendMailAsync(addr));
    try {
        await Task.WhenAll(asyncOps);
    } catch (AggregateException ex) {
        // 可以通過 InnerExceptions 來得到內部返回的異常
        var exceptions = ex.InnerExceptions;
        // 也可以使用 Handle 對每個異常進行處理
        ex.Handle(innerEx => {
            // 此處的演示僅僅為了說明 ex.Handle 可以對異常進行單獨處理
            // 實際專案中不一定會丟擲此異常

            if (innerEx is OperationCanceledException oce) {
                // 對 OperationCanceledException 進行單獨的處理
                return true;
            } else if (innerEx is UnauthorizedAccessException uae) {
                // 對 UnauthorizedAccessException 進行單獨處理
                return true;
            }
            return false;
        });
    }
}
複製程式碼

但,如果我們需要對每個任務進行更加詳細的管理,則可以使用以下方式來處理

public static async void EmailAsync() {
    List<string> addrs = new List<string>();
    IEnumerable<Task> asyncOps = addrs.Select(addr => SendMailAsync(addr));
    try {
        await Task.WhenAll(asyncOps);
    } catch (AggregateException ex) {
        // 此處可以針對每個任務進行更加具體的管理
        foreach (Task<string> task in asyncOps) {
            if (task.IsCanceled) {
            }else if (task.IsFaulted) {
            }else if (task.IsCompleted) {
            }
        }
    }
}
複製程式碼

這樣,就應該基本上足夠應對我們工作中的大部分的異常處理了

Task.WhenAny

Task.WhenAll 不同,Task.WhenAny 返回的是已完成的任務(可能只是所有任務中的幾個任務)

舉個例子,比如我們開發了一個圖片類App。我們可能需要在開啟這個頁面時,同時下載並展示多張圖片。但我們希望無論是哪一張圖片,只要下載完成,就展示出來,而不是所有的圖片都下載完了之後再展示。示例程式碼如下

List<Task<Bitmap>> imageTasks = urls.Select(imgUrl => GetBitmapAsync(imgUrl)).ToList();
// 如果我們需要對圖片做一些處理(比如灰度化),可以使用以下程式碼
// List<Task<Bitmap>> imageTasks = urls.Select(imgUrl => GetBitmapAsync(imgUrl).ContinueWith(task => ConvertToGray(task.Result)).ToList();
while(imageTasks.Count > 0) {  
    try {  
        Task<Bitmap> imageTask = await Task.WhenAny(imageTasks);
        // 移除已經下載完成的任務
        imageTasks.Remove(imageTask);  
        // 同時將該任務的圖片,在UI上呈現出來
        Bitmap image = await imageTask;  
        panel.AddImage(image);  
    } catch{}  
}
複製程式碼

Task.Delay

此方法用於暫停當前任務的執行,在指定時間之後繼續執行。

它可以與 Task.WhenAnyTask.WhenAll 結合,實現任務的超時,如下

public async void btnDownload_Click(object sender, EventArgs e) {  
    btnDownload.Enabled = false;  
    try {  
        Task<Bitmap> download = GetBitmapAsync(url); 
        // 以下的這行程式碼表示,如果在 3s 之內沒有下載完成,則認為超時
        if (download == await Task.WhenAny(download, Task.Delay(3000))) {  
            Bitmap bmp = await download;  
            pictureBox.Image = bmp;  
            status.Text = "Downloaded";  
        } else {  
            pictureBox.Image = null;  
            status.Text = "Timed out";  
            var ignored = download.ContinueWith(t => Trace("Task finally completed"));
        }  
    } finally { 
      btnDownload.Enabled = true; 
    }  
}  
複製程式碼

通過這種方式,也可以監聽使用 Task.WhenAll 時多個任務的超時,如下

Task<Bitmap[]> downloads = Task.WhenAll(from url in urls select GetBitmapAsync(url));  
if (downloads == await Task.WhenAny(downloads, Task.Delay(3000))) {  
    foreach(var bmp in downloads) 
        panel.AddImage(bmp);  
    status.Text = "Downloaded";  
} else {
    status.Text = "Timed out";  
    downloads.ContinueWith(t => Log(t));  
}
複製程式碼

另外,提供兩個有用的函式,以方便我們在專案中使用

RetryOnFail

定義如下所示

// 如果下載資源失敗後,我們希望重新下載時可以使用此方法
// 我們可以指定失敗之後,間隔多長時間才重試。
// 也可以將 retryWhen 指定為 null,以便在失敗之後立即重試
public static async Task<T> RetryOnFail<T>(Func<Task<T>> function, int maxTries, Func<Task> retryWhen) {
    for (int i = 0; i < maxTries; i++) {
        try {
            return await function().ConfigureAwait(false);
        } catch {
            if (i == maxTries - 1) throw;
        }
        if (retryWhen != null)
            await retryWhen().ConfigureAwait(false);
    }
    return default(T);
}
複製程式碼

使用方式如下,這在失敗之後,暫停 1s,然後再重試

string pageContents = await RetryOnFail(() => DownloadStringAsync(url), 3, () => Task.Delay(1000)); 
複製程式碼

或者如下,這將在失敗之後立即重試

string pageContents = await RetryOnFail(() => DownloadStringAsync(url), 3, null); 
複製程式碼

NeedOnlyOne

定義如下

public static async Task<T> NeedOnlyOne<T>(params Func<CancellationToken, Task<T>>[] functions) {
    var cts = new CancellationTokenSource();
    var tasks = functions.Select(func => func(cts.Token));
    var completed = await Task.WhenAny(tasks).ConfigureAwait(false);
    cts.Cancel();
    foreach (var task in tasks) {
        var ignored = task.ContinueWith(t => Trace.WriteLine(t), TaskContinuationOptions.OnlyOnFaulted);
    }
    return await completed;
}
複製程式碼

對於前面我們提到的下載電影的例子:獲取到速度最快的渠道之後,立即取消其他的任務。現在我們可以這樣做

var line = await NeedOnlyOne(
            token => DetectSpeedAsync("line_1", movieName, cts.Token),
            token => DetectSpeedAsync("line_2", movieName, cts.Token),
            token => DetectSpeedAsync("line_3", movieName, cts.Token)
            );
複製程式碼

以上提供的這兩個方法,在實際專案中會非常有用,在需要時可以將它們用起來。當然,通過對 `Task` 的靈活運用,可以組合出更多方便的方法出來。在具體專案中多多使用即可

關於 Task 的一些基本的用法就介紹到這兒了


至此,本節內容講解完畢。下一篇文章我們將講解 .NET 中的並行程式設計。歡迎關注公眾號【嘿嘿的學習日記】,所有的文章,都會在公眾號首發,Thank you~

公眾號二維碼

相關文章