Proposal: Extension await operator to address scoped ConfigureAwait

Library developers are often frustrated at needing to use .ConfigureAwait(false) on all of their awaits, and this has led to numerous proposals for assembly-level configuration, e.g. https://github.com/dotnet/csharplang/issues/645, https://github.com/dotnet/csharplang/issues/2542.

There are, however, concerns with such specific solutions. await is pattern based, and the ConfigureAwait instance methods exposed by {Value}Task{<T>} aren't known to the language or special in any way: they just return something that implements the awaiter pattern. And not everything that is awaitable exposes such a ConfigureAwait method, so creating a global option that somehow specially-recognizes ConfigureAwait is very constraining.

I instead propose a way we can address the ConfigureAwait concerns, with minimal boilerplate, while also being flexible enough to support other scenarios, provide some level of scoping beyond assembly level, etc.

Proposal

We introduce the notion of extension operators, and in particular an extension await operator. Such extension await operators would be used by the compiler to get the actual awaitable instance any time an await is issued, whether explicitly by the developer or implicitly by the compiler as part of a language construct like await foreach or await using.

As a developer, I can add such extension operators to my project, e.g. ```C# internal static class ConfigureAwaitExtensions { internal static ConfiguredTaskAwaitable operator await (Task t) => t.ConfigureAwait(false); internal static ConfiguredTaskAwaitable operator await(Task t) => t.ConfigureAwait(false); internal static ConfiguredValueTaskAwaitable operator await (ValueTask vt) => vt.ConfigureAwait(false); internal static ConfiguredValueTaskAwaitable operator await(ValueTask vt) => vt.ConfigureAwait(false); }

Following normal scoping rules, any `await` that sees these in scope will then use them to determine the actual instance to await, e.g. code that does the following:
```C#
Task t = ...;
await t;

and that has the relevant extension operator in scope will be compiled instead as: C# Task t = ...; await ConfigureAwaitExtensions.<>__await1(t); // compiler-generated name for the operator

That way, I can have a file containing such extensions, include it in my project, and all of my awaits in my project will then subscribe to the relevant behavior. It's also not limited to just working with a fixed set of types, with operators being writable for any type a developer may want to await, even if it doesn't directly expose the awaiter pattern. And such extensions need not be limited to calling ConfigureAwait(false), but could perform arbitrary operations as any operator can. By changing where the operators are defined, I can also limit their impact to just a subset of my project, as is the case for extension methods.

cc: @MadsTorgersen

Asked Dec 14 '21 22:12
avatar stephentoub
stephentoub

9 Answer:

There are some aspects I fundamentally like about this mechanism: - it intercepts the await in a scope-based manner - it uses language level constructs to do so - it avoids undue knowledge of types or members in the compiler/language

Now, needless to say it also hinges on numerous other concepts. This notion of extension operators: We wouldn't want to do those just for the await operator, but would have to flesh that out as a general feature. Oh, and why only extension operators? We'd want to get into "extension everything" (#192). Also, obviously await is not an overloadable operator today, so what does that look like? Also, it would have to be generic (to pass through the result type of Task<T>, ValueTask<T> etc), and we don't have a notion of generic operators today, so what does that look like? And so on. All in all there's a lot to figure out, and a lot of adjacent features to agree on, before this becomes solid.

I also wonder whether the generality of this proposal is worthwhile. Yes, you could use it to extend await for other purposes than ConfigureAwait. But if you do, does that compose with also doing it for ConfigureAwait? Presumably you can't have more than one extension await operator applying to a given type at a given point in the code.

So the way I take it is: It's a great idea to keep around, but the path to it has many challenges. Most of the features along the way are interesting, and align with a lot of our thinking. If we did those things for their broader value, this proposal shows that we could get a solution to ConfigureAwait as an additional benefit.

1
Answered Jul 11 '19 at 19:33
avatar  of MadsTorgersen
MadsTorgersen

needless to say it also hinges on numerous other concepts

All true. To me this highlights that a solution for ConfigureAwait could naturally fall out of solving all those other things that it would be nice (in most cases) to address anyway (how many times have we uttered "if only we had extension everything").

Of course, there are other ways to functionally achieve the same thing. The proposal is putting forth a strawman syntax, but at the end of the day, all it's really doing is providing a way to hook an await. And we already have such a mechanism, GetAwaiter, so essentially this is nothing more than a glorified syntax for writing an extension GetAwaiter method. The rub is that it needs to take precedence over the existing GetAwaiter instance methods that exist today, and I think we can all agree that changing that precedence would be a bad thing; not only would it a massive, unacceptable breaking change to do for all extension methods, it'd be a breaking change to do for just GetAwaiter, and even if we decided that was ok, special-casing it for just GetAwaiter feels very wrong.

Of course, we could introduce another aspect to the awaiter pattern: a type is awaitable not only if it exposes GetAwaiter returning the right shape, but alternatively if it exposes a GetAwaitable which itself returns a GetAwaiter. All of the operators in my original proposal just become extension methods: C# internal static class ConfigureAwaitExtensions { internal static ConfiguredTaskAwaitable GetAwaitable(Task t) => t.ConfigureAwait(false); internal static ConfiguredTaskAwaitable<T> GetAwaitable<T>(Task<T> t) => t.ConfigureAwait(false); internal static ConfiguredValueTaskAwaitable GetAwaitable(ValueTask vt) => vt.ConfigureAwait(false); internal static ConfiguredValueTaskAwaitable<T> GetAwaitable<T>(ValueTask<T> vt) => vt.ConfigureAwait(false); } which of course already exist as a concept, can be generic, etc. And we would suggest that types themselves not expose their own instance GetAwaitable, but instead leave it as something for others to implement in order to hook awaits (we could even go so far as to say that await doesn't consider instance GetAwaitable methods :)).

1
Answered Jul 11 '19 at 20:04
avatar  of stephentoub
stephentoub

There are two reasons I don't like this as extension operators.

First is that there is seemingly one flavor of how these operators would be implemented, so it'd either belong in the BCL, or it would need to be in a common NuGet package, otherwise everyone will end up reimplementing them over and over again.

Second is that as extension operators I can only assume that they would need to be imported via the appropriate namespaces in scope, which makes the solution relatively brittle across a codebase. Accidentally miss a using statement and you're doing the wrong thing. And that namespace would have to be fairly unique with nothing else of interest lest you accidentally change the synchronization behavior just by pulling in some other types.

The attribute approach just seems cleaner to me, both for the compiler and for the developer. Assuming that it can target module/assembly it's set once and forget it. This operator approach seemingly opens several new cans of worms around treating await as an operator in general, extension operators, generic operators, etc., all of which has already been pointed out.

1
Answered Jul 11 '19 at 21:30
avatar  of HaloFour
HaloFour

Which attribute approach? I've yet to see one that's actually viable.

That seems to be a conversation fraught with opinion.

I don't see any problems with the viability of a ConfigureAwaitAttribute(bool), and it seems to be a lot less complicated than the five or so orthogonal proposals necessary to get an extension generic await operator even off the ground, just so that it can accomplish one thing. Even better, the attribute approach could be 100% accomplished via source generators, if they ever become a thing.

1
Answered Jul 11 '19 at 22:01
avatar  of HaloFour
HaloFour

@stephentoub

That applies to what types?

IMO? Just spitballing, but I think I'd allow it to target assembly/module, class/struct and method. The compiler/generator would inspect the current async method for the attribute and if it was not defined there would check the declaring type then the module/assembly. If the attribute is found the compiler/generator would attempt the pattern .ConfigureAwait(bool).GetAwaiter() rather than .GetAwaiter().

I don't doubt that there are problems with this approach, conceptually and from an implementation perspective. But it feels like it involves significantly fewer moving parts than an await operator. I'd like to hear about other flavors of an await operator other than one that calls ConfigureAwait(false).

1
Answered Jul 11 '19 at 22:09
avatar  of HaloFour
HaloFour

I agree with @HaloFour. Having the behavior of your awaits based on the presence/non-presence of a using statement at the top of the file seems like a recipe for mistakes. Sure, an analyzer could help here but I think the whole UX wouldn't be very good. You miss one file and you have a hard-to-find bug hiding in your code.

Having an assembly level targeted attribute which sets the default ConfigureAwait for the whole assembly is far cleaner. You do it once, and you have confidence in the behavior everywhere.

I don't really buy the argument that the compiler or framework specially targeting ConfigureAwait is too over-constrained. This is a construct that is literally used everywhere, and substantially adds to the burden of using async correctly in C#/.NET. IMO, making ConfigureAwait(true) the default was a mistake, and providing a better workaround for that mistake as cleanly as possible (and sooner rather than later) trumps any theoretical argument for increased generality.

1
Answered Jul 12 '19 at 04:20
avatar  of MgSam
MgSam

Why would internals be visible outside the assembly?

This is assuming everybody copies and pastes the code into their project manually.

I think it highly likely someone, somewhere will create a package where this extension method is public and in the global namespace, and it will infect a lot of downstream assemblies.

Is this a risk you are willing to take?

1
Answered Jul 12 '19 at 13:53
avatar  of YairHalberstadt
YairHalberstadt

I fail to see how this specific point would be an issue. There are already plenty of ways a nuget package can affect a whole application. If a library does that and this is not a desirable behavior for you, you simply stop using that library and pick another one.

Indeed. The risk here is it's something that is easy to do, seemingly innocuous, and quite hard to detect.

The issue is you're telling everybody whose writing a library that they have to copy and paste this specific file into every single project they have. Somebody is definitely going to have the bright idea of simply making it public, and then everyone who depends on this library will have their behaviour subtly changed.

I'm not saying this is definitely going to be a disaster. I'm saying that this probably makes it a lot more likely people will do the wrong thing than other features that have the ability to effect every consumer. The risk ought to be considered, even if it's decided the benefits are worth it.

1
Answered Jul 12 '19 at 15:26
avatar  of YairHalberstadt
YairHalberstadt

I do like the ability to intercept await as an operator one way or another. Recently, I've been working on a system in C++ using upcoming support for coroutines in C++20, which offers ample extensibility points we've been using to thread through schedulers and mechanisms akin to ExecutionContext to flow state across co_await sites.

Three things have been particularly useful and are relevant to this conversion:

  • Binding of co_await e first looks for an await_transform method on the promise type, which is analogous to the async method builder type in .NET. It allows to transform an awaiter and co_await the result returned by await_transform.
  • Binding of operator co_await, which is the equivalent of GetAwaiter and can either be found either as a member on non-member overload, akin to support for binding GetAwaiter as an extension method. The main difference is that co_await is an operator.
  • Ability to bind to an "awaiter" without the presence of an operator co_await, which would be equivalent to await being willing to bind to an object that has the shape of what's returned by GetAwaiter today.

Quoting @stephentoub on this possibility:

Of course, we could introduce another aspect to the awaiter pattern: a type is awaitable not only if it exposes GetAwaiter returning the right shape, but alternatively if it exposes a GetAwaitable which itself returns a GetAwaiter.

This is very similar to the latter two extensibility points in C++ coroutines, effectively adding two chances for binding await e. In C++, there are three levels: e.operator co_await() as a member, operator co_await(e) as a non-member, or e (requiring the "awaiter" pattern). In the method-based approach for C#, there'd be two levels: e.GetAwaitable() returning an awaitable and e.GetAwaiter() returning an awaiter.

For the (extension) operator-based approach, I assume the thinking is that a regular (non-extension) operator await would be added to the language, but types such as [Value]Task[<T>] would not have such an operator defined (as a public static … operator await() on those types themselves).

Then, if an extension operator await is defined, it takes precedence. If no operator await exists at all, the existing rules apply to detect the awaitable pattern. So the rules would be similar to the GetAwaitable approach above:

  1. find operator await on the type of the await operand (~ GetAwaitable instance method, or operator co_await as a member lookup in C++);
  2. find an extension operator await based on the new concept of extension operators (~ GetAwaitable extension method, or operator co_await as a non-member lookup in C++);
  3. existing rules to treat the await operand as having the awaitable pattern (~ GetAwaiter, or treating the object itself as an "awaiter" in C++).

Without a "regular" operator await being supported to be defined on a type (just like any existing unary operator definition), we'd have some new notion of an operator that only exists as an "extension operator". This would also feel like an extension operator await taking precedence over an instance GetAwaiter, which is backwards compared to existing extension methods being the last resort for binding.

FWIW, another extensibility point that could be considered is an analogous concept to await_transform on promise types in C++, which we've been using extensively to intercept co_await sites in a coroutine method, e.g. to capture and restore context across suspension points, but also to implement a coroutine scheduling scheme. (In fact, we combine this with "parameter preview" capabilities on coroutine methods to fish out an allocator from the parameters on the coroutine method, which is similar to weaving a concept like cancellation through async methods.)

The C# analogous concept to this would be some instance method on the async method builder, which does not exist in the BCL by default, thus opening up for it to be defined as an extension method. While potentially more flexible (i.e. the ability to substitute one awaitable for another one, e.g. wrapping the original one), it likely gets unweildy quickly because on needs to define extension methods like this:

static ConfiguredTaskAwaiter<T> GetAwaiter<R, T>(this AsyncTaskMethodBuilder<R> builder, Task<T> task) {  }

which has way more combinations than the four task variants because it also involves the return type of the async method (for which we have five distinct builder types), unless there's some common (interface) type across all builder types to tame this:

static ConfiguredTaskAwaiter<T> GetAwaiter<Builder, T>(this Builder builder, Task<T> task) where Builder : IAsyncMethodBuilder {  }

In a way, it's similar to AwaitOnCompleted methods on the builder operating on the awaitees within the async method, though it'd serve a different complementary purpose. For plain ConfigureAwait it's most likely overly complex (and users have to see the System.Runtime.CompilerServices builder types they likely have never heard of), but it could be a design point if other behavior adapters for await are desirable and could benefit from context provided by the builder. Quoting @stephentoub:

Wanting to make all awaits cancelable via a global token. Wanting a timeout on all awaits. Wanting additional logging around all awaits. Wanting to flow additional state (e.g. in specific thread locals) across awaits. Wanting to force all awaits to complete asynchronously. Wanting to schedule all continuations to a specific scheduler. Wanting to override the SyncCtx/TaskScheduler behavior to instead look for a different ambient scheduler.

Things like cancellation, timeouts, schedulers, etc. seem like they would likely be threaded through async methods as parameters rather than being globals (though async locals may be an alternative for a subset of those in some cases). At that point, these things become contextual, and the nearest relevant context for an await site is the containing async method, so getting a handle to that would be desirable.

Obviously, the question becomes how to "fish out" parameters and have them be accessible to the transformer of the awaitees. The [EnumeratorCancellation] attribute for async enumerables is in fact a bit similar and a very specific case of this more general pattern; it carries a top-level parameter down to a synthesized construct. (For comparison, in the C++ land, one uses a variadic template to preview the parameters of the coroutine and uses template metaprogramming techniques to fish out things, the simplest of which is the "leading allocator convention" which feels very similar to the "trailing cancellation token convention" in .NET.)

In fact, await foreach is another place where ConfigureAwait(false) can occur in the body of an async method, this time on an IAsyncEnumerable<T> to influence all await sites. It'd be great for whatever proposal on implicit ConfigureAwait application to cover this case as well. It may just fall out from the await expressions on the ValueTask<bool> and ValueTask values returned from await foreach lowering being subject to binding rules that pick up on operator await or some GetAwaitable extension, but it would involve more binding steps after initial lowering of await foreach.

Alternatively, one could think of it as await foreach being "transformed" in the context of the surrounding async (iterator) method, thus allowing transformations for ConfigureAwait or passing of cancellation tokens to be applied to the source operand of await foreach (prior to lowering). With an await_transform type of thing, it could be (yet another) overload for IAsyncEnumerable<T> that returns the configured variant (or, with the ability to pick up on more async method context, the WithCancellation variant of the sequence to thread cancellation down).

Just one final thought from more noodling with C++ coroutines lately. We've been using initial_suspend and final_suspend as extensibility points as well, in combination with await_transform. One place where this became handy is to track execution of async coroutine methods (when they get kicked off, when they get suspended due to a co_await, and when they complete), both for diagnostics (async call stacks, async "task manager" to see what's running and how much time is spent, etc.) but also for scheduling decisions (e.g. one can force a co_await to suspend based on the containing coroutine's runtime relative to other coroutines running on the same scheduler, thus forcing a "context switch").

Wanting a timeout on all awaits.

Timeouts reminded me of this due to the similarity with our accounting voodoo. Sometimes one wants to compute timeouts from an initial budget (the timeout of the overall operation, no matter how many await sites it has) and actual execution time, rather than having all awaits being subject to the same timeout. Support for some contextual "await transformer" akin to await_transform could be use to transform each await e by an await e.WithTimeout() where WithTimeout is similar to Task.WhenAny(e, Task.Delay(t)) but also takes care of getting the remaining timeout t (e.g. from an async local) and adjusting it upon resumption).

All in all, I think having a look at C++ coroutines may be worth it as just another data point. The degree of extensibility over there is quite high, some of which may not carry over to C# (or not be desirable at all) but could provide another point of view that could be useful here. I, for one, found all the knobs provided over there to be useful.

1
Answered Jul 12 '19 at 20:33
avatar  of bartdesmet
bartdesmet