1. 程式人生 > 其它 >「C#.NET 拾遺補漏」16:幾個常見的TAP非同步操作

「C#.NET 拾遺補漏」16:幾個常見的TAP非同步操作

  在本系列上一篇文章「C#.NET 拾遺補漏」15:非同步程式設計基礎 中,我們講到,現代應用程式廣泛使用的是基於任務的非同步程式設計模式(TAP),歷史的 EAP 和 AMP 模式已經過時不推薦使用。今天繼續總結一下 TAP 的非同步操作,比如取消任務、報告進度、Task.Yield()、ConfigureAwait() 和並行操作等。

  雖然實際 TAP 程式設計中很少使用到任務的狀態,但它是很多 TAP 操作機理的基礎,所以下面先從任務狀態講起。

  1.任務狀態

  Task 類為非同步操作提供了一個生命週期,這個週期由 TaskStatus 列舉表示,它有如下值:

  public enum TaskStatus

  {

  Created=0,

  WaitingForActivation=1,

  WaitingToRun=2,

  Running=3,

  WaitingForChildrenToComplete=4,

  RanToCompletion=5,

  Canceled=6,

  Faulted=7

  }

  其中 Canceled、Faulted 和 RanToCompletion 狀態一起被認為是任務的最終狀態。因此,如果任務處於最終狀態,則其 IsCompleted 屬性為 true 值。

  1.1 手動控制任務啟動

  為了支援手動控制任務啟動,並支援構造與呼叫的分離,Task 類提供了一個 Start 方法。由 Task 建構函式建立的任務被稱為冷任務,因為它們的生命週期處於 Created 狀態,只有該例項的 Start 方法被呼叫才會啟動。

  任務狀態平時用的情況不多,一般我們在封裝一個任務相關的方法時,可能會用到。比如下面這個例子,需要判斷某任務滿足一定條件才啟動:

  static void Main(string[] args)

  {

  MyTask t=new(()=>

  {

  // do something.

  });

  StartMyTask(t);

  Console.ReadKey();

  }

  public static void StartMyTask(MyTask t)

  {

  if (t.Status==TaskStatus.Created && t.Counter>10)

  {

  t.Start();

  }

  else

  {

  // 這裡模擬計數,直到 Counter>10 再執行 Start

  while (t.Counter <=10)

  {

  // Do something

  t.Counter++;

  }

  t.Start();

  }

  }

  public class MyTask : Task

  {

  public MyTask(Action action) : base(action)

  {

  }

  public int Counter { get; set; }

  }

  同樣,TaskStatus.Created 狀態以外的狀態,我們叫它熱任務,熱任務一定是被呼叫了 Start 方法啟用過的。

  1.2 確保任務已啟用

  注意,所有從 TAP 方法返回的任務都必須被啟用,比如下面這樣的程式碼:

  MyTask task=new(()=>

  {

  Console.WriteLine("Do something.");

  });

  // 在其它地方呼叫

  await task;

  在 await 之前,任務沒有執行 Task.Start 啟用,await 時程式就會一直等待下去。所以如果一個 TAP 方法內部使用 Task 建構函式來例項化要返回的 Task,那麼 TAP 方法必須在返回 Task 物件之前對其呼叫 Start。

  2.任務取消

  在 TAP 中,取消對於非同步方法實現者和消費者來說都是可選的。如果一個操作允許取消,它就會暴露一個非同步方法的過載,該方法接受一個取消令牌(CancellationToken 例項)。按照慣例,引數被命名為 cancellationToken。例如:

  public Task ReadAsync(

  byte [] buffer, int offset, int count,

  CancellationToken cancellationToken)

  非同步操作會監控這個令牌是否有取消請求。如果收到取消請求,它可以選擇取消操作,如下面的示例通過 while 來監控令牌的取消請求:

  static void Main(string[] args)

  {

  CancellationTokenSource source=new();

  CancellationToken token=source.Token;

  var task=DoWork(token);

  // 實際情況可能是在稍後的其它執行緒請求取消

  Thread.Sleep(100);

  source.Cancel();

  Console.WriteLine($"取消後任務返回的狀態:{task.Status}");

  Console.ReadKey();

  }

  public static Task DoWork(CancellationToken cancellationToken)

  {

  while (!cancellationToken.IsCancellationRequested)

  {

  // Do something.

  Thread.Sleep(1000);

  return TaskpletedTask;

  }

  return Task.FromCanceled(cancellationToken);

  }

  如果取消請求導致工作提前結束,甚至還沒有開始就收到請求取消,則 TAP 方法返回一個以 Canceled 狀態結束的任務,它的 IsCompleted 屬性為 true,且不會丟擲異常。當任務在 Canceled 狀態下完成時,任何在該任務註冊的延續任務仍都會被呼叫和執行,除非指定了諸如 NotOnCanceled 這樣的選項來選擇不延續。

  但是,如果在非同步任務在工作時收到取消請求,非同步操作也可以選擇不立刻結束,而是等當前正在執行的工作完成後再結束,並返回 RanToCompletion 狀態的任務;也可以終止當前工作並強制結束,根據實際業務情況和是否生產異常結果返回 Canceled 或 Faulted 狀態。

  對於不能被取消的業務方法,不要提供接受取消令牌的過載,這有助於向呼叫者表明目標方法是否可以取消。

  3.進度報告

  幾乎所有非同步操作都可以提供進度通知,這些通知通常用於用非同步操作的進度資訊更新使用者介面

  在 TAP 中,進度是通過 IProgress 介面來處理的,該介面作為一個引數傳遞給非同步方法。下面是一個典型的的使用示例:

  static void Main(string[] args)

  {

  var progress=new Progress(n=>

  {

  Console.WriteLine($"當前進度:{n}%");

  });

  var task=DoWork(progress);

  Console.ReadKey();

  }

  public static async Task DoWork(IProgress progress)

  {

  for (int i=1; i <=100; i++)

  {

  await Task.Delay(100);

  if (i % 10==0)

  {

  progress?.Report(i);

  };

  }

  }

  輸出如下結果:

  當前進度:10%

  當前進度:20%

  當前進度:30%

  當前進度:40%

  當前進度:50%

  當前進度:60%

  當前進度:70%

  當前進度:80%

  當前進度:90%

  當前進度:100%

  IProgress 介面支援不同的進度實現,這是由消費程式碼決定的。例如,消費程式碼可能只關心最新的進度更新,或者希望緩衝所有更新,或者希望為每個更新呼叫一個操作,等等。所有這些選項都可以通過使用該介面來實現,並根據特定消費者的需求進行定製。例如,如果本文前面的 ReadAsync 方法能夠以當前讀取的位元組數的形式報告進度,那麼進度回撥可以是一個 IProgress 介面。

  public Task ReadAsync(

  byte[] buffer, int offset, int count,

  IProgress progress)

  再如 FindFilesAsync 方法返回符合特定搜尋模式的所有檔案列表,進度回撥可以提供工作完成的百分比和當前部分結果集,它可以用一個元組來提供這個資訊。

  public Task<ReadOnlyCollection> FindFilesAsync(

  string pattern,

  IProgress<Tuple<double, ReadOnlyCollection<List>>> progress)

  或使用 API 特有的資料型別:

  public Task<ReadOnlyCollection> FindFilesAsync(

  string pattern,

  IProgress progress)

  如果 TAP 的實現提供了接受 IProgress 引數的過載,它們必須允許引數為空,在這種情況下,不會報告進度。IProgress 例項可以作為獨立的物件,允許呼叫者決定如何以及在哪裡處理這些進度資訊。

  4.Task.Yield 讓步

  我們先來看一段 Task.Yield() 的程式碼:

  Task(async ()=>

  {

  for(int i=0; i<10; i++)

  {

  await Task.Yield();

  ...

  }

  });

  這裡的 Task.Yield() 其實什麼也沒幹,它返回的是一個空任務。那 await 一個什麼也沒做的空任務有什麼用呢?

  我們知道,對計算機來說,任務排程是根據一定的優先策略來安排執行緒去執行的。如果任務太多,執行緒不夠用,任務就會進入排隊狀態。而 Yield 的作用就是讓出等待的位置,讓後面排除的任務先行。它字面上的意思就是讓步,當任務做出讓步時,其它任務就可以儘快被分配執行緒去執行。舉個現實生活中的例子,就像你在排隊辦理業務時,好不容易到你了,但你的事情並不急,自願讓出位置,讓其他人先辦理,自己假裝臨時有事到外面溜一圈什麼事也沒幹又回來重新排隊。默默地做了一次大善人。

  Task.Yield() 方法就是在非同步方法中引入一個讓步點。當代碼執行到讓步點時,就會讓出控制權,去執行緒池外面兜一圈什麼事也沒幹再回來重新排隊。

  5. 定製非同步任務後續操作

  我們可以對非同步任務執行完成的後續操作進行定製。常見的兩個方法是 ConfigureAwait 和 ContinueWith。

  5.1 ConfigureAwait

  我們先來看一段 Windows Form 中的程式碼:

  private void button1_Click(object sender, EventArgs e)

  {

  var content=CurlAsync().Result;

  ...

  }

  private async Task CurlAsync()

  {

  using (var client=new HttpClient())

  {

  returnawait client.GetStringAsync("geekgist");

  }

  }

  想必大家都知道 CurlAsync().Result 這句程式碼在 Windows Form 程式中會造成死鎖。原因是 UI 主執行緒執行到這句程式碼時,就開始等待非同步任務的結果,處於阻塞狀態。而非同步任務執行完後回來準備找 UI 執行緒繼續執行後面的程式碼時,卻發現 UI 執行緒一直處於“忙碌”的狀態,沒空搭理回來的非同步任務。這就造成了你等我,我又在等你的尷尬局面。

  當然,這種死鎖的情況只會在 Winform 和早期的 ASP.NET WebForm 中才會發生,在 Console 和 Web API 應用中不會生產死鎖。

  解決辦法很簡單,作為非同步方法呼叫者,我們只需改用 await 即可:

  private async void button1_Click(object sender, EventArgs e)

  {

  var content=await CurlAsync();

  ...

  }

  在非同步方法內部,我們也可以呼叫任務的 ConfigureAwait(false) 方法來解決這個問題。如:

  private async Task CurlAsync()

  {

  using (var client=new HttpClient())

  {

  returnawait client

  .GetStringAsync("geekgist")

  .ConfigureAwait(false);

  }

  }

  雖然兩種方法都可行,但如果作為非同步方法提供者,比如封裝一個通用庫時,考慮到難免會有新手開發者會使用 CurlAsync().Result,為了提高通用庫的容錯性,我們就可能需要使用 ConfigureAwait 來做相容。

  ConfigureAwait(false) 的作用是告訴主執行緒,我要去遠行了,你去做其它事情吧,不用等我。只要先確保一方不在一直等另一方,就能避免互相等待而造成死鎖的情況。

  5.2 ContinueWith

  ContinueWith 方法很容易理解,就是字面上的意思。作用是在非同步任務執行完成後,安排後續要執行的工作。示例程式碼:

  private void Button1_Click(object sender, EventArgs e)

  {

  var backgroundScheduler=TaskScheduler.Default;

  var uiScheduler=TaskScheduler.FromCurrentSynchronizationContext();

  Task.Factory

  .StartNew(_=> DoBackgroundComputation(), backgroundScheduler)

  .ContinueWith(_=> UpdateUI(), uiScheduler)

  .ContinueWith(_=> DoAnotherBackgroundComputation(), backgroundScheduler)

  .ContinueWith(_=> UpdateUIAgain(), uiScheduler);

  }

  如上,可以一直鏈式的寫下去,任務會按照順序執行,一個執行完再繼續執行下一個。若其中一個任務返回的狀態是 Canceled 時,後續的任務也將被取消。這個方法有好些個過載,在實際用到的時候再檢視文件即可。

  6.總結

  本文內容都是相對比較基礎的 TAP 非同步操作知識點。C# 的 TAP 很強大,提供的 API 也很多,遠不止本文講的這些,都是圍繞 Task 轉的。關鍵是要理解好基礎操作,才能靈活使用更高階的功能。希望本文對你有所幫助。