In this article, we are going to talk about the difference between returning and awaiting a Task in an async method in C#. 

To download the source code for this article, you can visit our GitHub repository.

The async/await mechanism is an abstraction over asynchronous code that allows us to write asynchronous programs like a synchronous routine. This magical convenience comes through a compiler-driven transformation under the hood. However, this also leads to certain gotchas that we need to be aware of.

For example, returning a Task from a method and awaiting a Task may appear as equivalent operations but in reality, that’s not always true. Let’s see how they differ in various aspects.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Major Differences Between Returning and Awaiting a Task

To begin, let’s consider a simple routine that returns plain Task:

private static Task SimpleTask(int duration)
{
    if (duration > 10)
        throw new Exception("I don't want to delay so long");

    return Task.Delay(duration);
}

And its functional equivalent using async/await:

private static async Task SimpleTaskAsync(int duration)
{
    if (duration > 10)
        throw new Exception("I don't want to delay so long");

    await Task.Delay(duration);
}

This task simply emulates an operation running for a specific duration. It also throws an exception if the durationexceeds a certain limit. Let’s dig more to find the potential differences between these two versions.

Performance Overhead

One obvious difference is due to the state machine mechanism of async/await. Awaiting an async method, behind the scene, deals with extra compiler-generated types, allocation of a Task object, and lifting locals to the internal state machine. All these lead to extra memory overhead and pressure on the garbage collector. However, in most cases, this overhead is not significant enough to outweigh the benefits of await.

Exception Propagation

Probably the most important difference lies in exception propagation.

The execution flow in the non-async Task is nothing special. We encounter exceptions just like any usual synchronous routine:

Task? task = null;

// Throws exception when we call the method
await Assert.ThrowsAsync<Exception>(() => task = SimpleTask(20));

Assert.Null(task);

As we try to get the task with a duration above the maximum value, we encounter an exception along the way. But the story is different in the case of async version:

// Does not throw exception when we call the method
var task = SimpleTaskAsync(20);

Assert.NotNull(task);

// Throws exception while executing the task
await Assert.ThrowsAsync<Exception>(async () => await task);

Assert.True(task.IsFaulted);

This time the exception doesn’t stop us from getting the task instance. It shows up only when we execute the task (by calling await task, task.Wait(), task.Result etc.).

This behavior demands a closer look at the async/await mechanism. An async method can be seen as a set of code chunks with resumption/cancellation capability at chunk boundaries. So when we use an await inside, we are setting a resumption point. When the method execution happens, it flows synchronously until it reaches an awaited point and resumes at a later time when await completes. The compiler provides this mechanism under the cover by generating additional constructs called “State Machine”. 

In Summary

The body of an async method is not a truly live code and the true execution flow does not happen the way we see the code. The state machine captures exceptions from the visual code and places them in the returned task. So we just get a Task instance when we call the method and do not reach those exception points in real-time. Instead, the exception emerges when the task really starts executing that particular chunk of code. And if an exception occurs, the task will be flagged as faulty.

This may sound like a pitfall of async. But it actually aligns with the perspective of Task – it’s about the work to be done (in the future), not about how things are being done within the work. So, the exception which is part of the work, should not interfere with defining the work.

Note: we have used a pre-conditioned exception in examples for the sake of simplicity. But this exception can be a genuine I/O or network exception within the code as well. As long as we talk about the natural interpretation of a Task, it does not matter what exceptions we deal with.

Dispose Risk

Another major point of concern arises when we invoke an asynchronous method of a disposable object:

public class DataReader: IDisposable
{
    private bool _disposeInvoked = false;

    public void Dispose()
    {
        _disposeInvoked = true;
        GC.SuppressFinalize(this);
    }

    public async Task<string> ReadAsync()
    {
        await Task.Delay(10);

        return _disposeInvoked  ? "Dispose invoked before reading completed" : "Dispose invoked after reading completed";
    }
}

This simple data-reader class provides an async ReadAsync method. Such a class typically implements IDisposable to handle unmanaged resources before disposal. In our case, we just set a _disposeInvoked flag to better understand the dispose execution flow. For brevity, we opt out of a full-fledged dispose pattern.

Inside the ReadAsync method, we provide a conditional result based on the disposed state.

Now, let’s prepare two top-level tasks that call this ReadAsync within a using block:

private static async Task<string> ReadTaskAsync()
{
    using (var reader = new DataReader())
        return await reader.ReadAsync();
}

private static Task<string> ReadTask()
{
    using (var reader = new DataReader())
        return reader.ReadAsync();
}

When we execute the tasks:

var resultOfAwaitedTask = await ReadTaskAsync();

Assert.Equal("Dispose invoked after reading completed", resultOfAwaitedTask);

var resultOfReturnedTask = await ReadTask();

Assert.Equal("Dispose invoked before reading completed", resultOfReturnedTask);

We see two different outputs. As the result states, the ReadTaskAsync version waits for the reader.ReadAsync task to finish before disposing of the reader instance. In contrast, the ReadTask version immediately disposes the reader instance on leaving using block (with an incomplete task), and the returned Task finishes at the caller’s end. No wonder, the latter method is prone to unwanted side effects (and exceptions) due to possible attempts of accessing already disposed resources.

Other Differences Between Awaiting and Returning a Task

There are some other differences that are platform-specific and less common occurrences.

Deadlock Victim

Our next point of difference refers to one key principle of asynchronous programming – “Don’t block in async code“.

Synchronous execution of async methods often causes deadlocks in UI or legacy ASP.NET applications (not in Console or ASP.NET Core):

// Bad code - this will cause deadlock if called from UI thread      
private static string ReadContent()
{
    var task = ReadContentTaskAsync();

    return task.Result;
}

private static async Task<string> ReadContentTaskAsync()
{
    await Task.Delay(20);

    return "Sample content";
}

We get an async ReadContentTaskAsync() task and want a synchronous retrieval of the result inside ReadContentmethod. This code causes a deadlock if we are in UI applications e.g. WinForm, WPF, etc. To better understand this deadlock, let’s shed light on how await handles contexts.

When we await an incomplete Task, the current context gets captured. When the await completes, the rest of the method is executed within the captured context. If the context is locked for some reason, the continuation will wait for the context to be released first.  Here goes the second piece of the puzzle – the default context (SynchronizationContext) permits only one chunk of code to run at a time. So when we call task.Result, the current thread gets synchronously blocked and waits for the task to complete. However, within the task execution flow, when the captured context wants to resume execution after await, it finds itself locked by calling thread. That means both the calling thread and the context are waiting for each other, causing a deadlock. 

Returning a plain Task without await in this case, will avoid an extra occurrence of captured context and hence a less chance of deadlocking. Nonetheless, this is more of a problem with bad mixing of sync and async code than with the async/await.

AsyncLocal

In procedural programming, we sometimes use a context object that is accessible anywhere in the code (or class). This helps in maintaining cleaner API by sharing data across different methods without needing explicit parameters in every method. However, in a multi-threaded async program, this can be tricky to manage as we need to match the current execution flow and the current context object. AsyncLocal serves this exact purpose.

By definition, AsyncLocal represents ambient data that is local to a given asynchronous control flow, such as an asynchronous method. However, this contextual data flows down the asynchronous call stack, not the opposite:

private static readonly AsyncLocal<string> _context = new();

private const string ParentValue = "Parent";
private const string ChildAsyncValue = "ChildAsync";
private const string ChildNonAsyncValue = "ChildNonAsync";

[Fact]
public async Task GivenParentAsyncTask_WhenUsingAsyncChildTask_ThenParentContextIsPersisted()
{
    _context.Value = ParentValue;

    Assert.Equal(ParentValue, _context.Value);

    await ChildTaskAsync();

    Assert.Equal(ParentValue, _context.Value);
}

private static async Task ChildTaskAsync()
{
    Assert.Equal(ParentValue, _context.Value);

    _context.Value = ChildAsyncValue;
    await Task.Yield();

    Assert.Equal(ChildAsyncValue, _context.Value);
}

We are using a context object at a top-level async task, changing its value within the parent scope, and then changing the value inside a child task. As long as we stay within the parent task’s execution body, the parent’s context value persists across the await boundaries. This value also propagates to the child task’s execution flow along the way. However, any change of value inside the child async task does not reflect on the parent’s context. That’s why we get the same parent value even after the child execution completes. This is the expected asynchronous behavior – each asynchrony will hold its own local context so that multi-threaded calls will not interfere with each other.

However, if we use a non-async child task, it behaves just like a normal synchronous method:

[Fact]
public async Task GivenParentAsyncTask_WhenUsingNonAsyncChildTask_ThenLocalContextIsPersisted()
{
    _context.Value = ParentValue;

    Assert.Equal(ParentValue, _context.Value);

    await ChildTask();

    Assert.Equal(ChildNonAsyncValue, _context.Value);
}

private static Task ChildTask()
{
    Assert.Equal(ParentValue, _context.Value);

    _context.Value = ChildNonAsyncValue;

    Assert.Equal(ChildNonAsyncValue, _context.Value);

    return Task.CompletedTask;
}

This time, value changes inside the child affect the parent Task as well. 

When To Use Returning Versus Awaiting

We now know returning and awaiting a task does not always produce the same outcome. The async method, because of its easy-to-read natural code flow and truly asynchronous exception-handling semantics, is the recommended approach in most cases. However, when the method simply returns a passthrough Task or is merely an overload of another async method, we can consider using a non-async task:

private static async Task<decimal> CalculateTaskAsync(bool roundUp)
{
    await Task.Delay(10);

    return roundUp ? 3.5m : 3.48m;
}

private static Task<decimal> CalculateTask() => CalculateTaskAsync(true);

For example, we have an async CalculateTaskAsync method that expects a roundUp parameter. Now we want a convenient overload that just passes a default value for the roundUp flag. There is no added benefit of await in this overloaded method. 

And last but not least, we should always avoid synchronous blocking of an asynchronous task. If we ever need such a thing, it’s better to implement the task as a non-async task.

Conclusion

In this article, we have learned a few differences between returning and awaiting a task in asynchronous methods. In general, we should use async/await when we really want an asynchronous Task and use plain Task when no real asynchronous part exists in the method body.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!