Asynchronous programming keeps threads from being occupied while waiting for I/O. C# async/await lets you write non-blocking code in a style that reads like synchronous code.
Learning Objectives
- Understand the relationship between
Task/Task<T>andasync/await - Wait for all tasks with
Task.WhenAlland react to the first withTask.WhenAny - Safely cancel asynchronous operations with
CancellationToken - Exceptions from async methods surface at the
await. - Understand the purpose of
ConfigureAwait(false).
Key concepts
A Task represents work in progress, and Task<T> represents work that returns T when complete. await waits for the result and then retrieves it.
async Task<int> GetAsync()
{
await Task.Delay(1000); //Releases the thread for 1 second
return 42;
}
int n = await GetAsync(); //Looks asynchronous, but reads as synchronous
- The return type is
Task,Task<T>, orValueTask/ValueTask<T> async voidas an event handler-specific exception — Do not use in regular code (exception cannot be caught)- By convention,
Asyncis added to the end of the method name (SaveAsync,FetchAsync)
If you use await in succession, it will be a sequential wait, but if you collect Task and use await at once, it will proceed simultaneously.
Task<int> a = FetchAsync("A");
Task<int> b = FetchAsync("B");
int[] all = await Task.WhenAll(a, b); //are finished, only one
Task<int> first = await Task.WhenAny(a, b); //needs to be finished.
This is a cooperative cancellation model. The token must be confirmed on the receiving end for it to actually stop.
using CancellationTokenSource cts = new();
cts.CancelAfter(2000);
await Task.Delay(5000, cts.Token); //OperationCanceledException at 2 seconds
Most BCL methods, such as Task.Delay, HttpClient.GetAsync, and Stream.ReadAsync, take the CancellationToken parameter.
Exceptions from async methods are stored in Task and are rethrown at await. Therefore, try/catch can be placed on the calling side.
try { await DoAsync(); }
catch (InvalidOperationException ex) { /* ... */ }
If multiple exceptions occur in Task.WhenAll, only the first one is thrown, and the rest are collected in Task.Exception (AggregateException).
Use this when you want to avoid the cost of returning to the same context after awaiting in an environment with a **UI/Request context, such as WinForms/WPF/ASP.NET Framework. It's almost pointless in console apps or ASP.NET Core.
await stream.ReadAsync(buf).ConfigureAwait(false);
If you are a library writer, it is safe to add it repeatedly, and it is OK to not worry about the app code.
See as example
Example 1 — AsyncBasics: Basic flow
Console.WriteLine("Start task");
int result = await DoWorkAsync();
Console.WriteLine($"Result: {result}");
static async Task<int> DoWorkAsync()
{
await Task.Delay(1000);
await Task.Delay(1000);
return 42;
}
Execution result
Start task
...Wait 1 second
...Wait 1 second more
Result: 42
Operation terminated
Note: During await Task.Delay, the thread can do other things. This is the core reason why the UI doesn't freeze.
Task<int> t1 = FetchAsync("A", 1000);
Task<int> t2 = FetchAsync("B", 1500);
Task<int> t3 = FetchAsync("C", 800);
int[] results = await Task.WhenAll(t1, t2, t3);
Execution result
[A] Start (1000ms)
[B] Start (1500ms)
[C] Start (800ms)
[C] Complete
[A] Complete
[B] Complete
Total of results: 3300
Total time: 1500 ms (about 3300ms if sequential)
Note: It will only take as long as the longest operation. The best pattern for grouping multiple I/O calls.
Task<string> winner = await Task.WhenAny(mirrorA, mirrorB, mirrorC);
string result = await winner;
Execution result
First to respond: Tokyo (800ms)
Note: WhenAny returns the first Task itself, so you need to await one more time to get the value.
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));
try { await LongJobAsync(cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("Operation canceled"); }
Execution result
Phase 1 completed
Phase 2 completed
Operation canceled
Memo: Inside the method that receives the token, you must also pass the token like Task.Delay(ms, token). If you ignore the token, it will never stop.
try { await FailingAsync(); }
catch (InvalidOperationException ex) { Console.WriteLine($"Caught: {ex.Message}"); }
Execution result
Caught: Intended Failed
Caught in WhenAll: Intended Failed
t1 state: Faulted, t2 state: Faulted
Memo: Exceptions thrown within an async method are contained in a Task object and are thrown as is when awaiting. Just like usual synchronous code, you can catch it with try/catch.
Common Mistakes
- Using
async void— The caller cannot catch the exception, and the app dies. Do not use anything other than an event handler. task.Result/task.Wait()calls — the main culprit of deadlocks in the UI context. Alwaysawait.- Wrap simple I/O with
Task.Run(async () => ...)— I/O is already asynchronous, so there is no need to throw it into the thread pool. - After collecting the results of
WhenAll, only look at the first exception and finish — Exceptions from other tasks must also be checked to ensure they are not missed. - Just receive
CancellationTokenand not pass it to the next call — the cancellation is not propagated.
Cleanup
- Methods with
asyncreturnTask/Task<T>, and the caller retrieves the result asawait. Task.WhenAllwhen combining multiple tasks,Task.WhenAnywhen using only the fastest one.- External cancellation is
CancellationToken, which propagates to the end along the call chain. - The exception is thrown at
await, so you can handle it astry/catchas usual. - You don't need to worry about Console/Server app, you need to take care of the UI library.
Try it yourself
cd src/AsyncBasics && dotnet run
cd ../WhenAll && dotnet run
cd ../WhenAny && dotnet run
cd ../Cancellation && dotnet run
cd ../AsyncException && dotnet run