What’s the overhead of await without I/O?

570 Views Asked by At

One downside of the async pattern in C# 5 is that Tasks are not covariant, i.e., there isn't any ITask<out TResult>.

I have noticed that my developers often do

return await SomeAsyncMethod();

to come around this.

Exactly what impact performance-wise will this create? There isn't any I/O or thread yield. It will just await and cast it to the correct covariant. What will the async framework do under the hood in this case? Will there be any Thread context switch?

This code won’t compile:

public class ListProductsQueryHandler : IQueryHandler<ListProductsQuery, IEnumerable<Product>>
{
    private readonly IBusinessContext _context;

    public ListProductsQueryHandler(IBusinessContext context)
    {
        _context = context;
    }

    public Task<IEnumerable<Product>> Handle(ListProductsQuery query)
    {
        return _context.DbSet<Product>().ToListAsync();
    }
}

because Task is not covariant, but adding await and it will cast it to the correct IEnumerable<Product> instead of List<Product> that ToListAsync returns.

ConfigureAwait(false) everywhere in the domain code does not feel like a viable solution, but I will certainly use it for my low-level methods like

public async Task<object> Invoke(Query query)
{
    var dtoType = query.GetType();
    var resultType = GetResultType(dtoType.BaseType);
    var handler = _container.GetInstance(typeof(IQueryHandler<,>).MakeGenericType(dtoType, resultType)) as dynamic;
    return await handler.Handle(query as dynamic).ConfigureAwait(false);
}
4

There are 4 best solutions below

10
On BEST ANSWER

The more notable cost of your approach to solving this problem is that if there is a value in SynchronizationContext.Current you need to post a value to it and wait for it to schedule that work. If the context is busy doing other work, you could be waiting for some time when you don't actually need to do anything in that context.

That can be avoided by simply using ConfigureAwait(false), while still keeping the method async.

Once you've removed the possibility of using the sync context, then the state machine generated by the async method shouldn't have an overhead that's notably higher than what you'd need to provide when adding the continuation explicitly.

10
On

Exactly what impact performance-wise will this create?

Almost none.

Will there be any Thread context switch?

Not any more than normal.

As I describe on my blog, when an await decides to yield, it will first capture the current context (SynchronizationContext.Current, unless it is null, in which case the context is TaskScheduler.Current). Then, when the task completes, the async method resumes executing in that context.

The other important element in this conversation is that task continuations execute synchronously if possible (again, described on my blog). Note that this is not actually documented anywhere; it's an implementation detail.

ConfigureAwait(false) everywhere in domain code does not feel like a viable solution

You can use ConfigureAwait(false) in your domain code (and I actually recommend you do, for semantic reasons), but it probably will not make any difference performance-wise in this scenario. Here's why...

Let's consider how this call works within your application. You've undoubtedly got some entry-level code that depends on the context - say, a button click handler or an ASP.NET MVC action. This calls into the code in question - domain code that performs an "asynchronous cast". This in turn calls into low-level code which is already using ConfigureAwait(false).

If you use ConfigureAwait(false) in your domain code, the completion logic will look like this:

  1. Low-level task completes. Since this is probably I/O-based, the task completion code is running on a thread pool thread.
  2. The low-level-task completion code resumes executing the domain-level code. Since the context was not captured, the domain-level code (the cast) executes on the same thread pool thread, synchronously.
  3. The domain-level code reaches the end of its method, which completes the domain-level task. This task completion code is still running on the same thread pool thread.
  4. The domain-level-task completion code resumes executing the entry-level code. Since the entry level requires the context, the entry-level code is queued to that context. In a UI app, this causes a thread switch to the UI thread.

If you don't use ConfigureAwait(false) in your domain code, the completion logic will look like this:

  1. Low-level task completes. Since this is probably I/O-based, the task completion code is running on a thread pool thread.
  2. The low-level-task completion code resumes executing the domain-level code. Since the context was captured, the domain-level code (the cast) is queued to that context. In a UI app, this causes a thread switch to the UI thread.
  3. The domain-level code reaches the end of its method, which completes the domain-level task. This task completion code is running in the context.
  4. The domain-level-task completion code resumes executing the entry-level code. The entry level requires the context, which is already present.

So, it's just a matter of when that context switch takes place. For UI apps, it is nice to keep small amounts of work on the thread pool for as long as possible, but for most apps it won't hurt performance if you don't. Similarly, for ASP.NET apps, you can get tiny amounts of parallelism for free if you keep as much code as possible out of the request context, but for most apps this won't matter.

4
On

There will be a state machine generated by the compiler which saves the current SynchornizationContext when calling the method and restores it after the await.

So there could be a context switch, for example when you call this from a UI thread, the code after the await will switch back to run on the UI thread which will result in a context switch.

This article might be useful Asynchronous Programming - Async Performance: Understanding the Costs of Async and Await

In your case it may be handy to stick a ConfigureAwait(false) after the await to avoid the context switch, however the state machine for the async method will be still generated. In the end the cost of the await will be negligible in comparison to your query cost.

0
On

There is some overhead associated, though most of the time, there will not be a context switch - most of the cost is in the state machine generated for each async method with awaits. The main problem would be if there's something preventing the continuation from running synchronously. And since we're talking I/O operations, the overhead will tend to be entirely dwarfed by the actual I/O operation - again, the major exception being synchronization contexts like the one Windows Forms uses.

To lessen this overhead, you could make yourself a helper method:

public static Task<TOut> Cast<TIn, TOut>(this Task<TIn> @this, TOut defaultValue)
  where TIn : TOut
{
    return @this.ContinueWith(t => (TOut)t.GetAwaiter().GetResult());
}

(the defaultValue argument is only there for type inference—sadly, you will have to write out the return type explicitly, but at least you don't have to type out the "input" type manually as well)

Sample usage:

public class A
{
    public string Data;
}

public class B : A { }

public async Task<B> GetAsync()
{
    return new B { Data =
     (await new HttpClient().GetAsync("http://www.google.com")).ReasonPhrase };
}

public Task<A> WrapAsync()
{
    return GetAsync().Cast(default(A));
}

There is a bit of tweaking you can try if needed, e.g. using TaskContinuationOptions.ExecuteSynchronously, which should work well for most task schedulers.