【突然想多了解一點】可以用 Task.Run() 將同步方法包裝為異步方法嗎?
本文翻譯自《Should I expose asynchronous wrappers for synchronous methods? - Stephen Toub》,原文地址:Should I expose asynchronous wrappers for synchronous methods?(microsoft.com)
注:我會對照原文進行逐句翻譯,但是考慮到中西方表達方式以及中英文語法的差異,我會適當的修改陳述句的順序和陳述方式,此外,限于自身英文和技術水平,有些詞或者句子的翻譯并不能表達原文的意思,對于這些詞語我會同時標注原文用詞,個人水平有限,有不對的地方請多批評指教,文章中會添加我自己對原文的一些理解,有不對的地方也請多批評指教,
概述
本文將會介紹 為什么不推薦對外公開那些使用 Task.Run 將同步方法包裝為異步方法的方法,
引言
如果各位學習過或者接觸過 C# 中基于任務的異步編程,那么肯定對 Task.Run() 方法不陌生,Task.Run() 方法用于在執行緒池中運行指定的操作,Task.Run 再結合 C# 中的 async 和 await 兩個關鍵字,會讓撰寫異步代碼變的“很簡單”,就像寫同步代碼一樣,
初次嘗到異步編程的甜頭再加上對異步編程淺嘗輒止,就可能會想產生一個很普遍的想法:我要把原有的同步方法都包裝成異步方法,
例如,
//原有的同步方法
public T Foo()
{
//一些代碼
}
//設想如下
public Task<T> FooAsync()
{
return Task.Run(() => Foo());
}
注:我就這么干過,
那是否推薦這種做法呢?作者 Stephen Toub 說:別這樣er
至于原因,下面的內容都是原因,
1. 為什么要異步?
在使用一種新的技術之前我們通常會考慮一個問題,為什么要使用這種技術,它對我的程式有幫助嗎?
在我看來異步有兩個主要的好處:可擴展性(scalability) 和 負載轉移(offloading,例如回應性、并行性),
那這兩個哪一個更重要呢?這個問題一般與應用程式的型別相關,大多數客戶端應用出于負載轉移的原因而關心異步,例如要保持 UI 執行緒的回應性,而如果應用中有較多技術運算(technical computing,例如科學領域的資料計算)或者基于代理的仿真作業負荷(agent-based simulation workloads)時,可擴展性對客戶端應用也很重要,大多數服務器應用(例如 ASP.NET 應用)更多的是出于可擴展性的考慮而關心異步,當然如果需要在后端服務器中實作并行的時候,負載轉移也重要,
以下內容是我自己加的,僅供娛樂,有不對的地方請指教批評,
關于 scalability 和 offloading:不太知道應該怎么翻譯,查閱了英文釋義也沒能準確地表達出來,我做的了解如下:
- 可擴展性(scalability):指應用程式處理增加的作業量的能力,比如用一臺服務器能滿足一些要求,當添加了第二臺服務器之后,完成同樣的作業只需要一半的時間,或者說每分鐘可以處理原來兩倍數量的作業,那就表示應用的可擴展性強,可以參考 What does "scalability" mean? - Stack Overflow 和 What Is Scalability?, by Chris Shiflett,
- 負載轉移(offloading):把作業轉移到其它資源進行處理,可以參考 Computation offloading - Wikipedia,
關于 technical computing 和 agent-based simulation workload:我不太明白這兩個詞所對應的作業領域,目前理解就是有大量計算的作業,
2. 可擴展性(scalability)
異步呼叫同步方法的方式對可擴展性沒有任何幫助,因為這種方式通常還是會消耗和同步呼叫這個方法時相同數量的資源(實際上,異步呼叫同步方法使用的資源更多一點,因為需要有開銷安排一些事情),你只是使用不同的資源來做這件事,例如這種方式只是使用來自執行緒池的執行緒執行操作而不是當前正在執行的那個執行緒,
異步帶來的可擴展性這個好處是通過減少使用的資源量來實作的,這需要從異步方法的具體實作上來體現,這不是簡單的通過在同步方法的外部包裝一個異步呼叫來實作的,
以下內容是我自己加的,僅供娛樂,有不對的地方請指教批評,
真正的異步操作是很難自己去實作的,.NET 庫中提供的異步方法都是使用”標準P/Invoke異步I/O系統“實作的,這種真正的異步操作不會有其它執行緒的參與,所以自己基于.NET中提供的同步方法包裝的異步方法是不會有助于可擴展性的,可以參考 Stephen Cleary 的文章:There Is No Thread (stephencleary.com),這篇文章后續可能會進行翻譯,方便自己快速回顧,
舉個例子,有一個同步方法 Sleep(),該方法在 N 毫秒后才會結束執行:
public void Sleep(int millisecondsTimeout)
{
Thread.Sleep(millisecondsTimeout);
}
接下來,需要為 Sleep() 方法創建一個異步版本,下面是第一種實作方式,使用 Task.Run() 方法將原有的 Sleep() 方法包裹起來:
public Task SleepAsync(int millisecondsTimeout)
{
return Task.Run(() => Sleep(millisecondsTimeout));
}
然后看第二種實作方式,這種實作方式沒有使用原有的 Sleep() 方法,而是重寫內部實作以消耗更少的資源:
public Task SleepAsync(int millisecondsTimeout)
{
TaskCompletionSource<bool> tcs = null;
var t = new Timer(delegate { tcs.TrySetResult(true); }, null, –1, -1);
tcs = new TaskCompletionSource<bool>(t);
t.Change(millisecondsTimeout, -1);
return tcs.Task;
}
以上兩種異步的實作方式都實作了相同的操作,都在指定時間后才結束任務并回傳,但是,從可擴展性的角度來說,第二種方式更具有可擴展性,第一種方式在等待期間消耗了執行緒池中的一個執行緒,而第二種方式僅僅依賴于一個有效的計時器在持續時間到期后向任務發出完成的信號,
以下內容是我自己加的,僅供參考,有不對的地方請指教批評,
第一中方式沒有減少資源消耗,只是把阻塞的執行緒從呼叫它的執行緒轉到了執行緒池中的另一個執行緒,這對擴展性來說沒有提升,但它確實可以避免阻塞呼叫它的執行緒,這對 UI 應用來說是有用的,但是在異步代碼中一般會使用
Task.Delay()而不是Thread.Sleep(),兩者的區別可以參考: c# - When to use Task.Delay, when to use Thread.Sleep? - Stack Overflow,第二種方式使用了
Timer來實作相同的操作,文章中提到這可以消耗更少的資源,原因是這種方法僅依賴于一個計時器的回呼,其實Timer也是使用了執行緒池中的執行緒,只不過所有的Timer實體只會使用同一個執行緒,而且Task.Delay方法內部也使用了Timer,可以查看原始碼:runtime/Task.cs at main · dotnet/runtime (github.com),
3. 負載轉移(offloading)
異步呼叫同步方法的方式對于回應性非常有用,因為它允許將長時間運行的操作轉移到一個不同的執行緒中,重點不在于消耗了多少資源,而是在于消耗了哪些資源,
例如,在 Winform 應用程式中,主執行緒除了會執行運算操作之外還會處理 UI 訊息回圈,如果主執行緒上執行長時間的操作就會阻塞主執行緒從而導致應用程式失去回應,所以主執行緒相比其他執行緒(例如 ThreadPool 中的執行緒)來說,它對用戶體驗“更有價值”,所以,將方法的呼叫從 UI 執行緒轉移到 ThreadPool 的執行緒就能讓應用程式使用對用戶體驗來說“價值較低”的資源,這種負載轉移不需要修改原有方法的實作,它可以通過包裝原有方法來實作回應性的優勢,
異步呼叫同步方法的方式不僅對更改執行緒非常有用,而且也很有助于脫離當前背景關系(escaping the current context),
例如,有時我們需要呼叫一些第三方的代碼,但我們不適合或者不確定是否適合這樣做,比如在呼叫堆疊的更高位置存在鎖,而我們不想在持有鎖的同時呼叫第三方代碼,再比如我們的代碼也可能繼續被其它用戶呼叫,而這些用戶并不希望我們的代碼花費很長時間,那我們就可以異步呼叫第三方的代碼,而不是作為呼叫堆疊上更高層的一部分去同步呼叫它,
以下內容是我自己加的,僅供參考,有不對的地方請指教批評,
這部分沒有太明白,翻譯也就會不準確,建議可以閱讀原文,我大概理解就是通過異步呼叫把某部分代碼和異步方法外的執行環境分隔開,
異步呼叫同步方法的方式對于并行也很重要,并行編程就是把一個問題分解成可以同時處理的子問題,
如果我們把一個問題拆分為多個子問題,然后依次處理每個子問題,那就不存在任何并行,因為整個問題會在單個執行緒上進行處理,相反,如果我們通過異步呼叫將子問題轉移到另一個執行緒,那就可以同時處理多個子問題,與回應性一樣,這種負載轉移不需要修改原有方法的實作,可以通過包裝實作并行的優勢,
4. 上面說的一大堆和文章標題有什么關系?
回到核心問題:是否應該為實際上是同步的方法公開一個異步入口點? 我們在 .NET 4.5 中基于任務的異步模式的立場下應該堅定的說:不,
請注意,上述關于可伸縮性和負載轉移的討論中,我們了解到真正實作可伸縮性優勢的方法是通過修改同步方法的具體實作,而負載轉移則可以通過包裝同步方法來實作,它并不需要修改同步方法的具體實作,這就是關鍵,用簡單的異步外觀包裝同步方法不會產生任何可伸縮性優勢,而僅公開同步方法,就可以獲得一些不錯的好處,例如:
- 庫更加簡潔,這意味著這個庫的成本更低,包括開發、測驗、維護、檔案等等,這同時簡化了這個庫的用戶的選擇,雖然有更多選擇通常是一件好事,但過多的選擇往往會導致生產力下降,如果我作為用戶面對同一個操作的同步和異步方法,我經常需要評估哪一種方法是適合我在不同情況下使用的,
- 庫的用戶將會明白這個庫所公開的異步的 API 是否真正具有可擴展性優勢,因為根據共識,只有真正有助于可擴展性的 API 才會以異步方式公開,
- 是否異步呼叫同步方法的選擇由開發人員決定,圍繞同步方法的異步包裝器具有開銷(例如,分配記憶體、背景關系切換等),例如,如果您的客戶正在撰寫一個高吞吐量的服務器應用程式,他們不想將精力花費在實際上對他們沒有任何好處的開銷上,因此他們可以呼叫同步方法,如果同步方法和基于它的異步包裝方法都對公開了,那么開發人員就可能會出于可伸縮性的考慮而呼叫異步版本的方法,但實際上這種簡單包裝的異步方法不存在可伸縮性的優勢,這會引起額外的開銷而有損于他們的吞吐量,
如果開發人員需要獲得更好的可擴展性,他們就可以使用任何公開的異步 API,并且他們不必為呼叫虛假異步 API(指用異步包裝的同步方法) 承擔額外的開銷,而如果他們需要通過同步 API 實作回應性或并行性,他們可以簡單地使用 Task.Run 之類的方法包裝然后再呼叫,
在你的代碼庫中公開“基于同步的異步方法(async over sync)”的這種想法是很糟糕的,極端情況下每個方法都會同時公開它的同步和異步形式,有很多人問過我這種做法,他們想為長時間運行的 CPU 密集型的操作通過異步包裝器公開為異步方法,這種想法的意圖是好的:提升回應性,但就像前面所說,API 的使用者自己就可以輕松實作回應性,并且他們更加能知道應該在哪個層面上去做到這一點,而不需要針對每個呼叫進行單獨操作,另外,定義哪些操作可能是長時間運行是非常困難的,許多方法的時間復雜度通常變化很大,
以下內容是我自己加的,僅供參考,有不對的地方請指教批評,
基于同步的異步方法:
這句話的原文是 “async over sync”,按照我的理解這句話是指那些使用
Task.Run這種方法把原有的同步方法包裝成為的異步方法,或者也可以翻譯成”同步之上的異步“,大概意思就是這樣吧,
例如,Dictionary<TKey,TValue>.Add(TKey,TValue),這是一個非常快速的方法,但請記住 Dictonary 類是如何作業的:它需要對 Key 進行哈希處理才能找到正確的用來保存它的 bucket,并且它需要檢查該 Key 與 bucket 中已存在的其他項是否相等,這一系列哈希處理和相等性檢查可能會導致呼叫用戶代碼,而這些操作具體做什么或需要多長時間是不知道的,那 Dictionary 類上的每個方法都應該公開一個異步包裝器嗎?這顯然是一個極端的例子,但也有簡單點兒的例子,比如 Regex,提供給 Regex 的正則運算式模式的復雜性以及輸入字串的性質和大小可能會對 Regex 匹配的運行時間產生較大影響,以至于 Regex 現在支持可選超時,Regex 上的每個方法都應該有等價的異步方法嗎?我真的希望不會那樣,
5. 總結
我認為應該公開的異步方法只有那些比它自己的同步方法更具有可擴展性優勢的方法,不應該僅僅只為了實作負載轉移的目的而公開對應的異步方法,同步方法的呼叫者可以很容易地通過使用專門針對異步處理同步方法的功能來實作這些好處,例如 Task.Run,
當然,這也有例外,在 .NET 4.5 中就存在一些這樣的例外,例如,抽象基類 Stream 提供了 ReadAsync 和 WriteAsync 方法,在大多數情況下,Stream 的派生類使用不在記憶體中的資料源,因此派生類一般會涉及某種磁盤 I/O 或網路 I/O,而派生類很可能能夠提供利用異步 I/O 而不是阻塞執行緒的同步 I/O 的 ReadAsync 和 WriteAsync 的實作,因此派生類的擁有的 ReadAsync 和 WriteAsync 方法使其具有了可伸縮性優勢,
此外,我們希望能夠多型地使用這些方法,而不考慮具體的 Stream 型別,因此我們希望將這兩個方法作為基類上的虛擬方法,但是,基類不知道如何使用異步 I/O 來完成這些方法的基本實作,因此它所能做的最好的事情是為同步的 Read 和 Write 方法提供異步包裝器(實際上,ReadAsync 和 WriteAsync 實際上包裝了 BeginRead/EndRead 和 BeginWrite/EndWrite,而它們如果沒有被重寫,則將依次用等效的 Task.Run 包裝同步的 Read 和 Write 方法),
另一個類似的例子是 TextReader,它提供了 ReadToEndAsync 之類的方法,它在基類的實作中只是使用一個 Task 來包裝對 TextReader.ReadToEnd 的呼叫,但是,它期望開發人員實際使用的派生類會重寫 ReadToEndAsync 以提供有利于可伸縮性的實作,例如使用了 Stream.ReadAsync 方法的 StreamReader 的 ReadToEndAsync 方法,
以下內容是我自己加的,僅供參考,有不對的地方請指教批評,
聽君一席話,如聽一席話,
讀完整篇文章之后,可能會覺得好像看半天,但好像又沒有學到什么,不過我還是想說一下我自己從這篇文章看到的東西,
在我們使用異步的時候,首先要想清楚使用異步的目的是什么,如果只是因為微軟推薦使用異步或者大家都說異步好,所以就把原有的同步方法或者準備創建的新的同步方法通過
Task.Run改成異步方法,那這樣的想法是錯誤的,因為文章中已經提到,如果是為了提升性能而這么做的話是沒有意義的,它不會提升程式性能,反而可能會引起性能問題,但是如果是為了實作類似不阻塞 Winform 主執行緒的效果的話也是可以這么做的,
原文的標題是 Should I expose asynchronous wrappers for synchronous methods,是指如果我們寫的代碼是需要提供給其他人使用的,是否應該對外公開這種假異步方法,當然讀完文章后我們自然明白這種做法是不應該的,
其次,當我們想好使用異步的目的后,就要考慮如何實作異步了,文章中提到自己實作一個真正的異步是很難的,所以在自己撰寫 .NET 沒有提供的異步方法時就要慎重了,
最后,我翻譯文章主要是為了方便自己以后能快速回顧(畢竟看英文需要在腦子中先翻譯成中文才能開始消化),另外把自己看到的內容輸出出去也是一種吸收,英文和技術水平都有限,有不對的地方請指教批評,感謝!
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/503448.html
標籤:.NET技术
