When to Return a Task vs. Await

Elijah Koulaxis

December 26, 2024

async-await

The async/await keywords make it easy to write async code, but using them without thinking can sometimes add unnecessary overhead.

A good practice is to return a Task directly rather than awaiting it in certain situations. Let's break down why this is important, what benefits it offers, and how it works behind the scenes.

When you use async/await, the compiler generates something called a state machine in the compiled code. State machines are really helpful for managing asynchronous operations, but they come with a cost. They add extra memory usage because the system has to store information about the state of the operation. They also put more work on the CPU, since extra steps are needed to manage the state machine. For simple methods that just pass data along or don’t do much work, this extra complexity isn’t really necessary.

Example

This creates a state machine, even though the method simply passes the work to HttpClient.GetStringAsync:

public async Task<string> FetchDataAsync()
{
    return await HttpClient.GetStringAsync("https://api.example.com");
}

But we can just avoid the state machine:

public Task<string> FetchDataAsync()
{
    return HttpClient.GetStringAsync("https://api.example.com");
}

What happens under the hood

As mentioned before, when you write an async method, the compiler generates a state machine to manage the method's execution.

How does the state machine work?

The state machine tracks:

With async/await

For example, the earlier in our FetchDataAsync method might compile into something like this in IL (Simplified):

.class nested private auto ansi sealed '<FetchDataAsync>d__0'
    extends [System.Runtime]System.Object
    implements [System.Runtime.CompilerServices]System.Runtime.CompilerServices.IAsyncStateMachine
{
    // fields for state machine
    .field private int32 '<>1__state'
    .field private [System.Runtime.CompilerServices]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<string> '<>t__builder'
    .field private class [System.Net.Http]System.Net.Http.HttpClient '<>4__this'
    .field private valuetype [System.Runtime.CompilerServices]System.Runtime.CompilerServices.TaskAwaiter`1<string> '<>u__1'

    // MoveNext method to execute the state machine
    .method public final hidebysig newslot virtual 
        instance void MoveNext() cil managed
    {
        .locals init (
            [0] string result,
            [1] valuetype [System.Runtime.CompilerServices]System.Runtime.CompilerServices.TaskAwaiter`1<string> awaiter
        )

        .try
        {
            // state machine logic for awaiting
            IL_0000: ldarg.0
            IL_0001: ldfld int32 '<>1__state'
            IL_0006: ldc.i4.0
            IL_0007: beq.s IL_0030

            // task starts: call HttpClient.GetStringAsync
            IL_0009: ldarg.0
            IL_000A: ldfld class HttpClient '<>4__this'
            IL_000F: ldstr "https://api.example.com"
            IL_0014: callvirt instance class System.Threading.Tasks.Task`1<string> HttpClient::GetStringAsync(string)
            IL_0019: callvirt instance valuetype TaskAwaiter`1<string> Task`1<string>::GetAwaiter()

            // save state and pause execution
            IL_001E: stloc.1
            IL_001F: ldloca.s awaiter
            IL_0020: call instance bool TaskAwaiter`1<string>::get_IsCompleted()
            IL_0025: brtrue.s IL_004F
            IL_0027: ldarg.0
            IL_0028: ldc.i4.0
            IL_0029: stfld int32 '<>1__state'
            IL_002E: leave.s IL_0076

            // resume execution
            IL_0030: ldarg.0
            IL_0031: ldfld valuetype TaskAwaiter`1<string> '<>u__1'
            IL_0036: ldloca.s result
            IL_0037: call instance !0 TaskAwaiter`1<string>::GetResult()
            IL_003C: stloc.0
            IL_003D: ldarg.0
            IL_003E: ldflda AsyncTaskMethodBuilder`1<string> '<>t__builder'
            IL_0043: ldloc.0
            IL_0044: call instance void AsyncTaskMethodBuilder`1<string>::SetResult(!0)
            IL_0049: leave.s IL_0076
        }
        catch ...
    }
}

The compiler creates a nested class (<FetchDataAsync>d__0) to manage the state which tracks:

Why should we avoid this?

For simple delegation (methods that just pass a Task), this is unnecessary and it impacts the performance. If you just return the Task directly, the method skips generating a state machine completely!

Without async/await (Simplified)

.method public hidebysig 
    instance class System.Threading.Tasks.Task`1<string> FetchDataAsync() cil managed
{
    .maxstack 8
    IL_0000: ldarg.0
    IL_0001: ldfld class HttpClient '<>4__this'
    IL_0006: ldstr "https://api.example.com"
    IL_000B: callvirt instance class System.Threading.Tasks.Task`1<string> HttpClient::GetStringAsync(string)
    IL_0010: ret
}

As you can see, no state machine is generated! The method, simply calls HttpClient.GetStringAsync and returns the resulting Task. No additional fields or methods; execution is linear.

However, that is not the only reason you want to think more than once, why you should avoid unnecessary async/await.

Exceptions

When you use async/await in a method, you inherently take responsibility for handling any exceptions that occur during the asynchronous operation. On the other hand, returning a Task directly allows the caller to decide how exceptions should be handled.

Example: Handling Exceptions Inside the Method

public async Task FetchDataAsync()
{
    try
    {
        var result = await HttpClient.GetStringAsync("https://api.example.com");
        Console.WriteLine(result);
    }
    catch (HttpRequestException ex)
    {
        LogError(ex);
        throw;
    }
}

This effectively couples exception handling to the method.

Example: Delegating Exception Handling to the Caller

If you return the Task directly, you delegate responsibility for handling exceptions to the caller:

public Task<string> FetchDataAsync()
{
    return HttpClient.GetStringAsync("https://api.example.com");
}
try
{
    var data = await FetchDataAsync();
    Console.WriteLine(data);
}
catch (HttpRequestException ex)
{
    LogError(ex);
}
catch (Exception ex)
{
    HandleUnexpectedError(ex);
}

TLDR;

Returning a Task directly instead of awaiting it in a method:

However, there are valid scenarios where handling exceptions inside the method using async/await is preferable, especially when encapsulating complex work or hiding implementation details

The key takeaway is to use async/await only when the method adds actual value. For simple delegation, prefer returning the Task directly to keep your code efficient, maintainable, and flexible.

Tags:
Back to Home