Hidden costs of ‘async’

Spread the love

In a very early post, I stated:

An ‘async’ method is not free. The compiler does some complex code generation steps to morph your method into a proper asynchronous state machine with all the associated exception handling logic, management of continuation callbacks, etc.

Let’s explore this in a bit more detail. First, we will start with a very basic console application:

namespace ConsoleApplication1
{
    using System;
    using System.Threading.Tasks;

    internal sealed class Program
    {
        private static void Main(string[] args)
        {
            WaitOneSecondAwaitAsync().Wait();
            WaitOneSecondPassThroughAsync().Wait();
        }

        private static Task WaitOneSecondPassThroughAsync()
        {
            return Task.Delay(TimeSpan.FromSeconds(1.0d));
        }

        private static async Task WaitOneSecondAwaitAsync()
        {
            await Task.Delay(TimeSpan.FromSeconds(1.0d));
        }
    }
}

Now let’s use ILSpy to decompile the app so we can see all the generated code. To do this, make sure to uncheck “Decompile async methods (async/await)” in the Decompiler tab under View -> Options. The first thing to note is how much additional code the single line await call actually expands to in the WaitOneSecondAwaitAsync method:

[DebuggerStepThrough, AsyncStateMachine(typeof(Program.<WaitOneSecondAwaitAsync>d__0))]
private static Task WaitOneSecondAwaitAsync()
{
	Program.<WaitOneSecondAwaitAsync>d__0 <WaitOneSecondAwaitAsync>d__;
	<WaitOneSecondAwaitAsync>d__.<>t__builder = AsyncTaskMethodBuilder.Create();
	<WaitOneSecondAwaitAsync>d__.<>1__state = -1;
	AsyncTaskMethodBuilder <>t__builder = <WaitOneSecondAwaitAsync>d__.<>t__builder;
	<>t__builder.Start<Program.<WaitOneSecondAwaitAsync>d__0>(ref <WaitOneSecondAwaitAsync>d__);
	return <WaitOneSecondAwaitAsync>d__.<>t__builder.Task;
}

Now locate the struct inside Program named <WaitOneSecondAwaitAsync>d__0 and look at its definition:

using ConsoleApplication1;
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

[CompilerGenerated]
[StructLayout(LayoutKind.Auto)]
private struct <WaitOneSecondAwaitAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter <>u__$awaiter1;
    private object <>t__stack;

    void IAsyncStateMachine.MoveNext()
    {
        try
        {
            int num = this.<>1__state;
            TaskAwaiter taskAwaiter;
            if (num != 0)
            {
                taskAwaiter = Task.Delay(TimeSpan.FromSeconds(1.0)).GetAwaiter();
                if (!taskAwaiter.IsCompleted)
                {
                    this.<>1__state = 0;
                    this.<>u__$awaiter1 = taskAwaiter;
                    this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, Program.<WaitOneSecondAwaitAsync>d__0>(ref taskAwaiter, ref this);
                    return;
                }
            }
            else
            {
                taskAwaiter = this.<>u__$awaiter1;
                this.<>u__$awaiter1 = default(TaskAwaiter);
                this.<>1__state = -1;
            }

            taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter);
        }
        catch (Exception exception)
        {
            this.<>1__state = -2;
            this.<>t__builder.SetException(exception);
            return;
        }
        this.<>1__state = -2;
        this.<>t__builder.SetResult();
    }

    [DebuggerHidden]
    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
    {
        this.<>t__builder.SetStateMachine(param0);
    }
}

All this just to await one Task! Hopefully it is clearer now why you should simply pass through when it is convenient to do so.

One more thing about async methods that may be surprising in some circumstances: they will never throw synchronously. Consider this code:

private static async Task AppendLineAsync(string path, string line)
{
    if (path == null)
    {
        throw new ArgumentNullException("path");
    }

    using (FileStream stream = GetAsyncStream(path))
    {
        stream.Seek(0, SeekOrigin.End);
        byte[] buffer = Encoding.UTF8.GetBytes(line + Environment.NewLine);
        await stream.WriteAsync(buffer, 0, buffer.Length);
    }
}

If you call this method with a null path you will get this exception but only after awaiting:

System.AggregateException: One or more errors occurred. ---> System.ArgumentNullException: Value cannot be null.
Parameter name: path
   at ConsoleApplication1.Program.<AppendLineAsync>d__0.MoveNext()
[ . . . ]

If you prefer “throw on start” behavior, you need to do all the validation upfront in a non-async method and then pass through to an inner async method where all the actual logic lives. The pattern looks something like this:

private static Task AppendLineAsync(string path, string line)
{
    if (path == null)
    {
        throw new ArgumentNullException("path");
    }

    return AppendLineInnerAsync(path, line);
}

private static async Task AppendLineInnerAsync(string path, string line)
{
    using (FileStream stream = GetAsyncStream(path))
    {
        stream.Seek(0, SeekOrigin.End);
        byte[] buffer = Encoding.UTF8.GetBytes(line + Environment.NewLine);
        await stream.WriteAsync(buffer, 0, buffer.Length);
    }
}

Now the null argument case will throw directly:

System.ArgumentNullException: Value cannot be null.
Parameter name: path
   at ConsoleApplication1.Program.Main(String[] args)

5 thoughts on “Hidden costs of ‘async’

    1. Brian Rogers Post author

      Thanks for reading! Interesting benchmark! I built upon yours and added a few optimizations to more directly compare the two patterns and came up with this:
      https://gist.github.com/brian-dot-net/9519881

      Compiled with Release mode, ran a few times and got a fairly consistent result (no more than a few percent variance):

      437.552
      6432.5086
      Pass through is 14.7 times faster than await.
      
  1. Tarun

    Thanks Brian and Ahmet.
    14 times is quite a difference. So we should avoid method like –

    public async Task GetFooNameAsync()
    {
    var foo = await GetFooAsync();
    return foo.Name;
    }

    Since in this case the calling method can get the name if we return Foo right?

    1. Brian Rogers Post author

      Yes, though keep in mind this is a micro-benchmark and in the real world there are many other sources of overhead that might dwarf this cost in practice.

  2. Pingback: ContinueWith slowly | WriteAsync .NET

Leave a Reply to Brian Rogers Cancel reply

Your email address will not be published. Required fields are marked *