United Kingdom: +44 (0)208 088 8978

Task vs Async

With the upcoming introduction of native Task workflow support in F#, Ryan gives us an overview of the differences between this and the traditional F# Async model.

We're hiring Software Developers

Click here to find out more

Asynchronous Workflows

Most modern programmers will have some experience with asynchronous workflows.

We live in a connected world, and the days of standalone, single threaded applications are largely behind us.

Whilst the concept is pretty simple - start a computation that runs 'in the background' somehow and returns when it is complete - the details of the implementation differ significantly between runtime environments and languages, and this can have a big impact on both how you use them and the behaviour you can expect to observe.

This is particularly apparent in the world of .NET.

Traditionally, you could achieve asynchronous behaviour by manually creating new threads, but managing these was both difficult and error prone. It is also not an efficient way of programming, because it blocks threads which are waiting on external resources.

Back in 2007, F# sought to address these issues by providing simple, composable asynchronous workflows using an async {} computation expression.

Three years later in 2010, C# followed suit with the async keyword and a Task based pattern.

Perhaps surprisingly, the approach taken in each case was significantly different.

Once you peek beneath the covers, however, it makes a bit more sense as each is quite idiomatic to the respective language.

Interoperability has been possible for a long while, but with a couple of caveats

  • It is only possible to use C# Tasks in F# - you can't use F# Async in C#.
  • You either had to convert Tasks to Async, or rely on an external library for a task {} expression.
  • Neither of the above options typically provided the same performance as native C# Tasks.

The second and third points have finally been addressed with the release of F# 6, which includes an optimised task {} expression 'out of the box'.

We thought it might be a good time to provide a quick refresher on the differences between task {} and async {} to help you choose the most appropriate for your use case.

Generators vs Hot Tasks

When you define a task {} expression, the code inside is executed immediately - the task object itself holds the mutable state of the computation. You can think of this as a "hot" computation.

Conversely, the code contained within an async {} block represents a future computation that is generated on demand. In other words, on creating the value no computation is immediately started - you can pass the generator around as a simple value and execute it at some later point in time.

A "cold" computation is defined as a stateful operation which doesn't begin immediately. This isn't an option which is available for Tasks at the time of writing, although you can simulate this by making a Lazy Task, or putting the initial computation behind a function taking unit as an input that returns the Task.

Cache vs Computation

Because async {} is analogous to a unit -> Async<'a> function, each time you evaluate it you will generate and execute a new operation.

Conversely, a task {} holds the result of the computation it represents. This means that once the operation has returned a value, re-evaluating the task will just return the same result. It will not be executed again.

Cancellation

When you want to cancel a running async operation, the .NET approach is to use a 'cancellation token'. You pass this object to the operation when you start it. This brings us to another difference between task {} and async {}:

The async {} block supports implicit token passing. This means that a cancellation token is automatically created and passed to each async operation that you bind to with let!, use! or do!. The token will be automatically checked between each bind operation to see if cancellation has been requested.

You can also choose to explicitly pass a token to an operation for more granular control. If you have heavily CPU-bound work in your workflow, you may still want to manually check the token periodically.

When using task {} however, this is not the case. You are required to explicitly create the token and pass it down the chain to any child tasks that you start. You also need to manually check the token and bail out of the task yourself if required in all cases - nothing is done automatically.

Continuation

Asynchronous operations are not necessarily executed on a background thread. Background threads are mostly suitable for CPU-bound tasks.

When we have a long running operation that is waiting on an external resource, tying up a thread which is doing nothing would be wasteful, as mentioned in the introduction. These kinds of operations are best moved to the heap and interleaved with other processes, keeping the thread in use as much as possible.

When you start an asynchronous operation, you may need to ensure that when it is done, it returns to the thread it was started from. This is usually the case when calling out from a GUI thread. Other times, you may not care which thread it is resumed on. Configuring this behaviour for task {} and async {} is quite different.

Because async {} is just a computation generator, its behaviour depends on how you start it.

  • Async.StartImmediate will execute a child computation that begins on the current thread.

  • Async.StartChild will start a child computation that shares a cancellation token with the parent. It is not tied to the current thread.

  • Async.Start will start a child computation which returns unit. It does not share a cancellation token, and is not tied to the current thread.

On the other hand, because task {} is stateful, you must specify the continuation behaviour on the object itself. In C# this is done by calling the .ConfigureAwait(...) method. This isn't how F# approaches the problem, however. Instead, we have a new backgroundTask {} expression for operations which do not wish to be bound to the current sync context. Meanwhile, the standard task {} will schedule continuations on the current sync context if present.

There are many subtle details to be aware of, it is recommended to read the RFC for the bigger picture.

Performance

Performance is a tricky subject as it is highly dependent on workload.

  • For code that is waiting on external resources, the performance differences between async {} and task {} are likely to be largely insignificant.
  • For code which is sensitive to allocations or is heavily CPU-bound, the state-based nature of task may provide significant performance gains.

Ultimately, your mileage may vary and testing performance sensitive parts of your code is the best approach.

Special considerations

  • Tasks always run the code up until the first bind operation on the same thread as the calling code. This can require attention, for example if your aim is to parallelise some CPU intensive code on background threads.
  • Tasks do not support asynchronous tail recursion, so be careful you don't blow the stack.

Conclusion

I hope this has provided a useful overview of the similarities and differences between task {} and async {} in F#.

Personally, I reach for async {} by default unless I am dealing with external Task based libraries or I face a specific performance issue (which hasn't happened yet!). I find it fits the functional mindset better and is easier to use.

The reality is however that a great deal of libraries have been written with Task in mind, and it is fantastic that F# now has first class, out of the box support for the pattern.