In the previous two articles, I’ve explained why and how to use async
/await
for asynchronous programming in C#.
Now, we will turn our attention to more interesting things that we can do when combining multiple tasks within the same method.
Update 22nd September 2018: Another pattern not covered here is fire-and-forget. There are many ways to achieve this, including simply not await
ing (causes warnings – see ways to ignore them), using Task.Run()
, using Task.Factory.StartNew()
, or async void
(not recommended, see “Common Mistakes in Asynchronous Programming with .NET“. This is suitable when you want to trigger some kind of processing but don’t care whether/when it completes. It doesn’t really fit the fast food scenario used in this article — placing an order without ever being notified of its completion/failure is sure to annoy customers. Which I suppose is also why applying for jobs is such a pain in the ass for many people.
Fast Food Example
In order to see each pattern at work, we need a simple example involving multiple tasks. Imagine you walk into your favourite fast food restaurant, and order a meal involving a burger, fries and a drink. Each of these takes a different amount of time to prepare, and the total time of the order may vary depending on how the execution of these three tasks takes place.
Sequential Tasks
The simplest approach is to just execute tasks one after another, waiting for one to finish before starting the next.
static void Main(string[] args) { OrderAsync(); Console.ReadLine(); } static async void OrderAsync() { var stopwatch = Stopwatch.StartNew(); await Task.Delay(3000) .ContinueWith(task => ShowCompletion("Fries", stopwatch.Elapsed)); await Task.Delay(1000) .ContinueWith(task => ShowCompletion("Drink", stopwatch.Elapsed)); await Task.Delay(5000) .ContinueWith(task => ShowCompletion("Burger", stopwatch.Elapsed)); ShowCompletion("Order", stopwatch.Elapsed); stopwatch.Stop(); } static void ShowCompletion(string name, TimeSpan time) { Console.WriteLine($"{name} completed after {time}"); }
In this example code, we are representing the fries, drink and burger tasks as delays of different length. The rest of the code is purely diagnostic in order to allow us to get some output and understand the results. There is also a workaround allowing us to use asynchronous code in Main()
, that was described in the previous article.
Here is the output from the above:
Fries completed after 00:00:03.0359621 Drink completed after 00:00:04.0408785 Burger completed after 00:00:09.0426927 Order completed after 00:00:09.0434057
Because we performed each task sequentially, the total order took 9 seconds. In a fast food restaurant, it probably does not make sense to wait for the fries to be ready before preparing the drink, and to wait for both to be ready before starting to prepare the burger. These could be done in parallel, as we will see in the next sections.
However, there are many legitimate cases where sequential task execution makes sense. We’ve seen one in “Motivation for async/await in C#“:
private async void Button_Click(object sender, RoutedEventArgs e) { var baseAddress = new Uri("http://mta.com.mt"); using (var httpClient = new HttpClient() { BaseAddress = baseAddress }) { var response = await httpClient.GetAsync("/"); var content = await response.Content.ReadAsStringAsync(); MessageBox.Show("Response arrived!", "Slow website"); } }
In this case, the tasks are dependent on each other. In order to get the content of the response, the response itself must first finish executing. Because there is this dependency, the tasks must be executed one after the other.
Parallel Tasks, All Must Finish
If we fire off the tasks without await
ing them right away, there are more interesting things we can do with them. Essentially, by removing await
, we are running the tasks in parallel.
static async void OrderAsync() { var stopwatch = Stopwatch.StartNew(); var friesTask = Task.Delay(3000) .ContinueWith(task => ShowCompletion("Fries", stopwatch.Elapsed)); var drinkTask = Task.Delay(1000) .ContinueWith(task => ShowCompletion("Drink", stopwatch.Elapsed)); var burgerTask = Task.Delay(5000) .ContinueWith(task => ShowCompletion("Burger", stopwatch.Elapsed)); await Task.WhenAll(friesTask, drinkTask, burgerTask); ShowCompletion("Order", stopwatch.Elapsed); stopwatch.Stop(); }
Aside from removing await
before each task, we are assigning them to variables so that we can keep track of them. We then rely on Task.WhenAll()
to wait until all tasks have completed (as an analogy, think of it as a memory barrier). Task.WhenAll()
is awaitable, unlike its blocking cousin Task.WaitAll()
. This gives us a way to easily run asynchronous tasks in parallel where it makes sense to do so.
And in a fast food restaurant, preparing fries and drink while the burger is cooking makes a lot of sense. In fact, the order is ready after just 5 seconds, which is the time of the longest task (the burger). Because the fries and drink were prepared concurrently with the burger, they did not add anything to the total time of the order.
Drink completed after 00:00:01.1696855 Fries completed after 00:00:03.0363008 Burger completed after 00:00:05.0443482 Order completed after 00:00:05.0445130
Note that Task.WhenAll()
takes an IEnumerable<Task>
, and as such, you can easily pass it a list of tasks (e.g. when the number of tasks is dynamic based on input or data).
Parallel Tasks, First To Finish
If you’re hungry and thirsty after an unexpected trip in the desert, it’s unlikely that you’re going to want to wait for all items to finish before starting to eat and drink. Instead, you’ll consume each item as soon as it arrives.
static async void OrderAsync() { var stopwatch = Stopwatch.StartNew(); var friesTask = Task.Delay(3000) .ContinueWith(task => ShowCompletion("Fries", stopwatch.Elapsed)); var drinkTask = Task.Delay(1000) .ContinueWith(task => ShowCompletion("Drink", stopwatch.Elapsed)); var burgerTask = Task.Delay(5000) .ContinueWith(task => ShowCompletion("Burger", stopwatch.Elapsed)); await Task.WhenAny(friesTask, drinkTask, burgerTask); ShowCompletion("Order", stopwatch.Elapsed); stopwatch.Stop(); }
Task.WhenAny()
will wait until the first task has completed, and then resume execution of the method. It also returns the task that completed (though we’re not using that here).
Drink completed after 00:00:01.0390588 Order completed after 00:00:01.0412190 Fries completed after 00:00:01.0413729 Burger completed after 00:00:01.0413729
Our results are a little messed up. Since Task.WhenAny()
only waits for the first task to complete, the entire order was considered complete as soon as the drink was ready. The stopwatch was subsequently stopped, and the output shows 1 second for everything even though the fries and burger actually took longer.
This scenario is useful when you want to retrieve data from different sources and just use the result that arrived fastest. It is not very intuitive for when you’re dying of hunger and want to gobble up everything as it arrives. We’ll address this in the next section.
Parallel Tasks, All Must Finish, Process As They Arrive
So here’s the scenario: we’re famished, and we want to consume our drink, fries and burger as they are ready. We want to consume all of them, but Task.WhenAny()
only gives us the first task that completed.
It’s easy to reuse Task.WhenAny()
to wait for all tasks to complete, by using a simple loop.
static async void OrderAsync() { var stopwatch = Stopwatch.StartNew(); var friesTask = Task.Delay(3000) .ContinueWith(task => ShowCompletion("Fries", stopwatch.Elapsed)); var drinkTask = Task.Delay(1000) .ContinueWith(task => ShowCompletion("Drink", stopwatch.Elapsed)); var burgerTask = Task.Delay(5000) .ContinueWith(task => ShowCompletion("Burger", stopwatch.Elapsed)); var tasks = new List<Task>() { friesTask, drinkTask, burgerTask }; while (tasks.Count > 0) { var task = await Task.WhenAny(tasks); tasks.Remove(task); Console.WriteLine($"Yum! {tasks.Count} left!"); } ShowCompletion("Order", stopwatch.Elapsed); stopwatch.Stop(); }
We’re putting all tasks in a list, and as each task completes, we remove it from the list. We know we’re done when there’s nothing left in the list.
Drink completed after 00:00:01.0506610 Yum! 2 left! Fries completed after 00:00:03.0328112 Yum! 1 left! Burger completed after 00:00:05.0317576 Yum! 0 left! Order completed after 00:00:05.0331167
From this example, it might appear that there’s no benefit from using this approach when compared to just using continuations on tasks and using Task.WhenAll()
. However, in real scenarios that don’t involve french fries, it is often reasonable to check the result of each task for failure. If one of the tasks fails, then the operation is aborted without having to wait for all the other tasks to complete.
Task With Timeout
As it turns out, we’re so hungry that we’re only willing to wait up to 4 seconds for each item, since the start of the order. If they take longer than 4 seconds, we’ll cancel that part of the order.
Fortunately, there’s an excellent blog post on the Parallel Programming MSDN blog from 2011 that shows how to write a TimeoutAfter()
method that does exactly this. I’ll go ahead and steal it:
public static class TaskExtensions { public static async Task TimeoutAfter(this Task task, int millisecondsTimeout) { if (task == await Task.WhenAny(task, Task.Delay(millisecondsTimeout))) await task; else throw new TimeoutException(); } }
It’s an extension method, so we can easily use it with the tasks we already have:
static async void OrderAsync() { var stopwatch = Stopwatch.StartNew(); var friesTask = Task.Delay(3000).TimeoutAfter(4000) .ContinueWith(task => ShowCompletion("Fries", stopwatch.Elapsed)); var drinkTask = Task.Delay(1000).TimeoutAfter(4000) .ContinueWith(task => ShowCompletion("Drink", stopwatch.Elapsed)); var burgerTask = Task.Delay(5000).TimeoutAfter(4000) .ContinueWith(task => ShowCompletion("Burger", stopwatch.Elapsed)); var tasks = new List<Task>() { friesTask, drinkTask, burgerTask }; while (tasks.Count > 0) { var task = await Task.WhenAny(tasks); tasks.Remove(task); Console.WriteLine($"Yum! {tasks.Count} left!"); } ShowCompletion("Order", stopwatch.Elapsed); stopwatch.Stop(); }
Running this, the burger task will timeout and an exception will be thrown. Since we’re not actually checking for this, all we see in the output is that the burger task finished after 4 seconds instead of 5.
Drink completed after 00:00:01.0819761 Yum! 2 left! Fries completed after 00:00:03.0493526 Yum! 1 left! Burger completed after 00:00:04.0952924 Yum! 0 left! Order completed after 00:00:04.0974441
By putting a breakpoint or turning on first chance exceptions, though, we see that the TimeoutException
was indeed thrown:
Summary
await
ing tasks one after another will result in sequential execution.- Use
Task.WhenAll()
to wait for all tasks to complete before proceeding. - Use
Task.WhenAny()
to get the first task that finished, and proceed before waiting for the others. - Use
Task.WhenAny()
in a loop to process all tasks as they arrive, and potentially break out early in case of failure. - Apply a timeout to a task using the
TimeoutAfter()
extension method from the Parallel Programming blog on MSDN.