歡迎光臨
每天分享高質量文章

C#併發編程之異步編程(三)

寫在前面

本篇是異步編程系列的第三篇,本來計劃第三篇的內容是介紹異步編程中常用的幾個方法,但是前兩篇寫出來後,身邊的朋友總是會有其他問題,所以決定再續寫一篇,作為異步編程(一)異步編程(二)的補充。

本篇內容主要討論,在我們的異步代碼里,運行的到底是哪個執行緒,在執行長時間運行操作時執行緒發生了什麼。

Await之前

在一個被async修飾了的異步方法里,如果沒有遇到await,你的代碼將一直在呼叫執行緒上。在UI應用程式里,比如ASP.NET或者WinForm程式里,你的代碼會在ASP.NET工作執行緒或WinForm工作執行緒上運行。

我們來看一下以下範例

   1:  public async Task GetResultAsync()
   2:  {
   3:      Console.WriteLine();
   4:  
   5:      User user = this.GetUserAsync();
   6:  
   7:      //call other code
   8:  
   9:      return Task.CompletedTask;
  10:  }

以上範例里,我們在一個異步方法里呼叫了另一個異步方法,但是我們並沒有使用await,這段代碼依然在原始呼叫執行緒上執行,此時這個方法只是扮演了一個傳播異步的作用。

當我們在UI執行緒上如此編程的時候,代碼在UI執行緒是執行,在沒有執行結束之前,頁面是沒有響應的。所以如果頁面長時間沒有響應,未必是異步導致的,可能會有其他原因,需要綜合考慮,可以借助性能分析器來查看影響系統的原因在哪裡。

Await中

代碼到達await後,到底是哪一個執行緒在執行異步操作呢。

我們以ASP.NET為例,對於網絡請求之類的操作,此時沒有執行緒在執行異步操作,他們都被阻塞了,正在等待操作完成。但是如果使用了Task.Run,那麼執行該任務時就要用到執行緒池裡的執行緒了。

那麼問題來了,我們在編寫異步方法的時候,確確實實可以看到這個方法被執行了,肯定有執行緒執行才行啊。

對的,確實需要執行緒來執行,這個執行緒我們把它稱之為是IO完成端口執行緒。此執行緒等待網絡請求完成,同時它在所有網絡請求之間共享。當網絡請求完成時,操作系統中的中斷處理程式會以Job方式添加到IO完成端口的佇列中。在請求發起後,響應傳回前,它們需要依次由單個IO完成端口處理。

實際上,一般情況下只有少量IO完成端口執行緒,以充分利用多個CPU核心。需要註意的是,無論當前有多少個請求,我們的執行緒數量都是固定的。

參考以下運行圖

SynchronizationContext

我在異步編程(一)這邊文章里,有講到SynchronizationContext這個類,它是.NET框架提供的類,可以在特定型別的執行緒中運行代碼。

.NET使用各種SynchronizationContext,常見的有ASP.NET、WinForms和WPF使用的UI執行緒背景關係。SynchronizationContext的實體本身並沒有特殊的地方,其實體指向的是其子類,具有靜態成員,可以用於讀取和控制當前的SynchronizationContext。

當前SynchronizationContext是當前執行緒的屬性。在一個特定執行緒所運行到的任意的地方,都能夠獲取當前的SynchronizationContext並儲存它,並且可以使用SynchronizationContext,在所啟動的這個特定執行緒上運行代碼。綜上所述,我們並不需要知道代碼在哪個執行緒上啟動,只需要使用到SynchronizationContext,我們就可以傳回到啟動執行緒。

SynchronizationContext的重要方法是POST,它可以使委托在正確的背景關係中運行。

某些SynchronizationContext封裝單個執行緒,如UI執行緒。有些執行緒封裝了特定型別的執行緒,例如執行緒池,但可以選擇將委托發送到其中的任何一個執行緒。有些不會更改代碼運行在哪個執行緒上,而只用於監視,如ASP.NET SynchronizationContext。

到這個地方,我們就需要瞭解一個問題了。在await之前,我們的代碼是在呼叫執行緒上運行,那麼await之後,恢復方法時到了哪個執行緒上了?

實際上,大多數情況下,await後的代碼也由呼叫執行緒運行,儘管呼叫執行緒可能在等待期間做了其他事情。C#使用SynchronizationContext來完成此操作。當等待任務完成時,當前的同步背景關係被儲存為暫停方法的一部分。然後,當方法恢復時,await關鍵字的基礎結構使用POST在捕獲的同步背景關係上恢復該方法。

既然有大多數情況,那麼肯定也有小眾情況吧,以下情況可以在不同的執行緒上運行

  • SynchronizationContext具有多個執行緒,如執行緒池
  • SynchronizationContext不是真正切換執行緒的背景關係
  • 到達等待時,沒有當前的同步背景關係,例如在控制台應用程式中。
  • 將任務配置為不使用同步背景關係來恢復

註意:

對於UI應用程式來說,在同一執行緒上恢復是最重要的,我們等待之後安全的操作UI。

解析異步操作

以WinForm為例,我們設計一個按鈕,用於下載我們喜歡的小圖標。用戶點擊按鈕之後,UI執行緒啟動,並會執行響應的操作,以下圖片展示了一個異步操作的流程,以及期間UI執行緒與IO執行緒是如何切換的

1、用戶單擊該按鈕,事件處理程式GetButton_OnClick開始排隊等待運行。

2、用戶界面執行緒執行GetButton_OnClick的前半部分,包括對GetFaviconAsync的呼叫。

3、UI執行緒繼續進入GetFaviconAsync並執行其前半部分,包括對DownloadDataTaskAsync的呼叫。

4、UI執行緒繼續進入DownloadDataTaskAsync,它啟動下載並傳回任務。

5、UI執行緒離開DownloadDataTaskAsync,並傳回GgetFaviconAsync處的await。

6、當前的UI執行緒捕獲到了SynchronizationContext。

7、GetFaviconAsyncy因為有await的標識,會等待,當DownloadDataTaskAsync完成後GetFaviconAsyncy便會使用捕獲到的SynchronizationContext恢復。

8、用戶執行緒離開GetFaviconAsync,並傳回一個任務,並運行到GetButton_OnClick中的await。

9、類似地,GetButton_OnClick被等待暫停。

10、用戶執行緒離開GetButton_OnClick,可能會用於處理其他操作。【此時,我們正在等待圖標下載。可能需要幾秒鐘。註意,UI執行緒可以自由處理其他用戶操作,而IO完成端口執行緒尚未涉及到。操作期間阻塞的執行緒總數為零。】

11、下載完成,因此IO完成端口在DownloadDataTaskAsync中對邏輯進行排隊處理。

12、IO完成端口執行緒將把DownloadDataTaskAsync傳回的任務設置為完成。

13、IO完成端口執行緒在任務內部運行代碼並處理完成,並會呼叫捕獲到的同步背景關係(UI執行緒)上的POST以繼續運行接下來的代碼。

14、IO完成端口執行緒被釋放並可能在其他IO上工作。

15、用戶界面執行緒找到POST指令,並繼續執行GetFaviconAsync的後半部分,直到結束。

16、當UI執行緒離開GetFaviconAsync時,它會將GetFaviconAsync傳回的任務設置為完成。

17、在這個運行點里,當前的同步背景關係與捕獲的背景關係相同,因而無需用到POST,UI執行緒也會繼續同步進行。【此邏輯在WPF中是無效的,因為WPF經常創建新的SynchronizationContext物件。儘管它們是等效的,這使得TPL認為它需要重新POST。】

18、用戶執行緒繼續運行GetButton_OnClick的後半部分,直到結束。

總結

同步背景關係的每個實現都是以不同的方式執行POST的,這是非常消耗性能的事情。為了避免這種開銷,.NET內部也是有自己的優化機制的,它會在捕獲的SynchronizationContext與任務完成時的當前背景關係相同時,不使用POST。很有意思的是,如果你使用除錯器查看這種情況,會發現呼叫堆棧是顛倒的。

但是,當同步背景關係不同時,這就需要用到系統開銷了。在性能關鍵的代碼中或者某個代碼庫中,如果我們並不不關心使用到了哪個執行緒,這個時候我們也可以通過自己的手動操作來避開這種開銷。

在等待任務之前呼叫ConfigureaWait來完成。這樣就不會恢復到原始同步背景關係。

   1:  byte[] bytes = await httpClient.PostAsJsonAsync(url,data).ConfigureAwait(false).ReadAsStreamAsync();

不過,ConfigureAwait並不是嚴格的指令,它是.NET設計的一個標識,用來告訴運行時我們不介意方法在哪個執行緒上運行。如果該執行緒不重要(執行緒池執行緒),它將會繼續執行代碼。如果是很重要的執行緒,.NET會通過自身機制將執行緒釋放,讓它來做其他事情,而方法也將在執行緒池中恢復。.NET使用執行緒的當前的SynchronizationContext來判斷它是否重要。

前文有說過,本文再提一次,在同步代碼中運行異步代碼,可能有隱藏的問題。Task有一個Result屬性,該屬性阻止等待任務完成。如以下代碼:

   1:  var result = GetUserAsync().Result;

但是如果在只有一個執行緒(如UI執行緒)的SynchronizationContext使用就會發生死鎖現象。解決問題的方法就是,我們可以使用執行緒池執行緒來解決這個問題。如以下代碼:

   1:  var result = Task.Run(() =>GetUserAsync()).Result;
赞(0)

分享創造快樂