Design for Asynchrony

Published
Saturday, 4 December 2021
Category
Design
Author
Andrew H

In .NET, asynchrony is now everywhere. It's become ubiquitous. Async/await (more formally known as TAP - the Task-based Asynchronous Pattern) was added to C# as far back as version 5, released in 2012. Async/await is much easier to implement than the earlier asynchronous patterns available in .NET, making the addition of asynchrony to our applications a breeze - well, sort of - but more of that in a moment.

When designing solutions in .NET, we should be designing for asynchrony from the outset. Using async/await correctly can make our applications scale much better in any scenarios where we are making inter-process calls. Inter-process calls are everywhere in modern applications, so the benefit is significant. A call between processes is significantly slower than any call within a single process, and modern architectures and infrastructure practices encourage the separation of features across processes like never before. More and more we need to benefit from the ability to avoid blocking threads as we wait for inter-process calls to complete. Calls to data stores, third party REST APIs and microservices hosted in containers abound, and it is important that we do not synchronously wait for these calls to complete if our systems are to scale well.

We need to be mindful of the costs of all techniques and technologies, and there are two costs to embracing TAP

  • one is the mindset shift required to allow for it, because the other is that it is very much all nothing. While there are blog posts and forum answers that say otherwise, async/await has to be applied throughout the stack - throughout an entire application. We cannot choose to use async/await in a lower tier of an application if we do not use it at the top - in the interface or entrance to our application, be it a user interface, controller, API, or service host. Like turtles, it needs to go all the way down.

To fully embrace TAP, it is important that we think differently throughout. Anything that requires, or may require in the future, any inter-process communication - a call to a data store, a REST or SOAP endpoint, a message broker or an event bus - should be designed to support an asynchronous call, as well as everything that calls it. However, there is an important distinction between designing for asynchrony and making use of it which I want to call out. Async/await is so called because there are two parts to it. Await allows for asynchrony, and async implements it. We can design a method to support asynchrony in the future without actually needing to implement it straight away. I think it is worthwhile understanding this distinction, because it allows us to design methods to support asynchrony, even if it asynchrony is not yet required.

The viral nature of the async/await pattern - that we can only await in a method that something else awaits - makes adding it as an afterthought more painful than other changes we might make. As a result, it is worthwhile looking for places where we might need it and building it into our solution from the very outset, even if our initial MVP doesn't make use of it.

Awaitable By Design

To allow for an asynchronous call at a later date, a method needs to be awaitable. To be awaitable, the method's signature should be task-based - in other words, it should return Task, or Task. Then, our caller should await it.

Did You Know?

In truth, to be awaitable, a method doesn't strictly have to return a Task, it needs to return something that implements a GetAwaiter method - but for the majority of our work, we will overlook this subtlety and work exclusively with the System.Threading.Tasks.Task type.

In some cases a method should also support cancellation by accepting a cancellation token as a parameter. However, cancellation isn't applicable in every situation, and is something for consideration at a later date. Let's park that.

Async Implementation

If a method needs to await a method that it calls, then we need to include the async keyword on that method signature - but not before. If you have ever seen a compiler warning saying that a method is marked as async but does not await anything, that's because we've told the compiler something untrue. If we omit the async keyword, then the compiler warning is avoided, and our code is more accurate; it allows for an async call in the future, but doesn't yet need it. What we need instead is a synchronous implementation which fulfils our asynchronous design.

Sync Implementation

If you leave out the async keyword in a method signature, you will get one of two compiler errors: either that the return type doesn't match the type Task, or that the method does not have a return on all code paths. That's OK, we can fix both of those.

If you don't need to return any data - your method returns Task - then return Task.CompletedTask at the end of your method - this is a static property on type Task that exposes an object which indicates to the caller that its work is done.

If you do need to return data - your method returns Task - then instead return Task.FromResult(object)

Working Example

"Cut to the code!" I hear you cry. Quite right too.

Sprint 1

Here's a prototype console application, targeting .NET 6. This is version 0.1

namespace AsyncByDesign;

public class Program
{ 
	static void Main(string[] args)
	{
		IList<string> names;

		// Get the data to display
		Initialise();
		names = GetNames();

		// Use the data
		foreach (string name in names)
		{
			Console.WriteLine(name);
		}
	}

	private static void Initialise()
	{
		// TODO: Do intial setup work here
	}

	private static IList<string> GetNames()
	{
		IList<string> names;

		names = new List<string>()
		{
			"Sue,",
			"Joe",
			"Alice"
		};

		return names;
	}
}

OK, not the world's most valuable application yet, but it's an early prototype. Maybe it can make it onto the leaderboard of the world's most valuable applications in the next sprint, if the project progresses.

This single-file application calls the GetNames() method to get some data, and then displays it to the console. This is entirely synchronous at this stage - no async nonsense here! But wait, isn't the GetNames() method in danger of being enhanced in the future - perhaps to get the data from a data store? Yep, I'd say so. That is exactly the sort of method we should be designing for asynchrony. We need to put the Task Asynchronous Pattern in place here, before our MVP is improved in a later sprint.

We've shown our prototype to the customer, and feedback on it is good. We have an application that inspires them, and they want to continue with the project. Before the sprint is out, we should change our prototype to cater for future enhancements, because the customer is excited by the possibilities it affords.

We refactor the prototype. Here is version 1.0, the final version we would check in this sprint.

namespace AsyncByDesign;

public class Program
{ 
	static async Task Main(string[] args)
	{
		IList<string> names;

		// Get the data to display
		await InitialiseAsync();
		names = await GetNamesAsync();

		// Use the data
		foreach (string name in names)
		{
			Console.WriteLine(name);
		}
	}

	private static Task InitialiseAsync()
	{
		// TODO: Do intial setup work here
		return Task.CompletedTask;
	}

	private static Task<IList<string>> GetNamesAsync()
	{
		IList<string> names;

		names = new List<string>()
		{
			"Sue,",
			"Joe",
			"Alice"
		};

		return Task.FromResult(names);
	}
}

In this version we have made three changes.

Firstly, we have made GetNames awaitable. This is what allows us to change it in future.

private static Task<IList<string>> GetNamesAsync()
{
	IList<string> names;

	names = new List<string>()
	{
		"Sue,",
		"Joe",
		"Alice"
	};

	return Task.FromResult(names);
}

We have changed the return type from IList To Task<IList> - this is what makes it awaitable. To expose a Task to our caller to allow them to understand we have completed our work, we use Task.FromResult(names). This wraps our data with a Task which is marked as completed.

Secondly, our Initialise method is now awaitable as well, just in case we need to do asynchronous work here. This is less likely, but hey, it's an example. Bear with me. The void return has been replaced with Task, and to expose a Task from it, we have returned Task.CompletedTask.

private static Task InitialiseAsync()
{
	// TODO: Do intial setup work here
	return Task.CompletedTask;
}

Lastly, we have made our Main method async. This is required because we want to await the methods we call.

static async Task Main(string[] args)

You'll notice that I have also made changes to the method names, appending the Async suffix. This is entirely optional, but I like to do it because it conveys to the people who call it that it is an async method - one that should be awaited. If they write new code to call the method, it will be clear to them from the outset that their code must also be async.

Note that neither the InitialiseAsync() method nor GetNamesAsync() method declares the async keyword in their method signature. They do not require use of the async keyword, because these methods do not yet await anything.

Sprint 2

In sprint 2, the IT Director asks us to demonstrate that our methods can be asynchronous. He thinks we've made a mistake, and that our method signatures need to change again - and that this will affect the caller.

OK, so here's our next version:

namespace AsyncByDesign;

public class Program
{ 
	static async Task Main(string[] args)
	{
		IList<string> names;

		// Get the data to display
		await InitialiseAsync();
		names = await GetNamesAsync();

		// Use the data
		foreach (string name in names)
		{
			Console.WriteLine(name);
		}
	}

	private static async Task InitialiseAsync()
	{
		// TODO: Do intial setup work here
		await Task.Delay(200);
	}

	private static async Task<IList<string>> GetNamesAsync()
	{
		IList<string> names;

		await Task.Delay(300);

		names = new List<string>()
		{
			"Sue,",
			"Joe",
			"Alice"
		};

		return names;
	}
}

In this version, we have used Task.Delay to simulate a call to another process. Whilst it isn't actually doing any inter-process work, this does successfully demonstrate that we can make asynchronous calls, and that there were no changes to the Main method this time to achieve it. Our design from the end of sprint 1 stood the test of time, and allowed an easier change in a future sprint.

Whilst this was a very simple example, in the real world our data access call might be much lower in our application, often in a different assembly. We would have to make the whole method chain awaitable, from the external interface all the way down to the data access code. In such a scenario, making the changes later in the application's life would be more difficult. This is why we should design those async calls in from the outset; this avoids a long delay in a future sprint while we make a larger change to allow for asynchrony.

Summary

We should allow for slow, inter-process communication by making methods that might make those calls awaitable, and await their results in code that calls them. However, those methods should not use the async keyword unless and until we need to await methods they call.

A method that returns Task or Task can be awaited. That method only needs the async keyword if it, itself, will await something it calls.

Making methods awaitable - designing for asynchrony - early in an application's life can avoid more complex, cascading changes as the application evolves. This avoids risking delays to delivery; such delays can be problematic during busy periods.

A team can be consistent in its run rate - its agility - by designing for change.