【突然想多瞭解一點】可以用 Task.Run() 將同步方法包裝為非同步方法嗎? 本文翻譯自《Should I expose asynchronous wrappers for synchronous methods? - Stephen Toub》,原文地址:Should I expose asyn ...
【突然想多瞭解一點】可以用 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 沒有提供的非同步方法時就要慎重了。
最後,我翻譯文章主要是為了方便自己以後能快速回顧(畢竟看英文需要在腦子中先翻譯成中文才能開始消化),另外把自己看到的內容輸出出去也是一種吸收。英文和技術水平都有限,有不對的地方請指教批評,感謝!