async and await

async and await

Simple implementation to achieve asynchrony.

ยท

18 min read

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:

MethodDescription
Task.StartStarts the task for the execution.
Task.DelayDelays the task by passed parameter.
Task.WaitBlocks the calling thread until the task completes.
Task.WaitAllWait until all tasks completes. Blocks all the calling thread.
Task.WaitAnyWait until any of tasks completes. Blocks all the calling thread until any of the task completes.
Task.ContinueWithAttach a continuation to the task.
Task.WhenAllBasically saying "do this when all the task has completed".
Task.WhenAnyBasically saying "do this when any of the task completes".
Task.FromResultCreates a task with the completed specified result.
Task.FromCancelledCreates a canceled task with the specified cancellation token.
Task.FromExceptionCreates a faulted task with the specified exception
Task.IsCanceledGets whether the task has been canceled.
Task.IsFaultedGets whether the task has been faulted.
Task.IsCompletedGets whether the task has been completed.
Task.ConfigureAwaitConfigure 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.FactoryAccess to Task class's factory methods.
Task.StatusCurrent 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 StatusMeaning
Created : 0The Task is initiated but not started or asynchronously waiting for completion.
WaitingForActivation : 1The Task is created but not yet scheduled for execution. (like: lazy loading of iterator block)
WaitingToRun : 2The Task is not executing, but scheduled for execution.
Running : 3The Task is currently executing its asynchronous operation.
WaitingForChildrenToComplete : 4The Task is completed but waiting for attached task to complete.
RanToCompletion : 5The Task is completed without any exceptions.
Canceled : 6The Task is canceled before it could complete its operation. (usage of cancellation token)
Faulted : 7The 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 its Send or Post method will then be invoked in that location. (Post is the non-blocking / asynchronous version of Send)

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 poolUI 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:

  1. GetAwaiter method: The Task<TResult> type should have a parameterless method named GetAwaiter 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 of GetAwaiter method is called the awaiter type.

  2. Awaitable types must implement System.Runtime.CompilerServices.INotifyCompletion interface. For eg: The Task<string> has such struct as public struct TaskAwaiter<string> : INotifyCompletion.

  3. This awaiter type must have following property and method, respectfully:

    • bool IsCompleted

    • void OnCompleted(Action<TResult>)

  4. IsCompleted indicates whether the asynchronous operation has completed or not.

  5. OnCompleted receives Action<TResult> that will be executed once the awaited operation finishes or once IsCompleted is true.

  6. 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 when IsCompleted is true. Eg: public TResult GetResult(){ ... } for TResult or public void GetResult() for Task or void.

  7. 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

  1. TAP uses async await keyword and the Task class to achieve asynchrony.

  2. Task class represents an asynchronous operations.

  3. Async methods don't wait for completion; they start and return immediately.

  4. Awaiting tasks handles exceptions immediately.

  5. Avoid using Task.Result to prevent deadlocks.

  6. CancellationToken allows canceling asynchronous operations.

  7. SynchronizationContext manages the execution context, mostly in GUI applications where there is UI thread.

  8. Asp.Net Core utilizes TAP to achieve asynchrony without a dedicated UI thread.

  9. Async methods can be generic, static, and can have various access modifiers.

  10. ref and out parameters are disallowed for simplicity in state management.

  11. Awaiting tasks can result in completion (RanToCompletion) or failure (Faulted).

  12. Proper awaiting ensures expected execution order and prevents race conditions.

  13. Common awaitable types include Task, Task<TResult>, ValueTask, and IAsyncEnumerable.

  14. Any type with suitable methods or extension methods can be awaited.

  15. Chaining async methods (asynchronous programming) creates a responsive and efficient application.

Sourcecode

More on async await can be read at:

ย