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

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)

分享創造快樂