當你在函式庫中有一個同步方法時,你可能會想暴露一個非同步對應方法,將其包裝成 Task.Run:
public T Foo() { /* synchronous work */ }
// Don't do this in a library:
public Task<T> FooAsync()
{
return Task.Run(() => Foo());
}
本文說明為何這種策略對於函式庫幾乎總是不正確的,以及要如何考量相關取捨。
可擴展性與卸載
非同步程式設計有兩個明顯的優點:
- 可擴展性 — 透過在 I/O 等待期間釋放執行緒來減少資源消耗。
- 卸載 — 將工作移至其他執行緒以維持響應性(例如保持 UI 執行緒空閒)或實現平行性。
這些好處需要不同的方法。 關鍵區別是: 將同步方法包裝在 裡面 Task.Run 有助於卸載,但對可擴展性毫無幫助。
為什麼 Task.Run 不提升可擴展性
真正的非同步實作能減少長時間執行時所消耗的執行緒數量。
Task.Run包裝器仍然阻擋執行緒——它只是將阻擋從一個執行緒移到另一個執行緒:
public static class TimerExampleWrong
{
public static Task SleepAsync(int millisecondsTimeout)
{
return Task.Run(() => Thread.Sleep(millisecondsTimeout));
}
}
Public Module TimerExampleWrong
Public Function SleepAsync(millisecondsTimeout As Integer) As Task
Return Task.Run(Sub() Thread.Sleep(millisecondsTimeout))
End Function
End Module
將此方法與真正非同步的實作(等待時不消耗執行緒)相比:
public static class TimerExampleRight
{
public static Task SleepAsync(int millisecondsTimeout)
{
var tcs = new TaskCompletionSource<bool>();
var timer = new Timer(
_ => tcs.TrySetResult(true), null, millisecondsTimeout, Timeout.Infinite);
tcs.Task.ContinueWith(
_ => timer.Dispose(), TaskScheduler.Default);
return tcs.Task;
}
}
Public Module TimerExampleRight
Public Function SleepAsync(millisecondsTimeout As Integer) As Task
Dim tcs As New TaskCompletionSource(Of Boolean)()
Dim tmr As New Timer(
Sub(state) tcs.TrySetResult(True), Nothing, millisecondsTimeout, Timeout.Infinite)
tcs.Task.ContinueWith(
Sub(t) tmr.Dispose(), TaskScheduler.Default)
Return tcs.Task
End Function
End Module
兩個實作都在指定的延遲後完成,但第二個實作在等待時不會阻塞任何執行緒。 對於處理大量同時請求的伺服器應用,這種差異直接影響伺服器能同時處理的請求數量。
卸貨是消費者的責任
將同步呼叫封裝在 Task.Run 中,以便將工作從 UI 執行緒中卸載。 然而,這些封裝應該由消費者處理,而非函式庫:
public static class UIOffloadExample
{
public static int ComputeIntensive(int input)
{
int result = 0;
for (int i = 0; i < input; i++)
{
result += i;
}
return result;
}
public static async Task ConsumeFromUIThreadAsync()
{
int result = await Task.Run(() => ComputeIntensive(10_000));
Console.WriteLine($"Result: {result}");
}
}
Public Module UIOffloadExample
Public Function ComputeIntensive(input As Integer) As Integer
Dim result As Integer = 0
For i As Integer = 0 To input - 1
result += i
Next
Return result
End Function
Public Async Function ConsumeFromUIThreadAsync() As Task
Dim result As Integer = Await Task.Run(Function() ComputeIntensive(10_000))
Console.WriteLine($"Result: {result}")
End Function
End Module
消費者了解自己的情境:是否在 UI 執行緒中、需要多少細緻度,以及卸載是否能帶來價值。 圖書館沒有。
為什麼函式庫不應該暴露非同步過於同步的包裝器
當函式庫僅暴露同步方法(而非非同步包裝器)時,消費者可從多方面受益:
- API 表面積減少:學習、測試與維護的方法減少。
- 無誤導性的擴展性期待:使用者知道只有暴露為非同步的方法才真正帶來擴展性優勢。
-
消費者控制:來電者選擇是否以及如何在適當的細節層級進行卸載。 高吞吐量伺服器應用程式可直接呼叫同步方法,避免不必要的開銷。
Task.Run - 更佳的效能:非同步包裝器透過記憶體分配、上下文轉換和執行緒池排程增加開銷。 對於細粒度作業來說,這種開銷可能相當可觀。
規則的例外
有些基底類別會暴露非同步方法,讓衍生類別能以真正的非同步實作覆蓋它們。 基底類別提供非同步過合的預設值。
例如,Stream 暴露 ReadAsync 和 WriteAsync。 基礎實作會封裝同步的Read和Write方法。 衍生類別如 FileStream 和 NetworkStream 這樣的類別,會覆蓋這些方法,使用異步 I/O 實作來帶來真正的擴展性優勢。
同樣地,TextReader 在基底類別中提供 ReadToEndAsync 作為包裝器,而 StreamReader 則以真正非同步的實作方式覆蓋,並在內部呼叫 ReadAsync。
這些例外之所以有效,是因為:
- 此模式設計為多態性。 呼叫者會與基底類型互動。
- 衍生型態提供真正的非同步覆寫。
指導方針
只有當非同步方法的實作比同步對應物具有真正的擴展性優勢時,才從函式庫中暴露非同步方法。 不要只為了卸載而暴露非同步方法。 這個選擇權應該留給消費者。