How does using await differ from using ContinueWith when processing async tasks?

The async/await mechanism makes the compiler transform your code into a state machine. Your code will run synchronously until the first await that hits an awaitable that has not completed, if any.

In the Microsoft C# compiler, this state machine is a value type, which means it will have a very small cost when all awaits get completed awaitables, as it won't allocate an object, and therefore, it won't generate garbage. When any awaitable is not completed, this value type is inevitably boxed.

Note that this doesn't avoid allocation of Tasks if that's the type of awaitables used in the await expressions.

With ContinueWith, you only avoid allocations (other than Task) if your continuation doesn't have a closure and if you either don't use a state object or you reuse a state object as much as possible (e.g. from a pool).

Also, the continuation is called when the task is completed, creating a stack frame, it doesn't get inlined. The framework tries to avoid stack overflows, but there may be a case where it won't avoid one, such as when big arrays are stack allocated.

The way it tries to avoid this is by checking how much stack is left and, if by some internal measure the stack is considered full, it schedules the continuation to run in the task scheduler. It tries to avoid fatal stack overflow exceptions at the cost of performance.

Here is a subtle difference between async/await and ContinueWith:

  • async/await will schedule continuations in SynchronizationContext.Current if any, otherwise in TaskScheduler.Current 1

  • ContinueWith will schedule continuations in the provided task scheduler, or in TaskScheduler.Current in the overloads without the task scheduler parameter

To simulate async/await's default behavior:

.ContinueWith(continuationAction,
    SynchronizationContext.Current != null ?
        TaskScheduler.FromCurrentSynchronizationContext() :
        TaskScheduler.Current)

To simulate async/await's behavior with Task's .ConfigureAwait(false):

.ContinueWith(continuationAction,
    TaskScheduler.Default)

Things start to get complicated with loops and exception handling. Besides keeping your code readable, async/await works with any awaitable.

Your case is best handled with a mixed approach: a synchronous method that calls an asynchronous method when needed. An example of your code with this approach:

public Task<SomeObject> GetSomeObjectByTokenAsync(int id)
{
    string token = repository.GetTokenById(id);
    if (string.IsNullOrEmpty(token))
    {
        return Task.FromResult(new SomeObject()
        {
            IsAuthorized = false
        });
    }
    else
    {
        return InternalGetSomeObjectByTokenAsync(repository, token);
    }
}

internal async Task<SomeObject> InternalGetSomeObjectByToken(Repository repository, string token)
{
    SomeObject result = await repository.GetSomeObjectByTokenAsync(token);
    result.IsAuthorized = true;
    return result;
}

In my experience, I've found very few places in application code where adding such complexity actually pays off the time to develop, review and test such approaches, whereas in library code any method can be a bottleneck.

The only case where I tend elide tasks is when a Task or Task<T> returning method simply returns the result of another asynchronous method, without itself having performed any I/O or any post-processing.

YMMV.


  1. Unless you use ConfigureAwait(false) or await on some awaitable that uses custom scheduling

By using ContinueWith you are using the tools that where available before the introduction of the async/await functionality with C# 5 back at 2012. As a tool it is verbose, not easily composable, and requires extra work for unwrapping AggregateExceptions and Task<Task<TResult>> return values (you get these when you pass asynchronous delegates as arguments). It offers few advantages in return. You may consider using it when you want to attach multiple continuations to the same Task, or in some rare cases where you can't use async/await for some reason (like when you are in a method with out parameters).


Update: I removed the misleading advice that the ContinueWith should use the TaskScheduler.Default to mimic the default behavior of await. Actually the await by default schedules its continuation using TaskScheduler.Current.