Closed jmarolf closed 2 years ago
Tagging subscribers to this area: @dotnet/area-system-threading-tasks See info in area-owners.md if you want to be subscribed.
Author: | jmarolf |
---|---|
Assignees: | - |
Labels: | `api-suggestion`, `area-System.Threading.Tasks` |
Milestone: | - |
cc: @stephentoub
This is a duplicate of https://github.com/dotnet/runtime/issues/58692.
Aren't these trivially implemented with await, e.g.
public static async Task<TNewResult> Then<TResult, TNewResult>(this Task<TResult> task, Func<TResult, TNewResult> selector) =>
selector(await task);
?
If we assume we have these implementations:
public static async Task<TNewResult> Then<TResult, TNewResult>(this Task<TResult> task, Func<TResult, TNewResult> selector) =>
selector(await task);
public static async Task<TNewResult> Then<TResult, TNewResult>(this ConfiguredTaskAwaitable<TResult> task, Func<TResult, TNewResult> selector) =>
selector(await task);
Then these are the behaviors we would observer, correct?
// unlikely to be true for this api but lets assume I deadlock if I do not ConfigureAwait(false) in this method
string text1 = (await File.ReadAllTextAsync(path).ConfigureAwait(false)).ReplaceLineEndings();
// not equivalent the expression that got us text1 if Then is "nakedly" awaiting the task
string text2 = await File.ReadAllTextAsync(path).Then(static text => text.ReplaceLineEndings()).ConfigureAwait(false);
// is equivalent to the expression for text1 but ConfigureAwait most be chained on all task return types.
string text3 = await File.ReadAllTextAsync(path).ConfigureAwait(false).Then(static text => text.ReplaceLineEndings()).ConfigureAwait(false);
await
ing a task "nakedly" is not a safe thing to do in many instances. We could say that is fine just append ConfigureAwait
in all our your expressions though, in which case this proposal has no value.
We could say that is fine just append ConfigureAwait in all our your expressions though, in which case this proposal has no value.
A built-in API would have to choose whether to use ConfigureAwait or not; it's going to get it wrong for some subset of consumers. Someone writing the one-liners themselves can choose for themselves in what context they want to run the delegate.
If the implementation of Then
just continues after the Task
is run then this is not the case right? The selector does not actually need to await to run, it just needs the underlying task to have completed.
This can be implemented with the existing ContinueWith
apis today but with some drawbacks and inefficiencies that are not possible to work around.
The selector does not actually need to await to run, it just needs the underlying task to have completed.
await is just a mechanism for waiting for the task to have completed, as is ContinueWith, as is doing a blocking Wait on the task, etc.
If the implementation of Then just continues after the Task is run then this is not the case right?
It's the case regardless of implementation detail. Des the delegate need to run in the original SynchronizationContext / TaskScheduler or is it ok for it to be invoked wherever? ConfigureAwait is a mechanism to implement the answer to that question specifically for await. If the delegate needs to be run in the original context, then don't use ConfigureAwait (or use ConfigureAwait(true). If it doesn't matter where the delegate is run, then use ConfigureAwait(false).
Imagine this was in a WinForms app, and someone wrote Task.Delay(1000).Then(() => textBox.Text = "Timer fired")
... then you want the delegate running back in the original sync context, and using ConfigureAwait(false) would be wrong. If it was your "lets assume I deadlock if I do not ConfigureAwait(false) in this method I deadlock" scenario, then not using ConfigureAwait(false) would be wrong.
If you're not using await and ConfigureAwait to implement these semantics, then you're using something else to implement the desired semantics... the desired semantics still need to be implemented :)
right, but (hopefully) you would only need to configure the task once and those semantics can be "passed along" to subsequent Then
calls. re-configuring them each time seems unnecessarily verbose.
One of the core thesis that I assume here is that manipulating tasks in expressions is not a negative thing. Today is it very hard to do correctly so most people just have separate statements for each await
. Then
implies that we think code like this:
var service = await serviceLocator.GetRequiredServiceAsync<MyService>();
service = service.WithCustomSetting(setting);
var value = await service.TryGetValueAsync(request);
or this (of course no one is actually writing this code):
var value = (await (await serviceLocator.GetRequiredServiceAsync<MyService>())
.WithCustomSetting(setting)
.TryGetValueAsync(request));
could be written like this:
var value = await serviceLocator.GetRequiredServiceAsync<MyService>()
.Then(static (service, setting) => service.WithCustomSetting(setting), setting)
.Then(static (service, request) => service.TryGetValueAsync(request), request)
.UnWrap();
With configuration for the schedule and context happening at the "main" task with Then
calls just doing transformations on the final result without needing to also be configured. Certainly, if someone does want different synchronization contexts or schedulers at different points the evaluation of an expression then this doesn't buy them anything. In my opinion that is not the typical use case.
but (hopefully) you would only need to configure the task once and those semantics can be "passed along" to subsequent Then calls
No, there is no concept of "how should this task be awaited" baked into a Task or somehow propagated from task to task... there might not even be a task, you might be awaiting something that's not a task, the async state machine might itself not be a task, etc. The configuration is about how a given await is performed, not anything to do with the Task itself; it simply impacts how a continuation is hooked up. We would not make Task more expensive for everyone to carry along that additional information for Task; it would also be a significant breaking change if awaits / ContinueWith / etc. were to change their default behavior based on how something previously in the await chain was awaited. (Also, note that the proposed APIs would result in significantly more expensive execution than just using await multiple times in the same async method.)
Today is it very hard to do correctly so most people just have separate statements for each await.
If that's really an issue, then we should fix that in the language.
Or we could copy rust and make await a postfix like rust:
var service = serviceLocator.GetRequiredServiceAsync<MyService>().await.
service.WithCustomSetting(setting).await.service.TryGetValueAsync(request);
Burns my eyes but, solves the problem 😄
If that's really an issue, then we should fix that in the language.
I have a proposal out for that, and perhaps that will get implemented in the next 10+ years :) but it's always less expensive to implement as a library feature (if possible) so I thought I would do my due diligence and start a discussion here.
No, there is no concept of "how should this task be awaited" baked into a Task or somehow propagated from task to task
Excellent point. If I must be in a specific context there is no way an extension method could be implemented to do this. If passing this information along is simply impossible, then this isn't something that can be done in the BCL and I can give the feedback to the LDM.
@davidfowl because of how complex task configurations can be post fix awaits were already rejected by the LDM: https://github.com/dotnet/csharplang/issues/4076. It sounds like that same complexity makes doing anything at the library level equally difficult.
Imagine this was in a WinForms app, and someone wrote Task.Delay(1000).Then(() => textBox.Text = "Timer fired")... then you want the delegate running back in the original sync context, and using ConfigureAwait(false) would be wrong. If it was your "lets assume I deadlock if I do not ConfigureAwait(false) in this method I deadlock" scenario, then not using ConfigureAwait(false) would be wrong.
This is a good argument for not adding this api on Task
and only Task<TResult>
as I agree, I have no idea what a general Action
is supposed to do for something like this.
For an example where task does return a value:
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
_ = StartTask(TaskScheduler.FromCurrentSynchronizationContext());
}
private async Task StartTask(TaskScheduler scheduler)
{
var factory = new TaskFactory(scheduler);
var text = await factory.StartNew(async () =>
{
await Task.Delay(1000).ConfigureAwait(false);
return 42;
}).Then(x =>
{
var newText = textBox1.Text = $"Timer fired";
return newText;
}).ConfigureAwait(true);
}
}
You would need something like this:
public static Task<TNewResult> Then<TResult, TNewResult>(this Task<TResult> task, Func<TResult, TNewResult> selector)
{
var scheduler = TaskScheduler.FromCurrentSynchronizationContext();
return task.ContinueWith(ContinuationFunction, state: (object?)selector, scheduler);
static TNewResult ContinuationFunction(Task<TResult> task, object? state)
{
if (task.IsCompletedSuccessfully && state is not null)
{
var selector = (Func<TResult, TNewResult>)state;
return selector(task.Result);
}
// this helper needs to account for canceled tasks and exceptions
throw new InvalidOperationException();
}
}
For things to work correctly. I would still argue that it is technically possible for us to have the Then
method run in the same manner as its "parent" Task, though the performance may be so bad that there is no point.
I assume that is the central argument here? While this could be done the expense buys you little or implementation would be so complex it would not be worth the cost of maintaining it?
I would still argue that it is technically possible
Anything is "technically possible", but at what cost and at what mental complexity. Besides, this puts things back into a callback-based world. The moment you want to do anything more complex, like loop, the complexity goes through the roof. That's the whole reason await was introduced in the first place. await
is our story for composing tasks. I do not want us to go backwards by adding more callback-based APIs for such composition.
yep, you would need to either add all the same overloads that ContinueWith
has (which makes this an exact duplicate of https://github.com/dotnet/runtime/issues/58692) or you have a complex internal implementation that might be easier to use (for some definition of "easier") but won't be worth the large cost to implement and maintain. and its unclear if we could even achieve an acceptable level of performance for all this complexity.
await is our story for composing tasks. I do not want us to go backwards by adding more callback-based APIs for such composition.
This is also a great POV for me to keep in mind. From my point of view many .NET developers are used to "callbacks" as that is the pattern that Linq and most "fluent apis" use, which are extremely common. The fact that await
plays so poorly here is unfortunate but it is, at its core, a language design issue. I will try and push things as best I can on the language side first.
For future generations that may encounter this issue :) it appears that the pipe operator or some form of general monadic transformation is the most likely solution to be accepted by the csharp language designers.
API Proposal
The functions given to these
Then
methods are only called ifTask.IsCompletedSuccessfully
would be true. Otherwise, aTask
with the same cancellation state and exception information is returned.API Usage
Background and motivation
Method chaining is a very common way to manipulate data.
and today expressions are one of the main ways in which data gets manipulated in .NET with C# continuing to make it easier to "expression-ify" things where it makes sense to do so.
Unfortunately, once async comes into the picture you need to wrap things in parenthesis in order for the associativity to keep working the way the developer wants and we need to be in an async method.
You can try and solve some of this with the
ContinueWith
methods but they have some usability issues.Lets say we wanted to write our own extension method with a signature like this
a naïve implementation may look like this
A few problems with this:
1. It needs to handle cancellation and exceptions
This is not impossible to do just tricky. It is unfortunate that this is necessary. In a world where folks are
await
ing Tasks, cancellation and exceptions are going to be thrown at the call site of theawait
. Handling these cases shouldn't be required and it would be nice to have a method that is only called if the task completes successfully instead of forcing this concern on developers using theContinueWith
api.2. You cannot pass in args without boxing
Since
ContinueWith
only exceptsobject?
for its state type you cannot do something like this without boxing:3. This approach cannot be taken on
ValueTask
If you wanted to write a similar helper for ValueTask you could do
but this:
ValueTask
to aTask
, negating the point ofValueTask
in many casesTask
, which may not be a safe thing to do (should it be configured? if so how?)Alternative Designs
Name
Then
is the name take by many other constructs of this type in other languages, but I could see someone arguing forSelect
.As this has a similar "transform a deferred value" feel that linq has.
Interaction with enumeration
You could also imagine an extension method like this being considered:
But once the core
Then
method exists writing this extension is trivial enough to get right that I do not think it is worth including.One thing that would be hard to get right is something like this:
since it would require us to
await
the move next which may not be a safe thing to do without configuring the task. However, at this point we are just talking about implementing theSelect
linq method forIAsyncEnumerable
types. If we ever do that, we can decide on the correct way such and enumerable would be written takingConfigureAwait
into account. Seems safe to punt on this concern for now.Risks
My assumption is that
Task
andValueTask
are some of the most used types in .NET so modifying them in any way carries a great risk. I think that the implementation here could just be extension methods on these types withinternal
access so they can be implemented efficiently but if they do actually need to exist on the types themselves there is the concern of increasing the size of these types at all as thay are so common it could decrease .NET performance by allocating more resources.