溫故之.NET 異步

這篇文章包含如下內容web

  • 異步基礎
  • 基於任務的異步模式
  • 部分 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密集型任務任務兩種dom

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

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

那咱們如何區分 I/O 密集型任務和計算密集型任務呢?
好比網絡操做,須要從服務器下載咱們所需的資源,它就是屬於 I/O 密集型的操做;好比咱們經過排序算法對一個數組排序時,這時的任務就是計算密集型任務。
簡而言之,判斷一個任務是計算型仍是 I/O 型,就看它佔用的 CPU 資源多,仍是 I/O 資源多就能夠了。async

對於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~

公衆號二維碼
相關文章
相關標籤/搜索