Prefer async/await over directly returning Task

 
 
  • Gérald Barré

This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!

These two methods look equivalent:

C#
public Task<int> DoSomethingAsync()
{
    return CallDependencyAsync();
}

public async Task<int> DoSomethingAsync()
{
    return await CallDependencyAsync();
}

The first is called eliding the await. It is faster, and it is usually the wrong choice. This post is about what you actually trade.

#What eliding saves

Without async, the method is an ordinary method that returns a value that happens to be a Task. No state machine, no builder, no 72-byte allocation.

MethodDepthMeanRatioAllocatedAlloc Ratio
Elided13.804 ns1.0072 B1.00
Awaited112.090 ns3.18144 B2.00
ElidedSuspending11,375.255 ns361.8296 B1.33
AwaitedSuspending11,683.632 ns442.95286 B3.97
Elided53.818 ns1.0072 B1.00
Awaited539.039 ns10.23432 B6.00
ElidedSuspending51,345.047 ns352.3796 B1.33
AwaitedSuspending51,847.719 ns484.06670 B9.31
Benchmark project, measured with BenchmarkDotNet

The savings are real and they compound with depth. One layer costs 72 extra bytes; five layers of pass-through cost 360. The elided version does not change at all as depth grows, because there is nothing there: the calls collapse into a single Task that is returned up the chain untouched.

That is the honest case for eliding, and it is why the technique exists. Now the other side.

#What eliding costs

##The stack trace loses a frame

The method is not on the stack when the exception is thrown, because it returned long ago. The sample shows both:

--- elided ---
   at Program.ThrowAsync()
   at Program.ShowStackTraceAsync(String name, Func`1 operation)

--- awaited ---
   at Program.ThrowAsync()
   at Program.AwaitedAsync()
   at Program.ShowStackTraceAsync(String name, Func`1 operation)

AwaitedAsync is missing from the first trace. One frame is survivable; a chain of six elided pass-through methods produces a trace that jumps from your controller straight into the database driver, which is exactly the situation where you needed the trace.

##Exceptions surface at the call, not at the await

This one changes behaviour rather than diagnostics:

C#
// Not async: the body runs synchronously when called.
static Task ElidedValidationAsync(string value)
{
    ArgumentNullException.ThrowIfNull(value);
    return Task.CompletedTask;
}

// async: the compiler wraps the body, so the exception lands on the returned task.
static async Task AwaitedValidationAsync(string value)
{
    ArgumentNullException.ThrowIfNull(value);
    await Task.CompletedTask;
}
elided:  threw at the call site, before anything was awaited
awaited: call returned a faulted task, nothing thrown yet

An async method never throws synchronously: the compiler's try/catch turns every exception into SetException. An elided method throws where you call it.

That breaks any caller that separates starting from awaiting:

C#
var tasks = items.Select(item => ProcessAsync(item)).ToArray();  // throws here, mid-loop
await Task.WhenAll(tasks);                                       // never reached

Some of the operations were started; some were not; nothing is awaited; and you are in a catch block with no idea which. With async the failure is one faulted task among many, and WhenAll reports it normally.

##using and try do not do what they look like

This is the one that produces real bugs:

C#
// Wrong. The stream is disposed as soon as ReadAsync returns its task, which is
// long before the read has finished.
public Task<string> ReadAsync(string path)
{
    using var stream = File.OpenRead(path);
    return ReadStreamAsync(stream);
}

The using disposes at the end of the method, and the method ends when it returns the task. Adding async and await makes the scope cover the whole operation:

C#
public async Task<string> ReadAsync(string path)
{
    using var stream = File.OpenRead(path);
    return await ReadStreamAsync(stream);
}

The same applies to try/catch, try/finally and lock. If the method has any of them around the returned task, await is not optional, it is required for correctness.

##It is easy to break later

An elided method is correct today and a bug after the next edit. Somebody adds a using, or a try, or a log line after the call, and the elision silently stops being equivalent. The async version has no such trap.

#When eliding is fine

It is a legitimate technique in the right place:

  • Trivial forwarding with no using, no try, no work after the call: public Task<int> GetAsync(int id) => _inner.GetAsync(id);
  • Interface implementations that return a constant: public Task StartAsync(CancellationToken token) => Task.CompletedTask; Making that async would be pure overhead.
  • A hot path in a library where you have measured the difference and the call chain is one level deep.

And it is worth noting the numbers in context. The saving is 72 bytes and 8 nanoseconds per layer. In the suspending rows it is a rounding error next to the 1.3 microseconds of scheduling. If the method does any I/O at all, eliding is not the reason your code is fast or slow.

#What to take away

  • Eliding the await removes a state machine: about 72 bytes and 8 ns per layer, and it does not grow with depth.
  • It removes the method from the stack trace of any exception thrown below it.
  • An elided method throws synchronously at the call site; an async method returns a faulted task instead.
  • using, try and lock around a returned task are broken by eliding, because the scope ends when the task is returned rather than when it completes.
  • Elided code is fragile: a later edit can silently make it wrong.
  • Default to return await. Elide when the method is a trivial forward, and preferably when you have measured it.

The next post pulls every measurement in this series together into one place: the full cost of async on .NET 10, before .NET 11 changes the rules.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?