Async All the Way Down
Return Task from every method in the chain: .Result and .Wait() block a thread and can deadlock, and async void hides failures.
Task DoWorkAsync() // never void, never .Result, never .Wait()Once one method is async, its callers must be async too. The Task it returns has to be awaited by someone, and that someone must be able to pause. This is what "async all the way down" means: from the entry point to the call that waits on the network, every method in between returns Task or Task<T>.
Why .Result and .Wait() are traps
task.Result and task.Wait() stop the current thread until the task finishes. That throws away the benefit: the thread is parked again. Worse, it can deadlock. Some environments have a synchronization context, a rule saying continuations must run on one particular thread. WinForms, WPF and classic ASP.NET all have one. The paused method needs that thread to resume, and that thread is the one blocked waiting for it, so neither moves.
ASP.NET Core has no synchronization context, so blocking there does not deadlock. It does something harder to diagnose: under load the thread pool runs dry and every request slows down at once.
async void
An async void method returns nothing to await. Nobody can know when it finished, and an exception inside it is not caught by the caller's try. It reaches the thread pool as an unhandled exception and usually ends the process. The one correct use is an event handler, because the event signature demands void.
Example
// Every method in this chain returns Task, so no thread is ever blocked.
// Top-level statements compile into an async entry point, which is what
// lets the outermost call be awaited rather than waited on.
await PrintReportAsync();
static async Task PrintReportAsync()
{
string body = await LoadBodyAsync();
Console.WriteLine($"report: {body}");
}
static async Task<string> LoadBodyAsync()
{
await Task.Delay(50);
return "3 rows";
}
// The blocking versions of that first line, for recognition only:
//
// string body = LoadBodyAsync().Result; // parks this thread
// LoadBodyAsync().Wait(); // parks this thread
//
// Both hold the thread until the work completes. In a UI app or classic
// ASP.NET they deadlock, because the paused method needs the very thread
// that is blocked waiting for it.
// Output:
// report: 3 rowsWhen to use it
- Converting a synchronous service method to async without leaving a .Result in the middle.
- Diagnosing a UI app that freezes on a button click that saves data.
- Explaining why a background operation crashed the process with no stack trace at the call site.
More examples
Top-level statements and the Main they compile into
People reach for .Result in Main because they think the entry point cannot be async. It can. Seeing the two forms side by side also explains where top-level statements put your code, which matters the first time a compiler error names a Main you never wrote.
// Shorthand: a Program.cs of top-level statements.
await RunAsync();
static async Task RunAsync()
{
await Task.Delay(10);
Console.WriteLine("done");
}
// Output:
// done
// Longhand: the same program written out. An async Task Main has been a
// valid entry point since C# 7.1, so the chain ends in an await, not a
// .Wait(). These two files behave identically.
//
// internal static class Program
// {
// private static async Task Main(string[] args)
// {
// await RunAsync();
// }
//
// private static async Task RunAsync()
// {
// await Task.Delay(10);
// Console.WriteLine("done");
// }
// }The one place async void belongs
This is the exception to the rule, and it comes with an obligation: the try/catch is not optional, because an escaping exception from async void takes the process down. If you ever need fire-and-forget outside an event handler, return a Task and keep it, rather than reaching for async void.
public sealed class SaveButton
{
private readonly DocumentStore _store;
public SaveButton(DocumentStore store)
{
_store = store;
}
// An event handler must match the delegate, which returns void, so
// async void is the only option here. Because no caller can await
// this method, every exception must be caught inside it.
private async void OnClicked(object? sender, EventArgs e)
{
try
{
await _store.SaveAsync();
ShowStatus("saved");
}
catch (IOException ex)
{
ShowStatus($"save failed: {ex.Message}");
}
}
private void ShowStatus(string message)
{
Console.WriteLine(message);
}
}Why the wrapper differs between .Result and GetAwaiter().GetResult()
You will meet GetAwaiter().GetResult() in codebases that must block, such as a constructor or a legacy interface implementation, and it is worth knowing it is no safer than .Result for deadlocks; it only changes the exception shape. If you are hunting a blocking call that deadlocks, search for all three forms.
// .Result and .Wait() wrap failures in an AggregateException.
try
{
_ = FailAsync().Result;
}
catch (AggregateException ex)
{
Console.WriteLine($"result: {ex.InnerException!.Message}");
}
// GetAwaiter().GetResult() blocks the thread in exactly the same way,
// but rethrows the original exception with no wrapper.
try
{
_ = FailAsync().GetAwaiter().GetResult();
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"getresult: {ex.Message}");
}
static async Task<int> FailAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("no connection");
}
// Output:
// result: no connection
// getresult: no connection
Discussion