I don’t often write “what’s new in .NET” posts, but .NET 8.0 has an interesting addition that I haven’t seen a lot of people talk about.
ConfigureAwait is getting a pretty good overhaul/enhancement; let’s take a look!
ConfigureAwait(true) and ConfigureAwait(false)
First, let’s review the semantics and history of the original
ConfigureAwait, which takes a boolean argument named
await acts on a task (
ValueTask<T>), its default behavior is to capture a “context”; later, when the task completes, the
async method resumes executing in that context. The “context” is
TaskScheduler.Current (falling back on the thread pool context if none is provided). This default behavior of continuing on the captured context can be made explicit by using
ConfigureAwait(continueOnCapturedContext: false) is useful if you don’t want to resume on that context. When using
async method resumes on any available thread pool thread.
The history of
ConfigureAwait(false) is interesting (at least to me). Originally, the community recommended using
ConfigureAwait(false) everywhere you could, unless you needed the context. This is the position I recommended in my Async Best Practices article. There were several discussions during that time frame over why the default was
true, especially from frustrated library developers who had to use
ConfigureAwait(false) a lot.
Over the years, though, the recommendation of “use
ConfigureAwait(false) whenever you can” has been modified. The first (albeit minor) shift was instead of “use
ConfigureAwait(false) whenever you can”, a simpler guideline arose: use
ConfigureAwait(false) in library code and don’t use it in application code. This is an easier guideline to understand and follow. Still, the complaints about having to use
ConfigureAwait(false) continued, with periodic requests to change the default on a project-wide level. These requests have always been rejected by the C# team for language consistency reasons.
More recently (specifically, since ASP.NET dropped their
SynchronizationContext with ASP.NET Core and fixed all the places where sync-over-async was necessary), there has been a move away from
ConfigureAwait(false). As a library author, I fully understand how annoying it is to have
ConfigureAwait(false) litter your codebase! Some library authors have just decided not to bother with
ConfigureAwait(false). For myself, I still use
ConfigureAwait(false) in my libraries, but I understand the frustration.
An earlier version of this post incorrectly claimed that the Entity Framework Core team had decided not to use
ConfigureAwait(false). This was only true in early versions of Entity Framework Core. Entity Framework Core added
ConfigureAwait(false) in version 5.0.0 and continues to use
ConfigureAwait(false) as of this writing (2023-11-11).
Since we’re on the topic of
ConfigureAwait(false), I’d like to note a few common misconceptions:
ConfigureAwait(false)is not a good way to avoid deadlocks. That’s not its purpose, and it’s a questionable solution at best. In order to avoid deadlocks when doing direct blocking, you’d have to make sure all the asynchronous code uses
ConfigureAwait(false), including code in libraries and the runtime. It’s just not a very maintainable solution. There are better solutions available.
await, not the task. E.g., the
SomethingAsync().ConfigureAwait(false).GetAwaiter().GetResult()does exactly nothing. Similarly, the
var task = SomethingAsync(); task.ConfigureAwait(false); await task;still continues on the captured context, completely ignoring the
ConfigureAwait(false). I’ve seen both of these mistakes over the years.
ConfigureAwait(false)does not mean “run the rest of this method on a thread pool thread” or “run the rest of this method on a different thread”. It only takes effect if the
awaityields control and then later resumes the
awaitwill not yield control if its task is already complete; in that case, the
ConfigureAwaithas no effect because the
OK, now that we’ve refreshed our understanding of
ConfigureAwait(false), let’s take a look at how
ConfigureAwait is getting some enhancements in .NET 8. None of the existing behavior is changed;
await without any
ConfigureAwait at all still has the default behavior of
ConfigureAwait(false) still has the same behavior, too. But there’s a new
ConfigureAwait coming into town!
There are several new options available for
ConfigureAwaitOptions is a new type that provides all the different ways to configure awaitables:
First, a quick note: this is a
Flags enum; any combination of these options can be used together.
The next thing I want to point out is that
ConfigureAwait(ConfigureAwaitOptions) is only available on
Task<T>, at least for .NET 8. It wasn’t added to
ValueTask<T> yet. It’s possible that a future release of .NET may add
ConfigureAwait(ConfigureAwaitOptions) for value tasks, but as of now it’s only available on reference tasks, so you’ll need to call
AsTask if you want to use these new options on value tasks.
Now, let’s consider each of these options in turn.
ConfigureAwaitOptions.None and ConfigureAwaitOptions.ContinueOnCapturedContext
These two are going to be pretty familiar, except with one twist.
ConfigureAwaitOptions.ContinueOnCapturedContext - as you might guess from the name - is the same as
ConfigureAwait(continueOnCapturedContext: true). In other words, the
await will capture the context and resume executing the
async method on that context.
ConfigureAwaitOptions.None is the same as
ConfigureAwait(continueOnCapturedContext: false). In other words,
await will behave perfectly normally, except that it will not capture the context; assuming the
await does yield (i.e, the task is not already complete), then the
async method will resume executing on any available thread pool thread.
Here’s the twist: with the new options, the default is to not capture the context! Unless you explicitly include
ContinueOnCapturedContext in your flags, the context will not be captured. Of course, the default behavior of
await itself is unchanged: without any
ConfigureAwait at all,
await will behave as though
ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext) was used.
So, that’s something to keep in mind as you start using this new
SuppressThrowing flag suppresses exceptions that would otherwise occur when
awaiting a task. Under normal conditions,
await will observe task exceptions by re-raising them at the point of the
await. Normally, this is exactly the behavior you want, but there are some situations where you just want to wait for the task to complete and you don’t care whether it completes successfully or with an exception.
SuppressThrowing allows you to wait for the completion of a task without observing its result.
I expect this will be most useful alongside cancellation. There are some cases where some code needs to cancel a task and then wait for the existing task to complete before starting a replacement task.
SuppressThrowing would be useful in that scenario: the code can
SuppressThrowing, and the method will continue when the task completes, whether it was successful, canceled, or finished with an exception.
await with the
SuppressThrowing flag, then the exception is considered “observed”, so
TaskScheduler.UnobservedTaskException is not raised. The assumption is that you are awaiting the task and deliberately discarding the exception, so it’s not considered unobserved.
There’s another consideration for this flag as well. When used with a plain
Task, the semantics are clear: if the task faults, the exception is just ignored. However, the same semantics don’t quite work for
Task<T>, because in that case the
await expression needs to return a value (of type
T). It’s not clear what value of
T would be appropriate to return in the case of an ignored exception, so the current behavior is to throw an
ArgumentOutOfRangeException at runtime. To help catch this at compile time, a new warning was added:
The ConfigureAwaitOptions.SuppressThrowing is only supported with the non-generic Task. This rule defaults to a warning, but I’d suggest making it an error, since it will always fail at runtime.
As a final note, this is one flag that also affects synchronous blocking in addition to
await. Specifically, you can call
.GetAwaiter().GetResult() to block on the awaiter returned from
SuppressThrowing flag will cause exceptions to be ignored whether using
GetAwaiter().GetResult(). Previously, when
ConfigureAwait only took a boolean parameter, you could say “ConfigureAwait configures the await”; but now you have to be more specific: “ConfigureAwait returns a configured awaitable”. And it is now possible that the configured awaitable modifies the behavior of blocking code in addition to the behavior of the
ConfigureAwait is perhaps a slight misnomer now, but it is still primarily intended for configuring
await. Of course, blocking on asynchronous code still isn’t recommended.
The final flag is the
ForceYielding flag. I expect this flag will be rarely used, but when you need it, you need it!
ForceYielding is similar to
Yield returns a special awaitable that always claims to be not completed, but schedules its continuations immediately. What this means is that the
await always acts asynchronously, yielding to its caller, and then the
async method continues executing as soon as possible. The normal behavior for
await is to check if its awaitable is complete, and if it is, then continue executing synchronously;
ForceYielding prevents that synchronous behavior, forcing the
await to behave asynchronously.
For myself, I find forcing asynchronous behavior most useful in unit testing. It can also be used to avoid stack dives in some cases. It may also be useful when implementing asynchronous coordination primitives, such as the ones in my AsyncEx library. Essentially, anywhere where you want to force
await to behave asynchronously, you can use
ForceYielding to accomplish that.
One point that I find interesting is that
ForceYielding makes the
await always yields, even if you pass it a resolved promise. In C#, you can now
await a completed task with
ForceYielding by itself also implies not continuing on the captured context, so it is the same as saying “schedule the rest of this method to the thread pool” or “switch to a thread pool thread”.
Task.Yield will resume on the captured context, so it’s not exactly like
ForceYielding by itself. It’s actually like
Of course, the real value of
ForceYielding is that it can be applied to any task at all. Previously, in the situations where yielding was required, you had to either add a separate
await Task.Yield(); statement or create a custom awaitable. That’s no longer necessary now that
ForceYielding can be applied to any task.
It’s great to see the .NET team still making improvements in
await, all these years later!
If you’re interested in more of the history and design discussion behind
ConfigureAwaitOptions, check out the pull request. At one point there was a
ForceAsynchronousContinuation that was dropped before release. It had a more obscure use case, essentially overriding
await’s default behavior of scheduling the
async method continuation with
ExecuteSynchronously. Perhaps a future update will add that back in, or perhaps a future update will add
ConfigureAwaitOptions support to value tasks. We’ll just have to see what the future holds!