csharplang
csharplang copied to clipboard
Proposal: Extension await operator to address scoped ConfigureAwait
Library developers are often frustrated at needing to use .ConfigureAwait(false)
on all of their await
s, 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.
internal static class ConfigureAwaitExtensions
{
internal static ConfiguredTaskAwaitable operator await (Task t) => t.ConfigureAwait(false);
internal static ConfiguredTaskAwaitable<T> operator await<T>(Task<T> t) => t.ConfigureAwait(false);
internal static ConfiguredValueTaskAwaitable operator await (ValueTask vt) => vt.ConfigureAwait(false);
internal static ConfiguredValueTaskAwaitable<T> operator await<T>(ValueTask<T> 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:
Task t = ...;
await t;
and that has the relevant extension operator in scope will be compiled instead as:
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
Another option would be the ability to override the use of AsyncTaskMethodBuilder
(or similar for other return types) by the compiler's lowering features. Something like an assembly-level AsyncMethodBuilderAttribute
that could be applied.
Another option would be the ability to override GetAwaiter()
used for pattern-based await operations, but I'm not sure what form that would take to not be concerning.
Something like an assembly-level AsyncMethodBuilderAttribute that could be applied.
See https://github.com/dotnet/csharplang/issues/1407. But such an approach is both way too heavy for this and also insufficient: there's no way a builder can control how an awaiter invokes a callback.
Another option would be the ability to override GetAwaiter() used for pattern-based await operations
That's basically what this is.
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.
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:
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 :)).
@stephentoub Yeah, that seems like a much lower cost approach to getting the problem solved, with most of the same basic properties. Of course relying on another (extension) method to take precedence is still a breaking change, because that method could exist today, and doesn't take precendence! :smile:
method to take precedence is still a breaking change, because that method could exist today, and doesn't take precendence!
Yeah, was hoping you wouldn't notice that ;) That's actually from my perspective a key benefit of the operator approach: you couldn't have written them today. There are of course ways around that, e.g. introducing a new attribute that doesn't exist today and requiring the method to be attributed. And while, sure, someone could have had both that method and attribute in their own code and had the latter on the former, I'm willing to accept that break (and we could do the whole poisoning thing if we weren't).
there's no way a builder can control how an awaiter invokes a callback
There is no need to do so. The builder can remove or replace the synchronization context for the operation before the awaiter is created. I'm not saying one way is better or worse, just presenting alternatives which could achieve the same final outcome.
The builder can remove or replace the synchronization context for the operation before the awaiter is created.
Not without changing other semantics the method may be relying on. And there's also the TaskScheduler that awaited tasks pay attention to. And other schedulers that other awaited things may pay attention to. And so on.
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.
The attribute approach just seems cleaner to me
Which attribute approach? I've yet to see one that's actually viable.
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.
I don't see any problems with the viability of a ConfigureAwaitAttribute(bool)
That applies to what types?
@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)
.
I agree with @HaloFour. Having the behavior of your await
s 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.
presence of a using statement
using for what? There's no namespace in my example. It's no different from the attribute in that regard: either you include the code or you don't. The arguments about it being easier to prove correct do not resonate with me.
specially targeting ConfigureAwait
From my perspective, ConfigureAwait is an ugly but necessary workaround we should not be propagating into the C# language, which the attribute does. I disagree that the attribute is "cleaner".
I'd like to hear about other flavors of an await operator other than one that calls ConfigureAwait(false).
I get asked about wanting this ability on some what regular basis. 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. And so on. And where the "all" here might be for an assembly, or a particular class.
using for what? There's no namespace in my example. It's no different from the attribute in that regard: either you include the code or you don't. The arguments about it being easier to prove correct do not resonate with me.
If it's put in the global namespace, then if someone imported it into a nuget package it would effect every package that consumes that package wouldn't it?
@stephentoub
There's no namespace in my example.
There has to be, otherwise you screw up everyone's notion of await
and you can't have different custom versions within a project.
From my perspective, ConfigureAwait is an ugly but necessary workaround we should not be propagating into the C# language, which the attribute does.
I also agree, but here we are.
I get asked about wanting this ability on some what regular basis.
- All things that you can accomplish via a custom synchronization context.
- All things that you can't manage well with extension methods because of the fact that you're pulling them into an entire source file/namespace by
using
statements.
If it's put in the global namespace, then if someone imported it into a nuget package it would effect every package that consumes that package wouldn't it?
Why would internal
s be visible outside the assembly?
All things that you can accomplish via a custom synchronization context.
This is simply not true.
There has to be, otherwise you screw up everyone's notion of await and you can't have different custom versions within a project.
There doesn't have to be. You can have going ones for the whole project, and ones in namespaces to pull in specific ones.
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?
@stephentoub
Why would
internal
s be visible outside the assembly?
So the expectation is that every developer will duplicate their own versions of these operators?
How about this, have the ConfigureAwaitAttribute
cause the compiler to emit the internal
versions of those operator extensions? At least then we can avoid having tons of broken/buggy implementations floating around.
That came off kind of snarky and I apologize.
Actually, if all of the proposals required to make extension await
a thing happen as well as source generators, that sounds like it might actually be a good way to have those operators generated.
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?
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.
Wanting to schedule all continuations to a specific scheduler. Wanting to override the SyncCtx/TaskScheduler behavior to instead look for a different ambient scheduler. And so on. And where the "all" here might be for an assembly, or a particular class.
For those points it would be even better to have some kind of scoping mechanism, to be able to also affect the external libraries (and the BCL) as well. But I agree it would already be a huge improvement.
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.
I'd like to hear about other flavors of an await operator other than one that calls ConfigureAwait(false).
I get asked about wanting this ability on some what regular basis. 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. And so on. And where the "all" here might be for an assembly, or a particular class.
Operators extensions seem like a poor place to put these functionalities, as they all share the same signature. So if you needed more than one of them in a project, you would need to define all the various combinations you need, you would need to define them each in seperate classes, and you would only be able to use one in any given scope. Anyone wanting "most" or "some" rather than "all" awaits to have a given behavior would need to hack around the extensions. Whereas attributes would allow much more granular control over what methods/classes/modules/assemblies do what, as well allowing mix and match.
We introduce the notion of extension operators
Outside of the specific await
overload-ability, I really like this idea overall
To address the worries it could be used to "mess up" the task types, it could just be that {Value}Task{<T>}
internally defines the operators - then extension ones would never be invoked
public partial {class|struct} {Value}Task{<T>}
{
public bool IsAwaitConfigured { get; set; } = false;
public static {void|T} operator await({Value}Task{<T>} task)
{
task.ConfigureAwait(IsAwaitConfigured);
}
}
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 anawait_transform
method on the promise type, which is analogous to the async method builder type in .NET. It allows to transform an awaiter andco_await
the result returned byawait_transform
. - Binding of
operator co_await
, which is the equivalent ofGetAwaiter
and can either be found either as a member on non-member overload, akin to support for bindingGetAwaiter
as an extension method. The main difference is thatco_await
is an operator. - Ability to bind to an "awaiter" without the presence of an
operator co_await
, which would be equivalent toawait
being willing to bind to an object that has the shape of what's returned byGetAwaiter
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:
- find
operator await
on the type of theawait
operand (~GetAwaitable
instance method, oroperator co_await
as a member lookup in C++); - find an extension
operator await
based on the new concept of extension operators (~GetAwaitable
extension method, oroperator co_await
as a non-member lookup in C++); - 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 await
s 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.
#2488