C# Async/Await Explained: Complete Guide with Examples [2026] - NDepend Blog (original) (raw)
May 22, 2026 9 minutes read
Mastering C# async / await is key to building fast, scalable .NET apps. Introduced in C# 5 back in 2012, the C# async and await keywords transformed how developers handle I/O-bound work like database calls or web APIs. Where synchronous code blocks threads and wastes resources, C# async await keeps threads free, improves responsiveness, and scales much better under load.
In this article we’ll cover the pitfalls of synchronous code, how .NET handles threads, C# async best practices, and how to use CancellationToken to cancel tasks cleanly. Let’s dive in!
In short: in C#, async marks a method so it can contain await, and await suspends that method until a Task completes – freeing the thread to do other work instead of blocking on it. The pair lets a single thread serve many I/O operations at once, which is why async code scales so well.
- The Problem with Usual C# Sequential Programming
- Understand C# Asynchronous Programming with async and await
- async vs await: What’s the Difference?
- CPU-Bound
- I/O-Bound
- CPU-Bound vs I/O-Bound: Which Approach?
- Understanding the Workflow: What Happens at Runtime?
- The caller’s point of view
- How do the C# keywords async and await work?
- .NET Task Library
- The plethora of asynchronous I/O Bound .NET APIs
- Async Streams: await foreach and IAsyncEnumerable
- C# Asynchronous Programming: Points to keep in mind
- How C# async and await Impact the Workflow
- Decompiling the Magic Behind the C# async await Keywords
- The Synchronization Context in C# async await
- The WPF and Winforms SynchronizationContext behavior
- Why do we need SynchronizationContext in WPF and Winforms scenarios?
- What is the runtime workflow in both WPF examples above?
- Disabling the WPF and Winforms SynchronizationContext behavior with task.ConfigureAwait(false)
- No SynchronizationContext in ASP.NET Core
- Common C# async/await Mistakes (and How to Avoid Them)
- Guidelines for Using the C# Keywords async and await
- Frequently Asked Questions about C# async/await
- Conclusion
The Problem with Usual C# Sequential Programming
Synchronous C# code runs tasks one after another, holding the thread until each one completes. That is fine for quick, CPU-bound work but inefficient for I/O-bound tasks like database calls or web requests. While waiting, threads sit idle and waste resources. In high-concurrency scenarios this can exhaust the thread pool, causing slowdowns and unresponsive apps. For scalable systems, the synchronous model quickly reaches its limits, which is exactly the problem the C# async await pattern was designed to solve.
Understand C# Asynchronous Programming with async and await
In .NET and C#, asynchronous programming revolves around Task and Task<T> objects. Using the keywords async and await drastically changes the workflow:
async vs await: What’s the Difference?
The two keywords are often searched together but play different roles. The table below sums it up:
| Keyword | What it does |
|---|---|
| async | A modifier on a method. It enables the use of await inside the method and wraps the return value in a Task or Task. On its own it creates no thread and does not make anything run in parallel. |
| await | An operator applied to a task. It suspends the current method, returns control to the caller, and resumes the rest of the method once the awaited task completes. This is where the non-blocking behavior comes from. |
In other words, async is the decorator and await is the engine. The next sections show both at work for CPU-bound and I/O-bound jobs.
CPU-Bound
To run a CPU-bound operation in the background, use Task.Run(), which returns a task that can be awaited with the await keyword.
| | // Again here is an async method that returns a Taskpublic async Task<string> CalculateResultAsync(string stringInput) { string stringResult = await Task.Run(() => CalculateComplexOutput(stringInput)); // Return the string result when the processing is completed return stringResult;}// Complex processing, here we just reverse the string inputprivate string CalculateComplexOutput(string stringInput) { StringBuilder sb = new StringBuilder(); for (int i = stringInput.Length - 1; i >= 0; i--) { sb.Append(stringInput[i]); } return sb.ToString();} | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
I/O-Bound
The C# async and await keywords aren’t just for CPU-bound tasks – they also handle I/O-bound operations like file reading or web requests. Here, await pauses execution until an async method returning a task completes.
| | // Notice that the method is declared as 'async' and returns a Taskpublic async Task<string> DownloadDataAsync() { using (var httpClient = new HttpClient()) { // Use the await keyword to execute a non-blocking GET request string stringResult = await httpClient.GetStringAsync("https://ndepend.com/data"); // Return the result obtained when the request is completed return stringResult; }} | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
CPU-Bound vs I/O-Bound: Which Approach?
Picking the wrong model is the most common async mistake. Before writing a line of code, ask whether the work waits on something external or burns the CPU:
| Work type | Typical examples | What to do |
|---|---|---|
| I/O-bound | HTTP request, database query, file read/write | Await the async API directly. Do not wrap it in Task.Run() – no thread is needed while waiting. |
| CPU-bound | Heavy computation, image processing, parsing | Offload it with await Task.Run(() => ...) so the calling thread (often the UI thread) stays free. |
A good rule of thumb: never use Task.Run() for a method that is already asynchronous. Wrapping GetStringAsync() in Task.Run() just wastes a pool thread for nothing.
Understanding the Workflow: What Happens at Runtime?
In both examples:
- A time-consuming task retrieves a string result.
- This job is started via a call prefixed with the
awaitkeyword within a method marked asasync. - The thread executing the task is an internal runtime detail, abstracted from the user. It may run on the current thread or be assigned to a thread pool thread.
- The code after the
awaitstatement – which isreturn stringResult;here – is resumed once the time-consuming job ends. - The
asyncmethod returns immediately upon encounteringawait, making it non-blocking.
The caller’s point of view
The non-blocking async method returns a Task<string>. If the caller is also async, it can:
- Await the task immediately:
string stringResult = await DownloadDataAsync(); - Start the task, perform other work, and await it later:
| | async Task<string> CallerAsync() { var taskDownloadData = DownloadDataAsync(); // do some work string stringResult = await taskDownloadData; return stringResult;} |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
The caller might not be an asynchronous method. It can just return the task to its own caller who will be responsible for awaiting it:
| | Task<string> Caller() { var taskDownloadData = DownloadDataAsync(); // do some work return taskDownloadData;} | | ------------------------------------------------------------------------------------------------------------------ |
How do the C# keywords async and await work?
The C# compiler converts the usage of the keyword await into a state machine. This state machine calls Task<T> based APIs to manage various aspects like:
- suspending execution when encountering an
awaitoperation - executing the CPU-Bound or I/O-bound task, eventually on a background thread
- resuming execution of instructions after the
awaitkeyword once the task has been completed
.NET Task Library
The .NET Base Class Library provides numerous APIs to work with tasks:
- Task continuations provide a means to chain additional job(s) once a task finishes its execution. Task continuation is achieved through the
ContinueWith()method designed to model a sequence of asynchronous operations.
| | string stringResult = await httpClient.GetStringAsync("https://ndepend.com/data") .ContinueWith(task => CalculateComplexOutput(task.Result)); | | --------------------------------------------------------------------------------------------------------------------------------------------------- |
- Awaiting multiple tasks is possible with methods like
Task.WhenAll()orTask.WhenAny(). WhileTask.WhenAll()lets you await the completion of all tasks executed concurrently to consolidate their results,Task.WhenAny()is designed to obtain the result of the task that completes first. - Cancellation of a task on time-out for example is also possible as we explain in this blog post: On replacing Thread.Abort() in .NET Core
The plethora of asynchronous I/O Bound .NET APIs
The .NET Base Class Library offers hundreds of asynchronous methods to achieve all sorts of I/O tasks including network access, database access, JSON, XML, binary serialization, file access, data compression, and more.
Here is a small example where we gather 3 website home pages to print their sizes in bytes on the console:
| | var tasks = new Task<string>[] { new HttpClient().GetStringAsync("https://www.google.com/"), new HttpClient().GetStringAsync("https://www.microsoft.com/"), new HttpClient().GetStringAsync("https://www.ndepend.com/")};await Task.WhenAll(tasks);// Print the size of the webpagesConsole.WriteLine( $"Home page sizes: {tasks.Select(t => t.Result.Length.ToString()).Aggregate((str1,str2) => str1+","+str2)}");Console.ReadKey(); | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
This program prints:
| | Home page sizes: 52891,193871,39755 | | -------------------------------------- |
Notice that this program relies on C# top-level statements that work fine with the await keyword. It would be easy to modify this program for example to read asynchronously some file content.
| | var tasks = new Task<string>[] { File.ReadAllTextAsync(@"C:\Program Files\dotnet\dotnet.exe"), File.ReadAllTextAsync(@"C:\Windows\explorer.exe"), File.ReadAllTextAsync(@"C:\Windows\py.exe"),};... | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Running these three downloads with Task.WhenAll() is also a quick win for performance. Awaiting them one after another would make the total time the sum of all three; firing them together and awaiting the group makes it roughly the time of the slowest one.
Async Streams: await foreach and IAsyncEnumerable
Since C# 8 you can also await a sequence of values that arrive over time – paged API results, rows from a query, sensor readings – instead of a single task. A method returns IAsyncEnumerable<T>, uses yield return, and the caller consumes it with await foreach:
| | async IAsyncEnumerable<int> ReadValuesAsync() { for (int i = 0; i < 5; i++) { await Task.Delay(100); // simulate async I/O yield return i; }}await foreach (int value in ReadValuesAsync()) { Console.WriteLine(value);} | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Each item is processed as soon as it is ready, so memory stays low and the first result shows up without waiting for the whole set. See the Microsoft tutorial Generate and consume async streams for a full walkthrough.
C# Asynchronous Programming: Points to keep in mind
Before diving deeper into how await affects the workflow, keep in mind:
- The C#
asyncandawaitkeywords simplify handling both CPU-bound and I/O-bound asynchronous tasks. - Asynchronous operations are represented by
TaskandTask<T>objects. - The
asynckeyword marks a method as asynchronous, making calls non-blocking, which helps maintain UI responsiveness. - By convention, asynchronous methods use the
Asyncsuffix, e.g.,HttpClient.GetStringAsync(). - The
awaitkeyword can only be used inside anasyncmethod. awaitmust be followed by aTaskorTask<TResult>object.- When
awaitis encountered, the compiler generates a state machine to pause execution, start the asynchronous task, and resume once it completes.
How C# async and await Impact the Workflow
Let’s illustrate the C# async await workflow with the small program below. Two tasks A and B run simultaneously.
- Task A runs within a method marked as
async.Task.Delay()is used instead ofTask.Run()to simulate a CPU-bound operation. - Task B is executed synchronously after calling the
asyncmethod.
| 1234567891011121314151617181920212223242526272829303132333435363738 | class Program { static async Task Main() { ConsoleWriteLine($"Start Program"); Task<int> taskA = MethodAAsync(); for (int i = 0; i < 5; i++) { ConsoleWriteLine($" B{i}"); Task.Delay(50).Wait(); } ConsoleWriteLine("Wait for taskA termination"); await taskA; ConsoleWriteLine($"The result of taskA is {taskA.Result}"); Console.ReadKey(); } static async Task<int> MethodAAsync() { for (int i = 0; i < 5; i++) { ConsoleWriteLine($" A{i}"); await Task.Delay(100); } int result = 123; ConsoleWriteLine($" A returns result {result}"); return result; } // Convenient helper to print colorful threadId on console static void ConsoleWriteLine(string str) { int threadId = Thread.CurrentThread.ManagedThreadId; Console.ForegroundColor = threadId == 1 ? ConsoleColor.White : ConsoleColor.Cyan; Console.WriteLine( $"{str}{new string(' ', 26 - str.Length)} Thread {threadId}"); }} |
|---|
Here is the result:
Here are some remarks about the workflow obtained:
- In the async method
MethodAAsync(), once the keywordawaitis met for the first time the remaining code in the method is actually executed by some threads from the runtime thread pool. - As a consequence, the call to the async method
MethodAAsync()is not blocking the main thread. First, it printsA0on the console and then returns to run task B synchronously while task A continues on some background threads. - This is why the async method
MethodAAsync()returns aTask<int>object namedtaskA. This task represents the remaining course ofMethodAAsync()that will printA1,A2,A3,A4and then return an integer result. - Thread #1 and then threads #4 and #7 are involved to run task A. Each time the keyword
awaitis executed, one cannot predict the pool thread that will be used to run the remaining code. The way the runtime chooses the thread is an implementation detail. Keep in mind that this behavior results from running within a console application context where there is noSynchronizationContext(this will be explained in a later section). - Similarly, in the main method, the code after
await taskA;is executed on a random pool thread. Here it appears to be the same thread that executed the last part ofMethodAAsync().
First, let’s explain the easy role of the async keyword. Then we’ll have a closer look at the influence of the await keyword.
The easy role of the async keyword
Focus your attention on the keyword await because the keyword async is just here to decorate a method. This keyword tells the C# compiler that this method contains at least one await keyword. The C# compiler could be smart enough to detect that a method contains the await keyword. However async was introduced both for readability and for backward compatibility, to avoid breaking existing code that uses await as a variable name:
Consequently, an async method with no await keyword is executed synchronously. A warning is emitted in this situation.
From now on keep in mind that the keyword async is just a decorator that tells the C# compiler that the method contains at least one occurrence of the await keyword. By the way, since the main method also contains the await keyword it must also be declared as async and must also return a Task.
Additionally, note that a main method can be declared as async since C# 7.1. In this situation, the main method name is Main() and doesn’t have the suffix Async.
Explaining the workflow resulting from the await keyword
In the program above there are two occurrences of the keyword await, in the Main() method and in the MethodAAsync() method. To understand the await workflow there are 3 points to carefully take into account:
A) The caller’s point of view:
Once the keyword await is met for the first time in an async method, the currently executing thread immediately returns. The caller doesn’t get a result but instead obtains a promise of a result, which is the Task<TResult> object returned by the async method. The caller’s thread can do some work (task B here) and then await the task later when it finally needs the result. By the way, the similar JavaScript construct is called a promise.
B) The awaited asynchronous task:
The keyword await is followed by a task object, that is not the task returned by the async method. Notice that:
- The task might be started at that point as in
await Task.Delay(100);that simulates a CPU-bound task. It could be replaced with something likeawait Task.Run(() => { ...computation intensive task running on a pool thread... });. - Or the task might already be running, as in the
await taskA;in theMain()method.
C) The task returned by the async method is the code remaining once the awaited task terminates:
The beauty is that the keyword await doesn’t lead to any wasted thread awaiting the task’s end. When the task finishes (eventually with a result in the case of Task<TResult>) the infrastructure behind the await keyword chooses a thread to resume the remaining code in the async method that is after the keyword await. This remaining code to run is nested within a task object. This is the task object returned by the async method.
- In the async method
MethodAAsync()the code after the keywordawaitis the remaining loops and then the code that returns the result. - In the async
Main()method, the code after theawaitkeyword isConsoleWriteLine($"The result of taskA is {taskA.Result}");followed byConsole.ReadKey();.
How many tasks are involved?
One key point that is not often understood is that there are at least 2 tasks involved in an async method:
- The task that follows the
awaitkeyword that runs the CPU-bound or I/O-bound code. - The task returned by the
asyncmethod that represents the remaining code to run upon the awaited task termination.
Creating tasks within a loop
In fact, in the short program above there are many more than 2 tasks involved at runtime. These few lines of code are more subtle than they look. Indeed in MethodAAsync(), the keyword await is met in each loop and each time await Task.Delay(100); simulates a new task. As a consequence at each loop, a new task is created to run the remaining code once the task Task.Delay(100); terminates. So taskA returned by MethodAAsync() is actually a chain of tasks executed sequentially and each loop is executed by a thread chosen randomly. We can see in the console output that the pool threads with IDs 7 and 4 are involved in running sub-tasks of taskA. Notice that the first loop that prints A0 executed by the main thread is not part of taskA.
Exception Handling in a C# Asynchronous Workflow
Let’s underline that the keyword await works as expected when an exception is thrown from an asynchronous processing.
| 123456789101112131415161718192021222324252627 | static async Task Main(string[] args) { ConsoleWriteLine($"Start Program"); ... ConsoleWriteLine("Wait for taskA termination"); try { await taskA; ConsoleWriteLine($"The result of taskA is {taskA.Result}"); } catch (ApplicationException ex) { ConsoleWriteLine($"{ex.GetType().ToString()} Msg:{ex.Message}"); } Console.ReadKey(); } static async Task<int> MethodAAsync() { for (int i = 0; i < 5; i++) { ConsoleWriteLine($" A{i}"); await Task.Delay(100); ConsoleWriteLine($" A throws exception"); throw new ApplicationException("Boum"); } int result = 123; ConsoleWriteLine($" A returns result {result}"); return result; }... |
|---|
Here is the output of this program:
On the other hand if the line await taskA; within the try { ... } catch scope is replaced with the line taskA.Wait();, the exception is not handled by the catch clause. This unexpected behavior illustrates well that when doing C# asynchronous programming, the keyword await should be the preferred way to await asynchronous methods.
Decompiling the Magic Behind the C# async await Keywords
Now that we detailed the await keyword workflow we can measure how powerful it is. Some magic does occur under the hood to resume the execution once the task finishes. Let’s have a look at the thread stack trace after await taskA; in the main method.
| | ... ConsoleWriteLine("Wait for taskA termination"); await taskA; Console.WriteLine(new System.Diagnostics.StackTrace()); ConsoleWriteLine($"The result of taskA is {taskA.Result}"); Console.ReadKey(); } | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
Here it is:
The simple line await taskA; leads the C# compiler to generate a lot of code to pilot the runtime through the .NET task library. Methods named like AsyncState... and MoveNext() are parts of the state machine created for us by the C# compiler to implement task continuation. Here is the assembly content decompiled with the .NET decompiler ILSpy. We can see that a whole class is generated by the compiler for each usage of the await keyword:
Here is a call graph generated by NDepend of the methods of the Task Parallel Library (TPL) called by the generated code. To obtain such a graph with methods and fields generated by the compiler, the following setting must be disabled first: NDepend > Project Properties > Analysis > Merge Code Generated by Compiler into Application Code
The details of what the C# compiler generates when it meets the keyword await are outside the scope of this article. For an in-depth exploration, you can refer to this Microsoft article: Dissecting the async methods in C#. For now bear in mind that:
- the code executed after the keyword
awaitcan eventually be executed by a thread chosen randomly by the runtime - a lot of code that calls the TPL is generated by the C# compiler to make this happen.
Now let’s explain how the tasks get dispatched through threads chosen by the runtime. This choice depends on the type of application.
The Synchronization Context in C# async await
So far we only demonstrated code executed in the context of a console application. The context in which some asynchronous code runs actually influences its workflow a lot. For example, let’s run the same code in the context of a WPF application. Since it is convenient to keep the console output to show the results of our experiments, let’s set the output type of our WPF project to Console Application, so a console is shown when the WPF app starts.
Now let’s execute the exact same code from within a WPF button click event handler.
| | public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } private async void Button_Click(object sender, RoutedEventArgs e) { ConsoleWriteLine($"Start Program"); Task<int> taskA = MethodAAsync(); ... | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Before going through the result notice that:
- An asynchronous event handler method like
Button_Click()does not require theAsyncsuffix, like for an asynchronousMain()method as explained above. - Using
async voidlike in the code sample above should be limited to asynchronous event handlers. Indeed event handlers lack return types and therefore cannot useTaskorTask<T>.
Here is the surprising result: there is no background thread involved, the main thread is used to run all tasks sequentially! Also, task A loops are postponed after task B loops (except the first one).
This mono-thread result is totally different from what we had with our console application. The key is that in a WPF context (and also in a Winforms context) there is a synchronization context object, that can be obtained through SynchronizationContext.Current.
There is no synchronization context in a console application.
C# asynchronous programming doesn’t necessarily require multi-threading at runtime. This is well illustrated by the code above.
The WPF and Winforms SynchronizationContext behavior
In the precedent WPF execution there is no background thread involved because there is no real asynchronous processing: remember we rely on await Task.Delay(100); to simulate it. Here is the output if we do some real processing instead:
Why do we need SynchronizationContext in WPF and Winforms scenarios?
In WPF there is a main UI thread that manages the UI (and a hidden thread that does the rendering). In Winforms, there is also a UI thread that does both the managing of controls and the rendering. When the UI thread gets too busy, the UI becomes unresponsive and the user gets nervous. This is why in both cases it is essential to run computation-intensive tasks on a pool thread and not on the UI thread. This is why both WPF and Winforms have their own synchronization contexts. So the runtime can resume by default on the UI thread to harness the result of an asynchronous operation that just terminated. Typically the result is used to refresh some controls with data obtained from the async job. To do so, these synchronization contexts rely on the internal infrastructure of the WPF and the Winforms platforms.
What is the runtime workflow in both WPF examples above?
In both WPF results above, we can see that A0 is displayed and then task B is running entirely from B0 to B4. Then task A can resume from A1 to A4. Remember that in task B we have Task.Delay(50).Wait(); that first simulates a task and then waits for its termination. This is a blocking call equivalent to Thread.Sleep(50); unlike await Task.Delay(100) in task A which is not blocking. This means that the UI thread is kept busy with task B until it finishes. Only upon task B termination, the UI thread becomes available again and the WPF synchronization context can resume task A on it.
Disabling the WPF and Winforms SynchronizationContext behavior with task.ConfigureAwait(false)
This WPF and Winforms asynchronous contexts’ default behavior of resuming on the main UI thread after an asynchronous call can be discarded by calling the method ConfigureAwait(false) on the task in the await call. The value false is set to the parameter ConfigureAwait(bool continueOnCapturedContext). By default, this well-named parameter is set to true. With ConfigureAwait(false) called in a WPF or Winforms context, we go back to the console behavior where a random thread from the pool is chosen by the runtime to resume after the await call.
In the execution result below, only the await usage in the method MethodAAsync() is performed with ConfigureAwait(false), not the await usage in the Button_Click() method. This is why the main thread is used to print "The result of taskA is 123", because of the WPF synchronization context behavior that is enabled here.
No SynchronizationContext in ASP.NET Core
Let’s notice that there is no synchronization context within an ASP.NET Core application. This was an important change because ASP.NET had an AspNetSynchronizationContext as discussed in this stackoverflow Q/A. On his blog, Stephen Cleary explains that the decision to discard AspNetSynchronizationContext was taken to obtain more simplicity and performance.
Finally let’s note that you can create a custom synchronization context as explained on this github page, although you won’t likely do so. For a detailed explanation of ConfigureAwait(false) you can refer to this Microsoft article: ConfigureAwait FAQ
Common C# async/await Mistakes (and How to Avoid Them)
A few patterns trip up almost everyone learning async/await. Knowing them upfront saves hours of debugging deadlocks and swallowed exceptions.
Don’t block on async code with .Result or .Wait()
Calling .Result or .Wait() on a task turns non-blocking code back into blocking code, and in a context that captures a single thread (WPF, Winforms, classic ASP.NET) it causes a deadlock: the blocked thread is exactly the one the continuation needs to resume on. The fix is to use await all the way up the call stack:
| Blocking call (avoid) | Async replacement |
|---|---|
| task.Wait() / task.Result | await task |
| Task.WaitAll(...) | await Task.WhenAll(...) |
| Task.WaitAny(...) | await Task.WhenAny(...) |
| Thread.Sleep(...) | await Task.Delay(...) |
When you truly cannot use await – for example inside a constructor or a legacy synchronous API – prefer GetAwaiter().GetResult() over .Result, because it rethrows the original exception instead of wrapping it in an AggregateException:
| | // Last resort only - await is always preferablestring data = GetDataAsync().GetAwaiter().GetResult(); | | ---------------------------------------------------------------------------------------------------------- |
Don’t run independent awaits one after another
Awaiting tasks sequentially when they could run together is a silent performance killer. Start them first, then await the group with Task.WhenAll() so they overlap rather than queue up.
Don’t use async void (except event handlers)
An async void method cannot be awaited and its exceptions cannot be caught by the caller – they crash the process instead. Reserve async void for event handlers and return Task everywhere else.
Guidelines for Using the C# Keywords async and await
- An asynchronous task should be pure: A pure method depends solely on its inputs to produce its output. Pure methods are inherently thread-safe because they don’t rely on shared state nor mutate data. Furthermore, it is easier to reason about pure methods because they have no side effects.
- Avoid async void methods:
async voidis dangerous. In the case of an exception being raised from anasync voidmethod, such as the WPF event handlerButton_Click()defined above, it cannot be caught because there is no caller client code. This is why you should useasync voidwith great caution. - Async Suffix: Append the
Asyncsuffix to the names of your asynchronous methods. This is a common convention in .NET that helps distinguish between synchronous and asynchronous methods. - Consider using
ValueTask<TResult>for better performance:TaskandTask<T>are classes. As a consequence returning multiple task objects from async methods can lead to performance issues due to object allocations, especially in tight loops or when synchronous results are involved. On the other handValueTask<TResult>is a structure and doesn’t lead to object allocation. - Error Handling: Always handle exceptions with
try..catchblocks in asynchronous methods to prevent uncaught exceptions. - Avoid Excessive Parallelism: Be cautious not to create excessive parallelism with async operations. Too many concurrent asynchronous operations can lead to resource exhaustion.
- Use Cancellation Tokens: Consider using cancellation tokens to allow users to cancel long-running asynchronous operations.
- Avoid Mixing Synchronous and Asynchronous Code: Minimize mixing synchronous and asynchronous code in the same method to maintain code clarity and consistency.
Frequently Asked Questions about C# async/await
Does async/await create a new thread?
No. By itself await creates no thread – it releases the current thread while an I/O operation is in flight, so no thread is consumed during the wait. A thread-pool thread only gets involved when you explicitly push CPU-bound work onto one with Task.Run().
What is the difference between async and await?
async is a modifier that lets a method use await and wraps its return value in a Task. await is the operator that actually suspends the method and resumes it when the awaited task completes. async decorates, await does the work.
What is the difference between Task and ValueTask?
Task<T> is a reference type allocated on the heap. ValueTask<T> is a struct that avoids that allocation when the result is already available, which helps in hot paths called very frequently. Use Task by default and ValueTask only when profiling shows allocation pressure.
Can I use await in the Main method?
Yes. Since C# 7.1 the entry point can be declared static async Task Main() and use await directly, with no Async suffix on the name.
Why does .Result or .Wait() cause a deadlock?
In a UI or classic ASP.NET context the synchronization context captures a single thread. Blocking that thread with .Result while the awaited continuation is queued to resume on the very same thread leaves both waiting forever. Using await instead avoids the trap.
When should I use async/await?
Use it for I/O-bound work – network calls, database queries, file access – and to keep a UI responsive. It will not speed up a pure CPU loop; for that, parallelize with Task.Run() or the Task Parallel Library.
Conclusion
In this article, we focused on the C# async and await keywords and the artifacts that influence their behavior, like the synchronization context and exception flow.
Hopefully, the C# async await workflow is now less mysterious to you. The await keyword leads to a lot of code generated by the C# compiler to make this workflow happen. In contrast, the async keyword primarily marks a method as asynchronous without introducing complexities similar to await.
Here are some resources to go further:
- Task Parallel Library (TPL)
- On replacing Thread.Abort() in .NET Core
- ConfigureAwait FAQ
- Dissecting the async methods in C#
- Writing async/await from scratch in C# with Scott Hanselman and Stephen Toub (1h 06 video)
This article is brought to you by the team behind NDepend — a proven .NET static analysis tool for improving code maintainability, security, and overall quality. Whether you’re modernizing a legacy .NET application or starting fresh in C#, get started with your free full-featured trial today!













