In this blog post, I am going to walk through Async and Await feature of C#. The async and await keywords were introduced in C# and it is part of the TAP or Task Asynchronous Programming model.
Pre async and await, if we want to write asynchronous code we will either use a Thread
class or we will use a ThreadPool
class.
Programming with Thread
class or the ThreadPool
class does a similar job as async and await. But going through the code, reading, and understanding was a little bit complex. Most importantly, debugging code was really complex.
With async and await, the programming model becomes much simpler. And I am going to demonstrate that in this blog post.
The use case for Async and Await
In this blog post, for the use case, what I am going to use today is the use case of a delivery truck driver.
Firstly, the driver in our use case will go into a distribution center for delivering the load.
Secondly, after the driver reaches the distribution center, the load which is in the truck will be verified. The load will be verified by a load verification manager.
Thirdly, if the load verification is successful, then the driver will be given a new load.
Fourthly, in the meantime, if the driver belongs to a separate trucking company, then the driver will also inform their back-office about reaching the distribution center. And all the detailed information about the transaction.
If we consider this as software, then the software will have the following components:
- The distribution center is going to have a load verification system
- And a new load assignment system
- Similarly, the driver will work with the back-office system on their side
The class model
If we define this use case in a class model, then we can have the following classes:
- Firstly, we will have a
Driver
class, which will be responsible for reporting back to the back-office - Secondly, we will have a
LoadVerifier
class, which will be responsible for verifying the load from the vehicle - Finally, we will have a
NewLoadAssigner
class, which will be responsible for assigning the new load to the vehicle
namespace AsyncAwait.Demo
{
interface IDriver
{
void ReportToBackoffice();
}
}
using System.Threading;
namespace AsyncAwait.Demo
{
class Driver : IDriver
{
public void ReportToBackoffice()
{
// Set break time
Thread.Sleep(5 * 1000);
}
}
}
namespace AsyncAwait.Demo
{
interface ILoadVerifier
{
bool Verify();
}
}
using System.Threading;
namespace AsyncAwait.Demo
{
class LoadVerifier : ILoadVerifier
{
public bool Verify()
{
// Call load verify service
Thread.Sleep(5 * 1000);
return true;
}
}
}
namespace AsyncAwait.Demo
{
interface INewLoadAssigner
{
void Assign();
}
}
using System.Threading;
namespace AsyncAwait.Demo
{
class NewLoadAssigner : INewLoadAssigner
{
public void Assign()
{
// Assign new load
Thread.Sleep(5 * 1000);
}
}
}
For the initial implementation of the three classes, for the time being, I am adding 5 seconds of sleep time using Thread.Sleep
. I am doing this to simulate a long-running I/O operation.
In a real-life scenario, if we make an HTTP call out, it can take a few milliseconds to upward of a few seconds. For mathematical simplicity, I am taking 5 seconds.
This is just to start, later we will convert these classes to use async and await.
Execution of current implementation
Firstly, I am going to use these classes in the Main
method of the Program
class to print out the time taken calling these classes in sequence. After that, I will change the implementation in progression to go to async and await.
The Main class
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
static class Program
{
static void Main(string[] args)
{
var driver = new Driver();
var verifier = new LoadVerifier();
var assigner = new NewLoadAssigner();
var startTime = DateTime.Now;
driver.ReportToBackoffice();
verifier.Verify();
assigner.Assign();
Console.WriteLine($"Total time taken: {DateTime.Now.Subtract(startTime).TotalSeconds}");
}
}
}
Now if I run the application, I will see total time taken is 15 seconds, plus a couple of milliseconds. Which is what we expected.
Run in parallel
Now, I will make a change to the Main
method itself, and use Task.Run
to run all the three methods in parallel and see what is the outcome.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
static class Program
{
static void Main(string[] args)
{
var driver = new Driver();
var verifier = new LoadVerifier();
var assigner = new NewLoadAssigner();
var startTime = DateTime.Now;
await Task.Run(() => driver.ReportToBackoffice());
await Task.Run(() => verifier.Verify());
await Task.Run(() => assigner.Assign());
Console.WriteLine($"Total time taken: {DateTime.Now.Subtract(startTime).TotalSeconds}");
}
}
}
After this change, if we run the application again, the expectation is that it will run in 5 seconds and a few milliseconds. But instead, when we run it, we will see it takes the same 15 seconds and a few milliseconds as before.
This is because we are waiting on the tasks as we run them, which causes the next task in line to wait until the first one completed. Meaning we are not really getting the advantage of multi-threads.
So, now let us go ahead and use it properly, but now instead of using Task.Run
in the Main
method, let us modify the code of the three core classes to use async and await features instead.
Change implementation with Async and Await
Now, we will change the base classes to use async and await. And also as a standard naming convention, we will change all the method names to suffixes with Async.
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
interface IDriver
{
Task ReportToBackofficeAsync();
}
}
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
class Driver : IDriver
{
public async Task ReportToBackofficeAsync()
{
// Set break time
await Task.Delay(5 * 1000);
}
}
}
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
interface ILoadVerifier
{
Task<bool> VerifyAsync();
}
}
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
class LoadVerifier : ILoadVerifier
{
public async Task<bool> VerifyAsync()
{
// Call load verify service
await Task.Delay(5 * 1000);
return true;
}
}
}
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
interface INewLoadAssigner
{
Task AssignAsync();
}
}
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
class NewLoadAssigner : INewLoadAssigner
{
public async Task AssignAsync()
{
// Assign new load
await Task.Delay(5 * 1000);
}
}
}
As you can see in all three classes I replaced Thread.Sleep
with Task.Delay
and also added the await statement in front of the Task.Delay
.
Change in Main method
Now we will change the Main
method to call all the async methods. But instead of adding an await statement in front of all the calls, we will get all the tasks returned from all the async methods. And finally, wait for all the tasks together.
This will allow all the tasks to start together and take their own time and create the parallel processing that we want to achieve.
We will use Task.WaitAll
method to achieve this. Though we can also use Task.WhenAll
. The primary difference between WaitAll
and WhenAll
is that WaitAll
is a void
method, whereas WhenAll
returns a Task
. Otherwise, they both do the same job.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
static class Program
{
static void Main(string[] args)
{
var driver = new Driver();
var verifier = new LoadVerifier();
var assigner = new NewLoadAssigner();
var startTime = DateTime.Now;
var driverTask = driver.ReportToBackofficeAsync();
var verifierTask = verifier.VerifyAsync();
var assignerTask = assigner.AssignAsync();
Task.WaitAll(driverTask, verifierTask, assignerTask);
Console.WriteLine($"Total time taken: {DateTime.Now.Subtract(startTime).TotalSeconds}");
}
}
}
Now if we run the application and see the output, we will see it is taking 5 seconds and a few milliseconds as expected.
Change to consider Assigner to run last
Based on the use case, the driver can report to the back office in parallel to the load verification process. But the new load assignment can happen only if the current load verification is successful.
To implement this logic, we will change the implementation of the Main
method.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
static class Program
{
static async Task Main(string[] args)
{
var driver = new Driver();
var verifier = new LoadVerifier();
var assigner = new NewLoadAssigner();
var startTime = DateTime.Now;
var driverTask = driver.ReportToBackofficeAsync();
var verifierTask = verifier.VerifyAsync();
Task.WaitAll(driverTask, verifierTask);
if(verifierTask.Result)
await assigner.AssignAsync();
Console.WriteLine($"Total time taken: {DateTime.Now.Subtract(startTime).TotalSeconds}");
}
}
}
As you can see in the code above, we are now executing the driver’s back-office report and warehouse verification process in parallel. But we are executing the new load assignment only if the result from the load verification task is correct.
Now if we run the application, we will see it takes 10 seconds and a few milliseconds as expected.
Tracing each task
In the above implementation in the Main
method, when we wait for the two tasks, we do not know when each one is completed. We just know that both are completed.
It is sometimes very useful to see at what point which task is completed for various reasons. So, I am going to update the above code to log when each task is completed, without compromising the parallel processing.
To achieve this, firstly, I will use a List
to add the tasks.
Secondly, I will run a while
loop on the number of tasks.
Thirdly, I will use the WhenAny
method of the Task
to get a response when any of the tasks are complete. And remove the completed task from the List
, to ensure the completed task is not used in the WhenAny
statement again and cause issues.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
static class Program
{
static async Task Main(string[] args)
{
var driver = new Driver();
var verifier = new LoadVerifier();
var assigner = new NewLoadAssigner();
var startTime = DateTime.Now;
var driverTask = driver.ReportToBackofficeAsync();
var verifierTask = verifier.VerifyAsync();
var tasks = new List<Task> { driverTask, verifierTask };
while (tasks.Count > 0)
{
var task = await Task.WhenAny(tasks);
if (task == driverTask)
Console.WriteLine("Driver task completed!");
else
Console.WriteLine("Verifier task completed!");
tasks.Remove(task);
}
if (verifierTask.Result)
await assigner.AssignAsync();
Console.WriteLine($"Total time taken: {DateTime.Now.Subtract(startTime).TotalSeconds}");
}
}
}
Now if we run the application, we will see it still takes 10 seconds and a few milliseconds to execute. But we will also log each task completion in the console without compromising the overall time taken.
Adding CPU intensive task in Main thread
So far we are only doing CPU intensive work in the child threads as a part of parallel operations. Now, I am going to add some sleep in the main thread itself to demonstrate that It will not make any change to the overall operation time. As long as the time taken by the main thread is less than the maximum time taken by any of the child threads.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncAwait.Demo
{
static class Program
{
static async Task Main(string[] args)
{
var driver = new Driver();
var verifier = new LoadVerifier();
var assigner = new NewLoadAssigner();
var startTime = DateTime.Now;
var driverTask = driver.ReportToBackofficeAsync();
var verifierTask = verifier.VerifyAsync();
for (int i = 0; i < 4; i++)
{
Console.WriteLine($"Count: {i}");
Thread.Sleep(1000);
}
var tasks = new List<Task> { driverTask, verifierTask };
while (tasks.Count > 0)
{
var task = await Task.WhenAny(tasks);
if (task == driverTask)
Console.WriteLine("Driver task completed!");
else
Console.WriteLine("Verifier task completed!");
tasks.Remove(task);
}
if (verifierTask.Result)
await assigner.AssignAsync();
Console.WriteLine($"Total time taken: {DateTime.Now.Subtract(startTime).TotalSeconds}");
}
}
}
Now if we run this application. And we will still see that the time taken will be 10 seconds and a few milliseconds. Though we added 4 seconds of sleep in the main thread.
Conclusion
Async and Await make parallel programming a breeze. Reading the code is much simpler compared to the old Thread programming model.
The debugging also becomes much more simpler.
I have captured the complete coding session in my YouTube video here.