C#併發編程之異步編程(線程討論)

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核心。需要注意的是,無論當前有多少個請求,我們的線程數量都是固定的。

參考以下運行圖

IO

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線程是如何切換的

image

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;
以上爲本篇文章的主要內容,希望大家多提意見,如果喜歡記得點個推薦哦

作者: 艾心

出處: https://www.cnblogs.com/edison0621/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章