[翻譯]用一個用戶場景來掌握它們
翻譯自一篇博文,原文:One user scenario to rule them all
異步系列
- 剖析C#中的異步方法
- 擴展C#中的異步方法
- C#中異步方法的性能特點。
- 用一個用戶場景來掌握它們
c#中異步方法的幾乎所有重要行為都可以基於一個用戶場景進行解釋:盡可能簡單地將現有的同步代碼遷移到異步。你應該能在方法的返回類型前面加上async
關鍵字,在方法名最後加上Async
後綴,在方法內部加上一些await
關鍵字,就能得到一個功能完整的異步方法。
這個“簡單”場景以許多不同的方式極大地影響異步方法的行為:從調度任務的延續到異常處理。這個場景聽起來似乎很合理,也很重要,但它使異步方法背後的簡單性變得非常具有欺騙性。
同步上下文(synchronization context)
UI開發是上面提到的場景特別重要的領域之一。UI線程中的耗時較長的操作使應用程序無法響應,而異步編程一直被認為是一個很好的解決方法。
private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; // 1 -- UI Thread var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread textBox.Text = "Result is: " + result; //3 -- Should be UI Thread }
這段代碼看起來十分簡單,但我們現在有一個問題。大多數UI框架都有只有專門的UI線程可以改變UI元素的限制。這意味著如果第三行代碼是在線程池上的線程被調度的任務延續,它將失敗。幸運的是,這個問題相對較老,從.NET Framework 2.0開始,就引入了同步上下文的概念。
每一個UI框架都為將代碼在專用UI線程上執行提供了特殊的實用工具。Windows Forms依靠Control.Invoke
,WPF依靠Dispatcher.Invoke
,而其他UI框架可能依靠其他什麽東西。這個概念在所有的情況下都是相似的,但是底層的細節是不同的。同步上下文把差異抽象掉,並提供一個API用於在“特殊”的上下文中執行代碼,將細節留給派生類,如WindowsFormsSynchronizationContext
DispatcherSynchronizationContext
等
為了解決線程關聯問題,C#語言作者決定在異步方法的開頭捕獲當前同步上下文,並將所有延續調度到所捕獲的上下文中。現在,await
語句之間的每個代碼塊都在UI線程中執行,這使得主場景成為可能。但解決方案也帶來了一系列其他挑戰。
死鎖
讓我們來審核一段相對較簡單的代碼。你能看出其中的問題嗎?
// UI code
private void buttonOk_Click(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
textBox.Text = "Result is: " + result;
}
// StockPrices.dll
public Task<decimal> GetStockPricesForAsync(string symbol)
{
await Task.Yield();
return 42;
}
這段代碼會造成死鎖。UI線程調用了一個異步方法,並且同步地等待它的結果。但是那個異步方法卻不能完成,因為它的第二行必須在UI線程下執行,從而造成死鎖。
你可能會說,這個問題比較容易發現,我同意你的觀點。在UI代碼中,任何對Task.Result
或Task.Wait
的調用都應該被禁止。但是如果UI代碼依賴的組件仍然同步地等待一個異步操作的結果,那麽問題依然是可能存在的:
// UI code
private void buttonOk_Click(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
textBox.Text = "Result is: " + result;
}
// StockPrices.dll
public Task<decimal> GetStockPricesForAsync(string symbol)
{
// We know that the initialization step is very fast,
// and completes synchronously in most cases,
// let‘s wait for the result synchronously for "performance reasons".
InitializeIfNeededAsync().Wait();
return Task.FromResult((decimal)42);
}
// StockPrices.dll
private async Task InitializeIfNeededAsync() => await Task.Delay(1);
這段代碼也會導致死鎖。現在,C#中兩個“眾所周知的”異步編程最佳實踐應該讓你更明白了:
- 不要通過
Task.Wait()
或Task.Result
阻塞異步代碼。 - 在類庫代碼中使用
ConfigureAwait(false)
。
上述第一條建議已經明了,現在我們解釋另一條。
Configure "awaits"
上一個例子中有兩個造成死鎖的原因:在GetStockPricesForAsync中
對Task.Wait()
的調用是阻塞的,以及在InitializeIfNeededAsync
中對任務延續的調度隱式地捕獲了同步上下文。盡管C#作者不鼓勵在異步方法中使用阻塞調用,但在很多情況下這種情況可能會發生。為了解決死鎖問題,C#語言作者提出了解決方案:Task.ConfigureAwait(continueOnCapturedContext:false)
。
public Task<decimal> GetStockPricesForAsync(string symbol)
{
InitializeIfNeededAsync().Wait();
return Task.FromResult((decimal)42);
}
private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false);
如此一來,Task.Delay(1)
的任務延續(在這個例子中也就是空語句)是在一個線程池的線程中被調度的,而不是在UI線程中,於是解決了死鎖問題。
分離(detach)同步上下文
我知道ConfigureAwait
是解決這個問題的實際辦法,但我發現它有一個很大的問題。這裏有一個小例子:
public Task<decimal> GetStockPricesForAsync(string symbol)
{
InitializeIfNeededAsync().Wait();
return Task.FromResult((decimal)42);
}
private async Task InitializeIfNeededAsync()
{
// Initialize the cache field first
await _cache.InitializeAsync().ConfigureAwait(false);
// Do some work
await Task.Delay(1);
}
你能看出其中的問題嗎?我們已經使用了ConfigureAwait(false)
所以一切都應該正常,但是並不一定。
ConfigureAwait(false)
返回一個叫ConfiguredTaskAwaitable
的自定義awaiter,並且我們已經知道:awaiter只有在任務沒有同步地完成的情況下才會被使用。也就是說如果_cache.InitializeAsync()
是同步執行完畢的,那麽我們依然可能面臨死鎖。
為了解決死鎖問題,每一個被await的task都應該被一個ConfigureAwait(false)
調用所“裝飾”。這是很繁瑣並且很容易出錯的。
另一個解決方案是:在每一個public方法中都使用一個自定義awaiter來將同步上下文從異步方法中分離:
private void buttonOk_Click(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
textBox.Text = "Result is: " + result;
}
// StockPrices.dll
public async Task<decimal> GetStockPricesForAsync(string symbol)
{
// The rest of the method is guarantee won‘t have a current sync context.
await Awaiters.DetachCurrentSyncContext();
// We can wait synchronously here and we won‘t have a deadlock.
InitializeIfNeededAsync().Wait();
return 42;
}
Awaiters.DetachCurrentSyncContext
返回下面的自定義awaiter:
public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion
{
/// <summary>
/// Returns true if a current synchronization context is null.
/// It means that the continuation is called only when a current context
/// is presented.
/// </summary>
public bool IsCompleted => SynchronizationContext.Current == null;
public void OnCompleted(Action continuation)
{
ThreadPool.QueueUserWorkItem(state => continuation());
}
public void UnsafeOnCompleted(Action continuation)
{
ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null);
}
public void GetResult() { }
public DetachSynchronizationContextAwaiter GetAwaiter() => this;
}
public static class Awaiters
{
public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext()
{
return new DetachSynchronizationContextAwaiter();
}
}
DetachSynchronizationContextAwaiter
做了以下幾點:如果異步方法是在一個非null的同步上下文中被調用的,這個awaiter會探測到這一點並且將延續調度給一個線程池線程。但如果異步方法的調用沒有任何同步上下文,那麽IsCompleted
屬性返回true
,並且任務延續將同步地執行。
這意味著,如果異步方法是被線程池中的線程調用的,那麽開銷接近於0,如果是從UI線程中被調用的,那麽你只需要付出這一次,就能從UI線程轉移到線程池線程。
這種方法的好處:
- 更不容易出錯。只有在所有被await的task被
ConfigureAwait(false)
所裝飾時,ConfigureAwait(false)
才有效。如果你不小心忘了一個,死鎖就有可能發生。而用上述的自定義awaiter方法,你只需要記住一件事:所有你類庫中的public方法的開頭都應該先調用Awaiters.DetachCurrentSyncContext()
。雖然仍有可能出錯,但概率更低了。 - 代碼更具聲明性,且更簡潔。在我看來,一個有好幾個
ConfigureAwait
調用的方法更難閱讀,對於一個新人來說可理解性也更低。
異常處理
下面兩種情況有什麽不同:
Task mayFail = Task.FromException(new ArgumentNullException());
// Case 1
try { await mayFail; }
catch (ArgumentException e)
{
// Handle the error
}
// Case 2
try { mayFail.Wait(); }
catch (ArgumentException e)
{
// Handle the error
}
第一種情況完全符合你的預期——處理錯誤,但是第二種情況並不會。TPL是為異步和並行編程設計的,而Task
/Task<T>
可以代表多個操作的結果。這就是為什麽Task.Result
和Task.Wait()
總是會拋出一個可能包含多個錯誤的AggregateException
。
但是我們的主場景改變了一切:用戶應該能夠添加async/await而無需更改錯誤處理邏輯。這也就意味著await
語句應該與Task.Result
/Task.Wait()
不同:它應該從AggregateException
實例中“unwrap”一個異常出來,今天它選擇了第一個。
如果所有基於task的方法都是異步,並且這些task不是基於並行計算,那麽一切就沒問題。但是事實並非總是如此:
try
{
Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
// await will rethrow the first exception
await Task.WhenAll(task1, task2);
}
catch (Exception e)
{
// ArgumentNullException. The second error is lost!
Console.WriteLine(e.GetType());
}
Task.WhenAll
返回一個代表了兩個錯誤的失敗任務,但是await
語句只會抽取其中第一個錯誤,然後拋出。
有兩種方法解決這個問題:
- 如果你有訪問這些任務的權限,可以手動觀察它們。
- 強制TPL將異常報裝進另一個
AggregateException
中。
try
{
Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
// t.Result forces TPL to wrap the exception into AggregateException
await Task.WhenAll(task1, task2).ContinueWith(t => t.Result);
}
catch(Exception e)
{
// AggregateException
Console.WriteLine(e.GetType());
}
async void方法
基於任務的方法返回一個承諾(promise)——一個可以用於在將來處理結果的令牌(token)。如果這個任務對象丟失,用戶的代碼將就無法觀察到該承諾。返回void
的異步操作就使得用戶代碼不可能處理錯誤情況。這就使它們變得有點兒沒什麽用,而且危險(我們馬上就會看到)。但我們的主場景卻需要這麽做:
private async void buttonOk_ClickAsync(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = await _stockPrices.GetStockPricesForAsync("MSFT");
textBox.Text = "Result is: " + result;
}
如果GetStockPricesForAsync
隨著一個錯誤而失敗了會發生什麽?這個async void方法的未處理異常會進入當前的同步上下文,觸發與同步代碼相同的行為(詳見AsyncMethodBuilder.cs的 ThrowAsync方法)。在Windows Forms中一個事件處理器的未處理異常會觸發Application.ThreadException
事件,WPF則是Application.DispatcherUnhandledException
事件等等。
但是如果一個async void方法沒有一個捕獲的同步上下文怎麽辦?在這種情況下,一個未處理異常將導致應用程序崩潰,而無法從中恢復。它不會觸發可恢復的TaskScheduler.UnobservedTaskException
事件,而會觸發不可恢復的AppDomain.UnhandledException
事件並關閉應用程序。這是有意為之的,也是應該的。
現在你應該了解另一個著名的最佳實踐:僅對UI事件處理器使用async-void方法。
不幸的是,不小心且未察覺地引入一個async void方法是相對比較容易的:
public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError)
{
// Calls ‘provider‘ N times and calls ‘onError‘ in case of an error.
}
public async Task<string> AccidentalAsyncVoid(string fileName)
{
return await ActionWithRetry(
provider:
() =>
{
return File.ReadAllTextAsync(fileName);
},
// Can you spot the issue?
onError:
async e =>
{
await File.WriteAllTextAsync(errorLogFile, e.ToString());
});
}
僅通過查看lambda表達式是很難判斷這個函數到底是返回task還是void,即使有徹底的代碼審核,這個錯誤也很容易潛入代碼庫。
結論
有一個用戶場景——對現有的UI應用程序從同步到異步代碼的簡單遷移——在很多方面影響了C#中的異步編程:
- 異步方法的延續會被調度進一個捕獲的同步上下文,可能會造成死鎖。
- 為了避免死鎖,類庫中所有的異步代碼都應該加上
ConfigureAwait(false)
。 await task;
只會拋出第一個錯誤,這使得對並行編程的異常處理更加復雜。- async void方法被用於處理UI事件,但它們可能會被不慎使用,造成在發生未處理異常時應用程序的崩潰。
天下沒有免費的午餐。在一種情況下的易用性可能會使其他情況復雜化。了解C#異步編程的歷史可以使奇怪的行為變得不那麽奇怪,並且減少異步代碼中出現錯誤的可能性。
[翻譯]用一個用戶場景來掌握它們