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 await
s 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 Task
s 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 inSynchronizationContext.Current
if any, otherwise inTaskScheduler.Current
1ContinueWith
will schedule continuations in the provided task scheduler, or inTaskScheduler.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.
- 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 AggregateException
s 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
.