11

Here's what I mean:

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

Above method can be awaited and I think it closely resembles to what the Task-based Asynchronous Pattern suggests doing? (The other patterns I know of are the APM and EAP patterns.)

Now, what about the following code:

public async Task<SomeObject> GetSomeObjectByToken(int id)
    {
        string token = repository.GetTokenById(id);
        if (string.IsNullOrEmpty(token))
        {
            return new SomeObject()
            {
                IsAuthorized = false
            };
        }
        else
        {
            SomeObject result = await repository.GetSomeObjectByTokenAsync(token);
            result.IsAuthorized = true;
            return result;
        }
    }

The key differences here are that the method is async and it utilizes the await keywords - so what does this change in contrast to the previously written method? I know it can too - be awaited. Any method returning Task can for that matter, unless I'm mistaken.

I'm aware of the state machine created with those switch statements whenever a method is labeled as async, and I'm aware that await itself uses no thread - it doesn't block at all, the thread simply goes to do other things, until it's called back to continue execution of the above code.

But what's the underlying difference between the two methods, when we invoke them using the await keyword? Is there any difference at all, and if there is - which is preferred?

EDIT: I feel like the first code snippet is preferred, because we effectively elide the async/await keywords, without any repercussions - we return a task that will continue its execution synchronously, or an already completed task on the hot path (which can be cached).

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
SpiritBob
  • 2,355
  • 3
  • 24
  • 62
  • 3
    Very little, in this case. In your first example, `result.IsAuthorized = true` will be run on the thread pool, whereas in the second example it might be run on the same thread that invoked `GetSomeObjectByToken` (if it had a `SynchronizationContext` installed on it, e.g. it was a UI thread). The behaviour if `GetSomeObjectByTokenAsync` throws an exception will also be slightly different. In general `await` is preferred over `ContinueWith`, as it's almost always more readable. – canton7 Oct 31 '19 at 08:33
  • 1
    In [this article](https://blog.stephencleary.com/2016/12/eliding-async-await.html) it's called "eliding," which seems like a pretty good word for it. Stephen definitely knows his business. The conclusion of the article is essentially that it doesn't matter much, performance-wise, but there are certain coding pitfalls if you don't await. Your second example is a safer pattern. – John Wu Oct 31 '19 at 08:53
  • 1
    You might be interested in this twitter discussion from the Partner Software Architect at Microsoft on the ASP.NET team: https://twitter.com/davidfowl/status/1044847039929028608?lang=en – Remy Oct 31 '19 at 11:08
  • It's called a "state machine", not a "virtual machine". – Enigmativity Nov 05 '19 at 23:20
  • @Enigmativity Thank you wise one, I forgot to edit that! – SpiritBob Nov 06 '19 at 09:02

2 Answers2

11

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. 1

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 2

  • 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. When building for Release, the compiler generates structs.

    When building for Debug, the compiler generates classes to allow edit-and-continue on async code.

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

acelent
  • 7,965
  • 21
  • 39
  • 1
    The state machine is not a value type. Take a look at the [generated source](https://sharplab.io/#v2:CYLg1APgAgTAjAWAFBQAwAIpwKwG5nJQDMmMmcA7OgN7Lr3p0PHkBsmAHJuwLICGASwB2ACgCUTerSQNZmAJzcAdAE0BAUwA2wcfhkMAvgSRGkQA) of a simple async program: `private sealed class
    d__0 : IAsyncStateMachine`. The instances of this class may be reusable though.
    – Theodor Zoulias Nov 05 '19 at 05:52
  • Would you say that if I were to tamper only with the result of a task, it'd be okay to use `ContinueWith`? – SpiritBob Nov 05 '19 at 09:39
  • 3
    @TheodorZoulias, the [generated source](https://sharplab.io/#v2:D4AQTAjAsAUCAMACEECsBuWsQGZlmQgHZEBvWRSxCq3QgNmQA5lGBZAQwEsA7ACgCUNSuRhVxyAJysAdAE0uAUwA2AE0GYxVAL5YYumEA===) targeting Release instead of Debug generates structs. – acelent Nov 05 '19 at 09:41
  • 2
    @SpiritBob, if you really want to fiddle with task results, I guess it's OK, `.ContinueWith` was meant to be used programmatically. Especially so if you're handling CPU intensive tasks instead of I/O tasks. But when you get to the point of having to deal with `Task`'s properties, the `AggregateException` and the complexity of handling conditions, cycles and exception handling when further asynchronous methods are invoked, then there's hardly any reason to stick with it. – acelent Nov 05 '19 at 09:52
  • Thank you for the valuable information! I have a very silly question to ask, I couldn't find a concrete answer about it, but you seem to know what's up! If I were to cache a simple task returning an empty string, do I just create a static read-only field with a task generated by `Task.FromResult` in order to cache it and return it whenever necessary, or if I do that I'd effectively be sharing that object with multiple threads which only leads to nastiness - How to cache it then? If you could also share a resource in regards to those "cycles" you're talking about. First time hearing that term. – SpiritBob Nov 05 '19 at 10:08
  • 2
    @SpiritBob, sorry, I meant "loops". Yes, you can create a static read-only field with a `Task.FromResult("...")`. In asynchronous code, if you cache values by key that require I/O to obtain, you can use a dictionary where the values are tasks, e.g. `ConcurrentDictionary>` instead of `ConcurrentDictionary`, use `GetOrAdd` call with a factory function, and await on its result. This guarantees only one I/O request is made to populate the cache's key, it awakes awaiters as soon as the task gets completed and serves as a completed task afterwards. – acelent Nov 05 '19 at 11:28
  • Regarding `ContinueWith`, if I'm passing a continuation that would instead start executing another task (because the first task has to finish first, in order to start the second task), should I configure it with `TaskContinuationOptions.RunSynchronously` to leverage some performance, or because it's not always certain if the task passed to complete is asynchronous or not (it has awaits that do suspend, regardless if they do I/O work or CPU-bound work, right?), to prevent a StackDive I should always use `TaskContinuationOptions.RunContinuationsAsynchronously`? _PS: You're amazing._ – SpiritBob Nov 07 '19 at 11:48
  • `ContinueWith` already creates a `Task` that will execute after the previous task, so you must mean that the continuation itself starts another task; you probably mean an `async` continuation. If so, consider "infecting" the caller to become `async` as well and simply `await` the previous task. The typical solution to this case without infecting the caller is to use [`.ContinueWith(async ... => { ... }).Unwrap()`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskextensions.unwrap), which generates yet another `Task`. – acelent Nov 07 '19 at 12:43
  • Note that `TaskContinuationOptions.ExecuteSynchronously` is a **hint**, telling you'd like to execute in the same thread, while `TaskContinuationOptions.RunContinuationsAsynchronously` actually **forces** your continuation to be scheduled. – acelent Nov 07 '19 at 12:44
  • In .NET, you shouldn't be worried about stack overflow when using `async`/`await`, the framework handles it pretty well. You should worry about it if you implement your own awaitables that use their own scheduling, so that the code that awaits on them doesn't have to worry. – acelent Nov 07 '19 at 12:47
  • Thank you for the information! Yeah, I was actually writing library code - a variant of [Interleaved](https://devblogs.microsoft.com/pfxteam/processing-tasks-as-they-complete/). I modified it to accept an additionalContinuation, in the form of an async Func, and was wondering with what `TaskContinuationOptions` to start it. If I did `ExecuteSynchronously`, I'm not sure if it would have lead to a stack Dive, because the implemented continuation could have been synchronous. (If it was asynchronous, it wouldn't stack dive, right?) I ended up just using `TaskContinuationOptions.NotOnFaulted`. – SpiritBob Nov 08 '19 at 16:14
  • I couldn't find a way of adding another `TaskContinuationOptions`, so I left it at that! I'm also unwrapping a few tasks here and there, to make it as pleasant as possible for the user - instead of writing `await await` to get the task, they simply have to write `await`. – SpiritBob Nov 08 '19 at 16:15
  • `TaskContinuationOptions` are flags, you may choose `None` for defaults, but you may also combine them, e.g. `TaskContinuationOptions.HideScheduler | TaskContinuationOptions.ExecuteSynchronously`. Regarding the unwrapping, when a task throws, its exception's stack trace will now include the continuation inside `Interleaved`, even though `Interleaved` had nothing to do with those exceptions at all; however, it simplifies its use a bit. I guess this compromise is not a bad one. – acelent Nov 09 '19 at 21:46
4

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, it has a potentially confusing default scheduler¹, 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).

¹ If the scheduler argument is not provided, it defaults to TaskScheduler.Current, and not to TaskScheduler.Default as one might expect. This means that by default when the ContinueWith is attached, the ambient TaskScheduler.Current is captured, and used for scheduling the continuation. This is somewhat similar with how the await captures the ambient SynchronizationContext.Current, and schedules the continuation after the await on this context. To prevent this behavior of await you can use the ConfigureAwait(false), and to prevent this behavior of ContinueWith you can use the TaskContinuationOptions.ExecuteSynchronously flag in combination with passing the TaskScheduler.Default. Most experts suggest to specify always the scheduler argument every time you use the ContinueWith, and not rely on the ambient TaskScheduler.Current. Specialized TaskSchedulers are generally doing more funky stuff than specialized SynchronizationContexts. For example the ambient scheduler could be a limited concurrency scheduler, in which case the continuation might be put in a queue of unrelated long-running tasks, and executed a long time after the associated task has completed.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104