In my previous post, I provided a brief intro to asynchrony. In this post, I will continue with TAP (Task-based Asynchronous Programming) which utilizes TPL (Task parallel library) and it is a standard pattern to achieve asynchrony in modern C#.
Introduction
In TAP(Task-based Asynchronous Programming), async
await
keyword and Task
class does everything to achieve asynchronous behavior. Simply understanding, the async
is used to say "this is my asynchronous method/function". But just highlighting a function with async
keyword doesn't make it asynchronous. If the asynchronous operation which is being awaited by using await
keyword has not yet completed, it will return immediately. Task
is a common type that can be awaited. Synchronous flow like not executing next operation is still maintained in asynchronous operation but without blocking or pausing the thread. If the task is already in completed state, code flow will execute as usual.
When long running operation is being awaited, the compiler sets a continuation to be executed after the operation has completed. Meaning, execution will start from where it left after the awaited operation has completed. Continuation is a callback (delegate) that will be executed as soon as the awaited operation has completed. Simpler terms, continuation is just a piece of code to be executed when the awaited operation has completed. Continuations are Action
delegate that receives result of the asynchronous operation (Action<Task<TResult>>
or Action<Task>
). The type of the awaited asynchronous operation is usually Task
or Task<TResult>
. You can obviously have your own custom awaitable types but .NET provide ValueTask<TResult>
which is better than having your own custom type. Custom types for awaiting is not preferred due to potential compatibility and tooling issues.
Task
has a method called ContinueWith
to attach continuations. Continuations are heavily used in event-based pattern (EAP). EAP pattern involves the use of events and callback methods to notify the completion of asynchronous task.
TAP (Task-based Asynchronous Programming) doesn't rely heavily on continuations like EAP. TAP uses await
keyword which provides a way to pause or wait for the asynchronous task to complete without blocking the thread. You might be wondering "what exactly does it mean to block the thread". It means the situation where the execution of code is blocked. It cannot execute further. Basically, the thread where this execution is happening becomes unable to proceed with other tasks.
Task-based asynchronous pattern
In TAP, asynchronous operation doesn't wait for the task to complete. It starts and returns immediately. But what does it return?. It returns a sort of token. After initiating the asynchronous operation and obtaining the "token" (usually a Task
or Task<TResult>
), the execution of the calling code continues immediately. The "token" (Task
or Task<TResult>
) is used for coordination. What I mean by that is when you await a task, further execution is paused which allows asynchronous operation to finish before moving on to next step of code. You can await the task, register continuations, or perform any other operations based on the completion status of the asynchronous operation. Here's a simple example to visualize an async method:
public async Task<string> PrintSomethingAsync()
{
Console.WriteLine("Before awaiting.");
await Task.Delay(2000); // simulating time consuming operation.
Console.WriteLine("After awaiting");
return "returned val from async method.";
}
Until the code reaches await
keyword, it runs synchronously. After that, it starts an asynchronous operation which delays for 2 seconds simulating some computation. After waiting for 2 seconds, it prints and finally returns to the caller.
// calling code
string ret = await eg.PrintSomethingAsync();
Console.WriteLine($"Returned from async method: {ret}");
Console.WriteLine("All done.");
Output:
Before awaiting.
After awaiting
Returned from async method: returned val from async method.
All done.
When you await
an asynchronous operation (represented by a Task
or Task<TResult>
), it pauses the execution of the method at that point. However, it doesn't block the calling thread. Instead, it allows the calling thread to do other work while waiting for the asynchronous operation to complete. But if the await
waits for the operation to complete and also immediately returns to the caller, how does it know where to start when the task is completed? This is achieved through the continuation. The compiler generates code to capture the current context and create a continuation that gets executed when the awaited task completes.
Task
Simply put, Task
class represents an asynchronous operation. Task
encapsulates the ongoing or completed asynchronous operation. Task
class provides varieties of methods to work with asynchronous operation. Some common methods are:
Method | Description |
Task.Start | Starts the task for the execution. |
Task.Delay | Delays the task by passed parameter. |
Task.Wait | Blocks the calling thread until the task completes. |
Task.WaitAll | Wait until all tasks completes. Blocks all the calling thread. |
Task.WaitAny | Wait until any of tasks completes. Blocks all the calling thread until any of the task completes. |
Task.ContinueWith | Attach a continuation to the task. |
Task.WhenAll | Basically saying "do this when all the task has completed". |
Task.WhenAny | Basically saying "do this when any of the task completes". |
Task.FromResult | Creates a task with the completed specified result. |
Task.FromCancelled | Creates a canceled task with the specified cancellation token. |
Task.FromException | Creates a faulted task with the specified exception |
Task.IsCanceled | Gets whether the task has been canceled. |
Task.IsFaulted | Gets whether the task has been faulted. |
Task.IsCompleted | Gets whether the task has been completed. |
Task.ConfigureAwait | Configure how continuations to be scheduled. Basically it is used to specify if you want your execution after continuation to continue in same thread (if true ) or execute on a ThreadPool thread (if false ). |
Task.Factory | Access to Task class's factory methods. |
Task.Status | Current status of the task. |
Task
has a Status
property (TaskStatus
enum) which holds the current state of the operation. Task.Status
can be Running
, WaitingToRun
, WaitingForActivation
, RanToCompletion
, Faulted
, WaitingForChildrenToComplete
and Canceled
.
Task Status | Meaning |
Created : 0 | The Task is initiated but not started or asynchronously waiting for completion. |
WaitingForActivation : 1 | The Task is created but not yet scheduled for execution. (like: lazy loading of iterator block) |
WaitingToRun : 2 | The Task is not executing, but scheduled for execution. |
Running : 3 | The Task is currently executing its asynchronous operation. |
WaitingForChildrenToComplete : 4 | The Task is completed but waiting for attached task to complete. |
RanToCompletion : 5 | The Task is completed without any exceptions. |
Canceled : 6 | The Task is canceled before it could complete its operation. (usage of cancellation token) |
Faulted : 7 | The Task is completed due to an unhandled exceptions during execution. |
One more thing, Task
doesn't need to be explicitly disposed. The management of Task
instances is handled by the runtime and the GC (garbage collector).
When you await a Task
, the compiler performs an unwrapping operation. This operation handles exceptions if Task
is faulted, else extracts the result. The result type of the await
expression is the type of the awaited task's result, which will be TResult
for Task<TResult>
or void
for a plain Task
. The result is not the original Task
itself. Example:
Task<string> task = SomeHeavyTaskAsync();
string result = await task; // unwraping operation converts Task<string> to string
Here, I have a variable called task
of type Task<string>
. But when I await
, it will unwrap from Task<string>
to string
.
Exceptions in Task and why you should await a Task
I previously said that awaiting a Task
handles exceptions if faulted, else extracts the result. However, what if the task is not awaited? When the Task
is not awaited, any exceptions that may have occurred during its execution may not be observed immediately. There may be multiple exceptions during this execution period. If not awaited, they will be stored but not thrown. This "stored" exception is called AggregateException
. AggregateException
is a wrapper that aggregates multiple exception. The main reason to not throw exception like synchronous flow is to prevent unhandled exceptions causing termination of the application. Also you don't want to hamper other Task
when there is exception in some Task
which is executing in parallel. Here is an example to demonstrate it.
public async Task<string> DemonstrateExceptionAsync()
{
await Task.Delay(1000); // simulating some operation
Console.WriteLine("after some operation.");
MethodWithExceptionAsync(); // not awaiting
Console.WriteLine("after exception in other task.");
throw new Exception(message:"exception inside the method.");
return "retured from DemonstrateExceptionAsync";
}
public async Task MethodWithExceptionAsync()
{
await Task.Delay(500); // simulating some operation
Console.WriteLine("inside MethodWithExceptionAsync method.");
throw new Exception(message:"Exception outside the method.");
}
Here, when MethodWithExceptionAsync()
is initiated, it won't throw exception. Instead this exception is stored in AggregateException
. Execution continues and reach another exception which will be stored in AggregateException
too.
// calling code
var exceptionMethod = eg.DemonstrateExceptionAsync();
await Task.Delay(1500);
Console.WriteLine("after initiating method with exception.");
await exceptionMethod;
Console.WriteLine("after awaiting faulted task");
/*
Output:
after some operation.
after exception in other task.
after initiating method with exception.
inside MethodWithExceptionAsync method.
-- Finally, throws an exception --
System.Exception: 'exception inside the method.'
*/
In calling code, I didn't await the anonymous type variable exceptionMethod
which is assigned to an async method DemonstrateExceptionAsync()
. All exceptions that occurred within this method is stored in AggregateException
. Unless I await an async method or if I try to access the result of an async method (Task.Result
), there won't be any exception. So if I didn't do await exceptionMethod;
, it won't throw an error.
It's important to understand that using Task.Result
to get the result of an asynchronous operation can lead to deadlocks. Imagine a scene where you're waiting for a result using Task.Result
, and the asynchronous operation needs a specific context, like the UI thread. If that context is blocked, a deadlock may occur, which hampers the operation. Output will be the following:
after some operation.
after exception in other task.
after initiating method with exception.
after awaiting faulted task
When I finally await this async method (await exceptionMethod;
), the most recent exception of AggregateException
is thrown which is System.Exception: 'exception inside the method.'
.
Now I'm sure you know why awaiting a task is crucial. You don't want exception to be silent. Also awaiting a task ensures that the code after the await
statement is executed only after the task has completed. This flow of execution is important to consider in asynchronous programming, preventing race conditions and ensuring expected order of execution.
race condition: A race condition occurs when two or more threads can access shared data and they try to change it at the same time.
Cancelling an asynchronous operation
Cancellation in Task
is supported via CancellationToken
. This allows an asynchronous operation to cancel its operation. You can think of a real scenario like visiting a website. If that website is taking too long to load, you'll close that tab and reenter the url in new tab or probably reload the page. In such cases(reload, closing tab), browser(client) will send a cancellation token to the server to cancel the operation. This helps in resource utilization.
When an asynchronous operation is cancelled, its state becomes Canceled
. To use cancellation in Task
, you can pass CancellationToken
which will be checked periodically. Example:
// asynchronous method which can be cancelled
public async Task DoSomethingWithCancellationAsync(CancellationToken cts)
{
try
{
Console.WriteLine("Inside DoSomethingWithCancellationAsync method.");
await Task.Delay(5000, cts);
Console.WriteLine("Still inside DoSomethingWithCancellationAsync method, after long running operation.");
}
catch(OperationCanceledException oce)
{
Console.WriteLine(oce.Message);
}
}
Here, Task.Delay
method has a parameter that accepts cancellation token (cts
) as an argument. This means that if the cancellation token is signaled before the delay completes, the operation will be canceled, and an OperationCanceledException
will be thrown.
// calling code
using (CancellationTokenSource cts = new CancellationTokenSource())
{
Task someTask = eg.DoSomethingWithCancellationAsync(cts.Token); // start the task
try
{
await Task.Delay(2000); // delay further execution for 2 seconds
cts.Cancel(); // cancel the operation after 2 seconds.
await someTask;
}
catch (OperationCanceledException oce)
{
Console.WriteLine($"Operation cancelled: {oce.Message}");
}
}
/*
Output:
Inside DoSomethingWithCancellationAsync method.
A task was canceled.
*/
Here, someTask
has already started the task. someTask
will take 5 seconds to complete its operation. But I will cancel this operation in 2 seconds.
Now, what if we pass cancellation token after the operation has already completed? Well, cancellation token will have no effect. The asynchronous operation, once completed, cannot be canceled. Example:
// calling code
using (CancellationTokenSource cts = new CancellationTokenSource())
{
Task someTask = eg.DoSomethingWithCancellationAsync(cts.Token); // start the task
try
{
await Task.Delay(6000); // delay further execution for 6 seconds
cts.Cancel(); // cancel the operation after 6 seconds.
await someTask;
}
catch (OperationCanceledException oce)
{
Console.WriteLine($"Operation cancelled: {oce.Message}");
}
}
/*
Output:
Inside DoSomethingWithCancellationAsync method.
Still inside DoSomethingWithCancellationAsync method, after long running operation
*/
Here, someTask
has already started the task. someTask
will take 5 seconds to complete its operation. If I cancel this operation after 6 seconds, it won't cancel someTask
because it has already completed having its state as RanToCompletion
.
Creating an asynchronous method
Asynchronous methods can be generic, static, and can have any access modifiers. Creating an asynchronous method involves using the async
keyword in the method signature, and the method should return either Task
for void methods or Task<TResult>
for methods with a result or ValueTask<TResult>
or custom awaitable types. Parameters for async methods are similar to regular methods, but ref
and out
are disallowed.
ref
and out
are disallowed due to the complications of managing the state of these parameters across various asynchronous operation. Imagine a scenario where some async method is computing progress value (percentage). If ref
and out
were allowed, it would really be complex to work around. What if some referenced variable's value is modified in race condition?
Asynchronous anonymous function
Async anonymous function are just unnamed lambda expression but are asynchronous. It allows for concise representation of asynchronous operations without the need to define a named method. To declare it, you use the async
keyword just before a lambda expression.
Asynchronous lambda expression syntax: async (parameter-list) => (expression-body)
This results in a concise way to represent a function with asynchronous operations. Example:
public async Task DoSomethingWithAnonFuncAsync()
{
Func<Task<string>, Task<int>> getStringLengthDelegateAsync = async (str) =>
{
string task = await str; // await parameter which is of type task
return task.Length;
};
async Task<string> someStringLocalFuncAsync() // local function can be asynchronous as well
{
await Task.Delay(2000); // simulating some operation
return "something";
}
Task<int> getLengthTask = getStringLengthDelegateAsync(someStringLocalFuncAsync());
int len = await getLengthTask; // unwrapping operation by await. Task<int> --> int
Console.WriteLine($"Length of string is: {len}");
}
This code might look a lot but let me break it down. Firstly, I created a delegate of type Func<Task<string>, Task<int>>
. For implementation of this delegate, I created an anonymous async function which returns the length of the string passed(str
). I awaited the passed string of getStringLengthDelegateAsync
because it is asynchronous of Task
type. await
unwraps the result of Task<string>
to string
. Then return the length of that string. After that, you can see I created a local async function. Yes, local function can be asynchronous too. This local function named someStringLocalFuncAsync
returns Task<string>
after a 2 seconds delay.
Next, I created a variable of type Task<int>
and assign it to the delegate. I passed a local function as an argument for this delegate because you can see that the return type of the local function someStringLocalFuncAsync
and the parameter for the delegate Func<Task<string>, Task<int>> getStringLengthDelegateAsync
are the same, i.e., Task<string>
. After this, I await the Task<int>
which unwraps the result as int
and assign that to a integer variable. Finally, print that int
.
// calling code
await eg.DoSomethingWithAnonFuncAsync();
/*
Output:
Length of string is: 9
*/
Success or failure of async execution
Awaited asynchronous have two possibilities. Either it has completed without any exception (RanToCompletion
) or it has failed due to some exception (Faulted
). When an async method waits for an asynchronous operation to complete, it really means the method is doing nothing. A continuation is attached to the operation and the method returns immediately. SynchronizationContext
makes sure that the continuation executes in the right thread in GUI application.
From stackoverflow: Simply put,
SynchronizationContext
represents a location "where" code might be executed. Delegates that are passed to itsSend
orPost
method will then be invoked in that location. (Post
is the non-blocking / asynchronous version ofSend
)
In Asp.Net Core, there is no dedicated UI thread. It makes sense to eliminate the dedicated UI thread in web APIs and web server scenarios. Asp.Net Core utilizes (TAP) task-based asynchronous pattern's async-await pattern to achieve asynchrony. Console application and background services utilizes thread pool. When an asynchronous operation completes in console app or background services, the continuation may run on a different thread from the one that initiated the operation. The thread pool efficiently manages these threads, preventing the application from being blocked.
Thread pool | UI thread |
Manage and provide workers threads. | Manage user interface in GUI applications. |
Avoids the overhead of creating and destroying threads for each asynchronous operation. | Important for updating UI elements, handling user interactions, and maintaining a smooth user experience. |
Threads from the pool are reused, leading to improved performance and resource utilization. | SynchronizationContext ensures that asynchronous continuations execute in the correct UI thread context. |
await
does it all
When you await
an asynchronous operation, the compiler pause the further execution until the operation completes. The compiler generates continuation to execute as soon as the operation completes and returns. If the awaited operation has not completed, it will return immediately. The calling code can continue with its execution, ensuring non-blocking behavior. If the environment is GUI, there is a SynchronizationContext
which ensures that the continuation runs on the same UI thread. In context of Asp.Net core, where UI thread is not needed, continuations may run on a different thread (from ThreadPool).
If the awaited task completes with an exception, await
handles it. If the task is faulted, it throws an exception. If it's canceled, it throws an OperationCanceledException
. If the task completes successfully, it continues with the next line of code.
What can be awaited
Any type that has a suitable methods or extension method can be awaited. Most common type that can be awaited is Task
, Task<TResult>
, ValueTask<TResult>
, IAsyncEnumerable<T>
, or any custom type which fulfills Task-Based Asynchronous Pattern can be awaited. Awaitable type must implement System.Runtime.CompilerServices.INotifyCompletion
interface.
If we look at Task<TResult>
, C# compiler looks for the following detail to be awaitable:
GetAwaiter
method: TheTask<TResult>
type should have a parameterless method namedGetAwaiter
that only returns an awaiter object and cannot be void. For eg:Task<string>
has such method like:public TaskAwaiter<string> GetAwaiter();
. The return type ofGetAwaiter
method is called the awaiter type.Awaitable types must implement
System.Runtime.CompilerServices.INotifyCompletion
interface. For eg: TheTask<string>
has such struct aspublic struct TaskAwaiter<string> : INotifyCompletion
.This awaiter type must have following property and method, respectfully:
bool IsCompleted
void OnCompleted(Action<TResult>)
IsCompleted
indicates whether the asynchronous operation has completed or not.OnCompleted
receivesAction<TResult>
that will be executed once the awaited operation finishes or onceIsCompleted
istrue
.Awaitable type must have
GetResult
method.GetResult
is a non-generic parameterless method of the awaiter type (e.g.,TaskAwaiter<TResult>
). It retrieves the result of the awaited asynchronous operation. It is called whenIsCompleted
istrue
. Eg:public TResult GetResult(){ ... }
forTResult
orpublic void GetResult()
forTask
orvoid
.GetResult
method is also responsible for handling the result of the asynchronous operation. When the asynchronous operation has an exception, it will throw that exception to the caller. This is to make sure that any exceptions that occurred during the asynchronous operation are appropriately shown to the point where the asynchronous operation was awaited.
Full codebase goes into async mode
When there is an async method which has an asynchronous operation calling another async method and every operation is awaited, this creates a chain of asynchronous operations which ensures that the application remains responsive and efficient, handling tasks without blocking threads. Or in other word "the full codebase goes in async mode". That's how you achieve asynchrony.
public async Task A()
{
await B();
}
private async Task B()
{
await C();
}
private async Task C()
{
await D();
}
private async Task D()
{
await E();
}
private async Task E()
{
await F();
}
private async Task F()
{
await Task.Delay(1000);
}
Here, you can see a chain of asynchronous tasks where each task awaits the completion of the next one.
// calling code
var someAsyncCodeTask = asyncMode.A();
await someAsyncCodeTask;
Console.WriteLine($"All async mode task completed. Status: {someAsyncCodeTask.Status}");
/*
Output:
All async mode task completed. Status: RanToCompletion
*/
Here, call to A
method would initiate a chain of an asynchronous operation. A
is like the entry point. When you call A
, it kicks off a sequence of tasks. A
immediately says, "I'm going to wait for B
to finish before I'm done." B
is the second task. When A
is waiting for B
, it's like putting B
on the to-do list and moving on to other things. B
also says, "I'm going to wait for C
to finish." C
joins the chain. A
is waiting for B
, and B
is waiting for C
. C
says, "I'll wait for D
to finish." Same goes for Task D
and E
.
Finally, F
is the last in line. A
, B
, C
, D
, and E
are all waiting for F
.
In summary, transitioning your codebase into async mode not only improves the efficiency and responsiveness of your application but also sets the foundation for scalable and performant software, especially in scenarios where parallel execution of tasks can significantly enhance overall system.
Summary
TAP uses
async
await
keyword and theTask
class to achieve asynchrony.Task
class represents an asynchronous operations.Async methods don't wait for completion; they start and return immediately.
Awaiting tasks handles exceptions immediately.
Avoid using
Task.Result
to prevent deadlocks.CancellationToken
allows canceling asynchronous operations.SynchronizationContext
manages the execution context, mostly in GUI applications where there is UI thread.Asp.Net Core utilizes TAP to achieve asynchrony without a dedicated UI thread.
Async methods can be generic, static, and can have various access modifiers.
ref
andout
parameters are disallowed for simplicity in state management.Awaiting tasks can result in completion (
RanToCompletion
) or failure (Faulted
).Proper awaiting ensures expected execution order and prevents race conditions.
Common awaitable types include
Task
,Task<TResult>
,ValueTask
, andIAsyncEnumerable
.Any type with suitable methods or extension methods can be awaited.
Chaining async methods (asynchronous programming) creates a responsive and efficient application.
More on async await can be read at:
C# in depth Fourth edition by Jon Skeet
David Fowler's tips on best asynchronous programming practices
NDC conference: Efficient Async and Await by Filip Ekberg