In this article, we are going to talk about the difference between returning and awaiting a Task in an async method in C#.
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.
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 duration
exceeds 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 ReadContent
method. 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.