A Conceptual Model of Async/Await

Microsoft released the async/await programming model to the world in 2012. While many of us diligent C# developers have been advocating the use of async/await since then, I have noticed many programmers still struggle with finding the benefits of async/await worth the cost of understanding. There's a few behaviors I notice when looking through code:

Developers will often go for synchronous code over asynchronous Continued use of Task.Result to force a task into performing synchronously

These behaviors indicate (to me) that developers have a hard time seeing the benefit of asynchronous code over synchronous. And to be fair, in their day-to-day development activities, it may seem like async/await just adds another layer of cognitive overhead to an already difficult job. Throughout this post, I will try to give a simple model with which to conceptualize async/await and show some of the fun things you can do with async/await.

Most of this text is also applicable to Javascript Promises, and their async/await implementation. This is because C# Tasks and Javascript Promises are both implementations of the same construct, better covered by Wikipedia than myself: https://en.wikipedia.org/wiki/Futures_and_promises.

The Simple Model

This is all that async/await does:

An await signals that you desire for your current line of code to pause execution until the task you are waiting for completes execution. Once the task completes, the code after your current line will continue execution. You can only await an object that has a GetAwaiter() method which returns an implementation of INotifyCompletion . In the wild, this is usually a Task class. An async tells the compiler that you want to use the await keyword in a function. The function must return a type Task<...> .

Uses

Now that we have a simple conceptual model for async/await, we can have a lot of fun with async/await while continuing to write code that is easy to reason with.

Imperative Code Style

Below is a simple example to illustrate the difference between using a Task with continuations and using a Task with await :

public Task Main () { GetData() .ContinueWith(myData => { var myProcessor = new Processor(); return myProcessor. Process (myData.Result); }, TaskCompletionOptions.OnlyOnRanToCompletion) .Unwrap() .Wait(); }

The above callback style model can become this:

public async Task Main ( ) { var myData = await GetData(); var myProcessor = new Processor(); await myProcessor.Process(myData); }

This contrived example obviously doesn't show much, but the code does arguably become cleaner. Async/await also opens us up to using other programming constructs, such as loops, with our asynchronous code. So taking the above example, let's say the data is an IEnumerable :

public async Task Main ( ) { var myData = await GetData(); var myProcessor = new Processor(); foreach ( var data in myData) { await myProcessor.Process(data); } }

I'm not even going to bother to try to write this with a Task chain! It's important to note that while this code does not block while waiting for the data to process, the code still does execute in-order. Async/await enables us to write non-blocking code with our typical C# programming style, which is really neat. However, we can do some even more interesting things with Tasks.

Interleaving

Interleaving is one of my favorite uses of async/await. Rather than immediately pausing execution on a long-running task, we can start execution of the task, hold onto the task itself, and then start execution of other tasks in the meanwhile. When we're actually ready for the result of the first task, we can then await it. For example:

public async Task Main ( ) { var myDataTask = GetData(); await Console.WriteLineAsync( "Please input some additional data while the other data loads!" ); var additionalData = await Console.ReadLineAsync(); var myProcessor = new Processor(); await myProcessor.Process( await myDataTask, additionalData); }

This allows gathering data from your data source while the user is keying in other data, potentially masking the time it took to retrieve the data from the data source.

Racing

Tasks also have the combinatorial helpers Task.WhenAll(Task...) and Task.WhenAny(Task...) (or Promise.all(Promise...) and Promise.race(Promise...) for those Javascript folks following along). While the applications of Task.WhenAll may seem obvious (and I will cover them soon), it is a little more difficult to find a use for Task.WhenAny . Often times, I'll use Task.WhenAny when I want to execute multiple tasks and change the control flow of my program as a result of which task completed first - timing out execution is a clear use case for this pattern.

Let's say we're waiting for our data to come in on a message bus:

public async Task< int > Main ( ) { var myDataTask = GetDataOffOfMessageBus(); var raceResult = await Task.WhenAny(myDataTask, Task.Delay(TimeSpan.FromMinutes( 30 ))); if (myDataTask != raceResult) { await Console.WriteLineAsync( "What's taking that message so long?" ); return -1 ; } var myProcessor = new Processor(); await myProcessor.Process( await myDataTask); return 0 ; }

Aggregation

Once you start interleaving and racing your code, you will start having the desire to just not wait at all for anything until it's absolutely needed. Aggregation of your tasks using Task.WhenAll(Task...) can help you with this.

Let's take the above example where we iterated over an IEnumerable of data. Let's instead use the combined power of LINQ and Tasks to process all of that data all at once:

public async Task Main ( ) { var myData = await GetData(); var myProcessor = new Processor(); await Task.WhenAll(myData.Select( async data => { await myProcessor.Process(data)); }); }

A Cautionary Note

It's important to note that except for the first two examples, all the above examples introduce concurrency into your code. Along with concurrency come all the concerns that plague concurrent models: deadlocks, unsynchronized state, etc. That being said, it's still completely acceptable (and possible) to write non-concurrent code with async/await, such as in the first two examples, and still realize benefits. That's because every operating system comes with a maximum number of supported native threads [0][1] (which many runtimes use) and an await call frees up that thread to either be used by something else, or to be collected by the garbage collector. This is why async/await helps your application more efficiently use its hosts resources - whether it be for parallel processing or just conservative use of threads. I hope you come to enjoy the benefits of having the more responsive and conscientious applications that async/await brings you as much as I have.

[0] Increasing number of threads per process (Linux) (https://dustycodes.wordpress.com/2012/02/09/increasing-number-of-threads-per-process/)

[1] Does Windows have a limit of 2000 threads per process? https://devblogs.microsoft.com/oldnewthing/20050729-14/?p=34773)