【突然想多瞭解一點】可以用 Task.Run() 將同步方法包裝為非同步方法嗎?

2022-09-02 09:09:18

【突然想多瞭解一點】可以用 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# 中的 asyncawait 兩個關鍵字,會讓編寫非同步程式碼變的「很簡單」,就像寫同步程式碼一樣。

初次嚐到非同步程式設計的甜頭再加上對非同步程式設計淺嘗輒止,就可能會想產生一個很普遍的想法:我要把原有的同步方法都包裝成非同步方法。

例如,

//原有的同步方法
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:不太知道應該怎麼翻譯,查閱了英文釋義也沒能準確地表達出來,我做的瞭解如下:

關於 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 提供了 ReadAsyncWriteAsync 方法。在大多數情況下,Stream 的派生類使用不在記憶體中的資料來源,因此派生類一般會涉及某種磁碟 I/O 或網路 I/O。而派生類很可能能夠提供利用非同步 I/O 而不是阻塞執行緒的同步 I/O 的 ReadAsyncWriteAsync 的實現,因此派生類的擁有的 ReadAsyncWriteAsync 方法使其具有了可伸縮性優勢。

此外,我們希望能夠多型地使用這些方法,而不考慮具體的 Stream 型別,因此我們希望將這兩個方法作為基礎類別上的虛擬方法。但是,基礎類別不知道如何使用非同步 I/O 來完成這些方法的基本實現,因此它所能做的最好的事情是為同步的 ReadWrite 方法提供非同步包裝器(實際上,ReadAsyncWriteAsync 實際上包裝了 BeginRead/EndReadBeginWrite/EndWrite,而它們如果沒有被重寫,則將依次用等效的 Task.Run 包裝同步的 ReadWrite 方法)。

另一個類似的例子是 TextReader,它提供了 ReadToEndAsync 之類的方法,它在基礎類別的實現中只是使用一個 Task 來包裝對 TextReader.ReadToEnd 的呼叫。但是,它期望開發人員實際使用的派生類會重寫 ReadToEndAsync 以提供有利於可伸縮性的實現,例如使用了 Stream.ReadAsync 方法的 StreamReaderReadToEndAsync 方法。

以下內容是我自己加的,僅供參考,有不對的地方請指教批評。

 

聽君一席話,如聽一席話。

讀完整篇文章之後,可能會覺得好像看半天,但好像又沒有學到什麼。不過我還是想說一下我自己從這篇文章看到的東西。

 

在我們使用非同步的時候,首先要想清楚使用非同步的目的是什麼。如果只是因為微軟推薦使用非同步或者大家都說非同步好,所以就把原有的同步方法或者準備建立的新的同步方法通過 Task.Run 改成非同步方法,那這樣的想法是錯誤的。因為文章中已經提到,如果是為了提升效能而這麼做的話是沒有意義的,它不會提升程式效能,反而可能會引起效能問題。但是如果是為了實現類似不阻塞 Winform 主執行緒的效果的話也是可以這麼做的。

 

原文的標題是 Should I expose asynchronous wrappers for synchronous methods,是指如果我們寫的程式碼是需要提供給其他人使用的,是否應該對外公開這種假非同步方法。當然讀完文章後我們自然明白這種做法是不應該的。

 

其次,當我們想好使用非同步的目的後,就要考慮如何實現非同步了。文章中提到自己實現一個真正的非同步是很難的,所以在自己編寫 .NET 沒有提供的非同步方法時就要慎重了。

 

最後,我翻譯文章主要是為了方便自己以後能快速回顧(畢竟看英文需要在腦子中先翻譯成中文才能開始消化),另外把自己看到的內容輸出出去也是一種吸收。英文和技術水平都有限,有不對的地方請指教批評,感謝!