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.| Method | Depth | Mean | Ratio | Allocated | Alloc Ratio |
|---|
| Elided | 1 | 3.804 ns | 1.00 | 72 B | 1.00 |
| Awaited | 1 | 12.090 ns | 3.18 | 144 B | 2.00 |
| ElidedSuspending | 1 | 1,375.255 ns | 361.82 | 96 B | 1.33 |
| AwaitedSuspending | 1 | 1,683.632 ns | 442.95 | 286 B | 3.97 |
| Elided | 5 | 3.818 ns | 1.00 | 72 B | 1.00 |
| Awaited | 5 | 39.039 ns | 10.23 | 432 B | 6.00 |
| ElidedSuspending | 5 | 1,345.047 ns | 352.37 | 96 B | 1.33 |
| AwaitedSuspending | 5 | 1,847.719 ns | 484.06 | 670 B | 9.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!