dotnet / csharplang

The official repo for the design of the C# programming language
11.62k stars 1.03k forks source link

[Proposal]: Cancel Async Functions Without Throw #4565

Open timcassell opened 3 years ago

timcassell commented 3 years ago

Cancel Async Functions Without Throw

Summary

Cancelation of async functions is currently only possible by throwing an OperationCanceledException. This proposal allows canceling async functions directly via awaiters implementing an IsCanceled property and AsyncMethodBuilders implementing a void SetCanceled<TAwaiter>(ref TAwaiter) method. Finally blocks are allowed to run after an awaiter is canceled, just like if an exception were thrown.

Previous Discussion

Motivation

Performance! Benchmarks show up to 1000x performance improvement when canceling directly instead of throwing an exception. Source

|              Method | Recursion |          Mean |        Error |       StdDev |    Ratio | RatioSD |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|-------------------- |---------- |--------------:|-------------:|-------------:|---------:|--------:|-------:|------:|------:|----------:|
|   DirectCancelation |         5 |      66.53 ns |     0.193 ns |     0.161 ns |     1.00 |    0.00 |      - |     - |     - |         - |
| ThrowNewCancelation |         5 |  70,834.43 ns |   451.118 ns |   399.905 ns | 1,065.02 |    6.72 | 0.3662 |     - |     - |    1248 B |
|  RethrowCancelation |         5 |  66,003.14 ns |   613.233 ns |   543.615 ns |   992.29 |    8.66 | 0.1221 |     - |     - |     568 B |
|                     |           |               |              |              |          |         |        |       |       |           |
|   DirectCancelation |        10 |     122.94 ns |     0.063 ns |     0.053 ns |     1.00 |    0.00 |      - |     - |     - |         - |
| ThrowNewCancelation |        10 | 128,770.42 ns | 1,330.938 ns | 1,244.960 ns | 1,046.39 |   10.72 | 0.4883 |     - |     - |    2288 B |
|  RethrowCancelation |        10 | 119,599.25 ns |   695.557 ns |   616.593 ns |   972.49 |    5.02 | 0.2441 |     - |     - |     928 B |
|                     |           |               |              |              |          |         |        |       |       |           |
|   DirectCancelation |        20 |     320.03 ns |     0.403 ns |     0.377 ns |     1.00 |    0.00 |      - |     - |     - |         - |
| ThrowNewCancelation |        20 | 249,736.53 ns | 2,580.620 ns | 2,413.913 ns |   780.35 |    7.51 | 0.9766 |     - |     - |    4368 B |
|  RethrowCancelation |        20 | 227,962.82 ns | 2,017.599 ns | 1,887.263 ns |   712.32 |    5.93 | 0.4883 |     - |     - |    1648 B |

Detailed design

Any awaiter can implement the bool IsCanceled { get; } property to enable fast-tracked async cancelations. The compiler could use duck-typing to check for the property (like it does with GetResult), or a new interface (like it does with I(Critical)NotifyCompletion).

AsyncMethodBuilders will need to add a new method void SetCanceled<TAwaiter>(ref TAwaiter awaiter) to enable fast-tracked cancelations for the async return type, where awaiter is the awaiter whose IsCanceled property returned true. Both the awaiter and the builder must have those methods in order for the compiler to emit the fast-tracked cancelation instructions, otherwise it falls back to the existing implementation.

Tasks won't be using the ref TAwaiter awaiter, it will just set the task to canceled. The purpose of the ref TAwaiter awaiter is for custom task-like types to be able to accept custom cancelations.

Example state-machine outputs:

CancellationToken and Task extensions: ```cs public static class CancellationTokenExtensions { public readonly struct CancelAsyncAwaitable : INotifyCompletion { private readonly CancellationToken _token; public CancelAsyncAwaitable(CancellationToken token) => _token = token; public CancelAsyncAwaitable GetAwaiter() => this; public bool IsCompleted => true; public bool IsCanceled => _token.IsCancellationRequested; public void GetResult() => _token.ThrowIfCancellationRequested(); void INotifyCompletion.OnCompleted(Action continuation) => throw new NotImplementedException(); } public static CancelAsyncAwaitable CancelAsyncIfCancellationRequested(this CancellationToken token) { return new CancelAsyncAwaitable(token); } } public static class TaskExtensions { public readonly struct CancelAsyncAwaitable : ICriticalNotifyCompletion { private readonly Task _task; private readonly TaskAwaiter _awaiter; public CancelAsyncAwaitable(Task task) { _task = task; _awaiter = task.GetAwaiter(); } public CancelAsyncAwaitable GetAwaiter() => this; public bool IsCompleted => _awaiter.IsCompleted; public bool IsCanceled => _task.Status == TaskStatus.Canceled; public void GetResult() => _awaiter.GetResult(); public void OnCompleted(Action continuation) => _awaiter.OnCompleted(continuation); public void UnsafeOnCompleted(Action continuation) => _awaiter.UnsafeOnCompleted(continuation); } public static CancelAsyncAwaitable CancelAsyncIfCanceled(this Task task) { return new CancelAsyncAwaitable(task); } } ```

Simple Example:

public class M
{
    async Task<int> FuncAsync(CancellationToken token)
    {
        await Task.Yield();
        await token.CancelAsyncIfCancellationRequested();
        return 42;
    }
}
Equivalent C#: ```cs public class M { [StructLayout(LayoutKind.Auto)] [CompilerGenerated] private struct _d__0 : IAsyncStateMachine { public int _1__state; public AsyncTaskMethodBuilder _t__builder; public CancellationToken token; private YieldAwaitable.YieldAwaiter _u__1; private CancellationTokenExtensions.CancelAsyncAwaitable _u__2; private void MoveNext() { int num = _1__state; int result; try { CancellationTokenExtensions.CancelAsyncAwaitable awaiter; YieldAwaitable.YieldAwaiter awaiter2; if (num != 0) { if (num == 1) { awaiter = _u__2; _u__2 = default(CancellationTokenExtensions.CancelAsyncAwaitable); num = (_1__state = -1); goto IL_00cb; } awaiter2 = Task.Yield().GetAwaiter(); if (!awaiter2.IsCompleted) { num = (_1__state = 0); _u__1 = awaiter2; _t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this); return; } } else { awaiter2 = _u__1; _u__1 = default(YieldAwaitable.YieldAwaiter); num = (_1__state = -1); } awaiter2.GetResult(); awaiter = CancellationTokenExtensions.CancelAsyncIfCancellationRequested(token).GetAwaiter(); if (!awaiter.IsCompleted) { num = (_1__state = 1); _u__2 = awaiter; _t__builder.AwaitOnCompleted(ref awaiter, ref this); return; } goto IL_00cb; IL_00cb: if (awaiter.IsCanceled) { _1__state = -2; _t__builder.SetCanceled(ref awaiter); return; } awaiter.GetResult(); result = 42; } catch (Exception exception) { _1__state = -2; _t__builder.SetException(exception); return; } _1__state = -2; _t__builder.SetResult(result); } void IAsyncStateMachine.MoveNext() { //ILSpy generated this explicit interface implementation from .override directive in MoveNext this.MoveNext(); } [DebuggerHidden] private void SetStateMachine(IAsyncStateMachine stateMachine) { _t__builder.SetStateMachine(stateMachine); } void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine this.SetStateMachine(stateMachine); } } [AsyncStateMachine(typeof(_d__0))] private Task FuncAsync(CancellationToken token) { _d__0 stateMachine = default(_d__0); stateMachine._t__builder = AsyncTaskMethodBuilder.Create(); stateMachine.token = token; stateMachine._1__state = -1; stateMachine._t__builder.Start(ref stateMachine); return stateMachine._t__builder.Task; } } ```

Complex Example:

public class M
{
    async Task<int> FuncAsync(CancellationToken token)
    {
        try
        {
            await OtherFuncAsync().CancelAsyncIfCanceled();
            await token.CancelAsyncIfCancellationRequested();
            return 42;
        }
        finally
        {
            await Task.Delay(1);
        }
    }
}
Equivalent C#: ```cs public class M { [StructLayout(LayoutKind.Auto)] [CompilerGenerated] private struct _d__0 : IAsyncStateMachine { public int _1__state; public AsyncTaskMethodBuilder _t__builder; public M _4__this; public CancellationToken token; private object _7__wrap1; private int _7__wrap2; private int _7__wrap3; private TaskExtensions.CancelAsyncAwaitable _u__1; private CancellationTokenExtensions.CancelAsyncAwaitable _u__2; private TaskAwaiter _u__3; private int _1__canceler; private void MoveNext() { int num = _1__state; M m = _4__this; int result = default(int); try { TaskAwaiter awaiter; if ((uint)num > 1u) { if (num == 2) { awaiter = _u__3; _u__3 = default(TaskAwaiter); num = (_1__state = -1); goto IL_017b; } _7__wrap1 = null; _7__wrap2 = 0; } object obj2; try { CancellationTokenExtensions.CancelAsyncAwaitable awaiter2; TaskExtensions.CancelAsyncAwaitable awaiter3; if (num != 0) { if (num == 1) { awaiter2 = _u__2; _u__2 = default(CancellationTokenExtensions.CancelAsyncAwaitable); num = (_1__state = -1); goto IL_0100; } awaiter3 = TaskExtensions.CancelAsyncIfCanceled(m.FuncAsync(default(CancellationToken))).GetAwaiter(); if (!awaiter3.IsCompleted) { num = (_1__state = 0); _u__1 = awaiter3; _t__builder.AwaitUnsafeOnCompleted(ref awaiter3, ref this); return; } } else { awaiter3 = _u__1; _u__1 = default(TaskExtensions.CancelAsyncAwaitable); num = (_1__state = -1); } if (awaiter3.IsCanceled) { _u__1 = awaiter3; _1__canceler = 1; goto IL_finally; } awaiter3.GetResult(); awaiter2 = CancellationTokenExtensions.CancelAsyncIfCancellationRequested(token).GetAwaiter(); if (!awaiter2.IsCompleted) { num = (_1__state = 1); _u__2 = awaiter2; _t__builder.AwaitOnCompleted(ref awaiter2, ref this); return; } goto IL_0100; IL_0100: if (awaiter2.IsCanceled) { _u__2 = awaiter2; _1__canceler = 2; goto IL_finally; } awaiter2.GetResult(); _7__wrap3 = 42; _7__wrap2 = 1; } catch (object obj) { obj2 = (_7__wrap1 = obj); } IL_finally: awaiter = Task.Delay(1).GetAwaiter(); if (!awaiter.IsCompleted) { num = (_1__state = 2); _u__3 = awaiter; _t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; } goto IL_017b; IL_017b: awaiter.GetResult(); obj2 = _7__wrap1; if (obj2 != null) { Exception obj3 = obj2 as Exception; if (obj3 == null) { throw obj2; } ExceptionDispatchInfo.Capture(obj3).Throw(); } int num2 = _7__wrap2; if (num2 == 1) { result = _7__wrap3; } else { _7__wrap1 = null; } } catch (Exception exception) { _1__state = -2; _t__builder.SetException(exception); return; } _1__state = -2; int canceled = _1__canceler; switch (canceled) { case 1: { _t__builder.SetCanceled(ref _u__1); return; } case 2: { _t__builder.SetCanceled(ref _u__2); return; } } _t__builder.SetResult(result); } void IAsyncStateMachine.MoveNext() { //ILSpy generated this explicit interface implementation from .override directive in MoveNext this.MoveNext(); } [DebuggerHidden] private void SetStateMachine(IAsyncStateMachine stateMachine) { _t__builder.SetStateMachine(stateMachine); } void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine this.SetStateMachine(stateMachine); } } [AsyncStateMachine(typeof(_d__0))] private Task FuncAsync(CancellationToken token) { _d__0 stateMachine = default(_d__0); stateMachine._t__builder = AsyncTaskMethodBuilder.Create(); stateMachine._4__this = this; stateMachine.token = token; stateMachine._1__state = -1; stateMachine._t__builder.Start(ref stateMachine); return stateMachine._t__builder.Task; } } ```

Drawbacks

Unlike exceptions, direct cancelations cannot be caught. Therefore, existing types should use the existing functionality by default, and expose new APIs for performance.

More work for the compiler.

Alternatives

New keywords - undesirable and unlikely to get implemented for such a niche feature.

Awaiters have a GetException method and the state-machine checks the return value for null - Still requires allocating an exception object and brings up the question of how the async stack traces are preserved.

Unresolved questions

Should a new keyword be added to catch these direct cancelations? (I think no)

Design meetings

huoyaoyuan commented 3 years ago

await​ ​OtherFuncAsync​().CancelAsyncIfCanceled()

What if the caller forget to call CancelAsyncIfCanceled?

AartBluestoke commented 3 years ago

await​ ​OtherFuncAsync​().CancelAsyncIfCanceled()

What if the caller forget to call CancelAsyncIfCanceled?

That would just compile to 'await (Task)t', as compared to await​ ​OtherFuncAsync​().CancelAsyncIfCanceled() which would compile to 'await (CancelableTask)t'

from the OP: "Both the awaiter and the builder must have those methods in order for the compiler to emit the fast-tracked cancelation instructions"

The new async state machine would still have to set the Exception object to the relevant throwable, along with setting 'isCanceled' to fast-path if the parent supported it.

I would imagine that every step which doesn't have that check would be one the old behaviour of returning by thrown exception at each layer. The new code has to return a Task capable of being sensibly handled by the old state machine, as you can't guarantee the parent has been recompiled since the introduction of this feature.

The child cancels with the flag, but the handling of this would have to be entirely on the parent.

timcassell commented 3 years ago

Exactly right @AartBluestoke. If the awaiter doesn't implement IsCanceled, then its GetResult should throw an OperationCanceledException, and if the builder doesn't implement SetCanceled, the awaiter's GetResult should still throw an OperationCanceledException. Which is why in my extension example I had public void GetResult() => _token.ThrowIfCancellationRequested();.

bjbr-dev commented 3 years ago

I really like the idea of this, just curious what would happen to the stack trace? Is it something you should care about for a cancelled event? How can you distinguish between a cancellation due to a timeout (over the network) or a "user requested" cancellation due to clicking a button? I guess its important to know whether your caller cancelled or whether something you called cancelled.

I supposed you could do something like this:

var task = OtherFuncAsync().CancelAsyncIfCanceled(cancellationToken);
await task;
if (cancellationToken. IsCancellationRequested) {
   ... gracefully handle cancellation by caller
}
else  if (task.IsCancelled) {
   ... gracefully handle cancellation by dependency
}
else {
   ... we werent cancelled!
}

but that seems pretty verbose. Admittedly it's much more common for code to just be "pass through" and not care why it was cancelled, but this sort of code is needed at the "top level" and therefore worth keeping terse.

I wonder if the task should return a tuple, in order to stop us having to allocate the Task as a local variable and await it seperately:

var (task, isCancelled) = await OtherFuncAsync().CancelAsyncIfCanceled(cancellationToken);
if (isCancelled) {
} else {
}

Can this style of cancellation be mixed with OperationCanceledException?

I.e. User method, A, supports IsCancelled, and call method B Library method, B, does not support IsCancelled, and calls method C Library method C, supports IsCancelled and calls SetCancelled

Because B does not support it, it throws the exception. Would method A bubble the exception or could it "catch it" and turn it into an IsCancelled task?

If the async machine doesnt catch it, would a developer be expected to catch OperationCancelledException AND check for the IsCancelled property to deal with legacy / non legacy?

I'm wondering if it would be more user friendly to somehow invoke the catch clause with an OperationCancelledException but not actually throw it. Would that be possible / still fast?


Finally, I appreciate that naming things is hard but the names seem extremely verbose if we're supposed to use it everywhere! How about

await token.CancelIfRequested(); // Instead of CancelAsyncIfCancellationRequested
timcassell commented 3 years ago

I really like the idea of this, just curious what would happen to the stack trace? Is it something you should care about for a cancelled event?

Typically you wouldn't care about the stack trace for cancelations, and if you do care about it, you can just opt not to use this feature.

How can you distinguish between a cancellation due to a timeout (over the network) or a "user requested" cancellation due to clicking a button? I guess its important to know whether your caller cancelled or whether something you called cancelled.

That's already a concern with cancellations today. That needs to be handled at an individual level by the programmer.

[Edit] Also, it's bad practice to cancel an operation without the caller requesting it. The operation in that case should throw a TimeoutException rather than an OperationCancelledException.

I supposed you could do something like this:

var task = OtherFuncAsync().CancelAsyncIfCanceled(cancellationToken);
await task;
if (cancellationToken. IsCancellationRequested) {
   ... gracefully handle cancellation by caller
}
else  if (task.IsCancelled) {
   ... gracefully handle cancellation by dependency
}
else {
   ... we werent cancelled!
}

but that seems pretty verbose. Admittedly it's much more common for code to just be "pass through" and not care why it was cancelled, but this sort of code is needed at the "top level" and therefore worth keeping terse.

I wonder if the task should return a tuple, in order to stop us having to allocate the Task as a local variable and await it seperately:

var (task, isCancelled) = await OtherFuncAsync().CancelAsyncIfCanceled(cancellationToken);
if (isCancelled) {
} else {
}

You're getting into territory that is outside the scope of this feature request. Suppressing exceptions and returning a tuple like that is already possible today with custom awaiters (see how UniTask does exactly that).

Can this style of cancellation be mixed with OperationCanceledException?

I.e. User method, A, supports IsCancelled, and call method B Library method, B, does not support IsCancelled, and calls method C Library method C, supports IsCancelled and calls SetCancelled

Because B does not support it, it throws the exception. Would method A bubble the exception or could it "catch it" and turn it into an IsCancelled task?

Yes. One method would throw, then the next would take the fast path or vice-versa. The only difference is stack traces would be lost.

If the async machine doesnt catch it, would a developer be expected to catch OperationCancelledException AND check for the IsCancelled property to deal with legacy / non legacy?

The existing async method builders should work exactly as they do currently, simply adding a SetCancelled as an extra optimization. And because awaiters can optionally support IsCancelled, yes, builders should be able to handle both. So they can have SetCancelled and check for OperationCancelledException inside SetException as they do currently.

I'm wondering if it would be more user friendly to somehow invoke the catch clause with an OperationCancelledException but not actually throw it. Would that be possible / still fast?

See the previous dicussion for the answer to that. TLDR: yes it's possible, but not as desirable.

Finally, I appreciate that naming things is hard but the names seem extremely verbose if we're supposed to use it everywhere! How about

await token.CancelIfRequested(); // Instead of CancelAsyncIfCancellationRequested

I don't disagree about the verbosity, but CancelAsyncIfCancellationRequested was meant to mirror ThrowIfCancellationRequested and CancelAsync was to give an extremely clear intent of canceling the async method. It's propagating the cancelation up, not triggering a cancelation. CancelIfRequested is not clear in its intent to me.

erik-kallen commented 3 years ago

I think a new keyword is warranted for something whose purpose is to stop executing the method in the middle.

AartBluestoke commented 3 years ago

await token.ReturnCancled()?

ronnygunawan commented 3 years ago

I think a new keyword is warranted for something whose purpose is to stop executing the method in the middle.

public class M
{
    async Task<int> FuncAsync(CancellationToken token)
    {
        await Task.Yield();
        if (token.IsCancellationRequested) cancel;
        return 42;
    }
}
erik-kallen commented 3 years ago

@AartBluestoke That does not look like something that is expected to stop executing the method and return normally.

@ronnygunawan Yes, something like that!

ronnygunawan commented 3 years ago

I prefer Task.CurrentTask.Cancel() or Task.Cancel()

andreas-synnerdahl commented 2 years ago

We have the method: ConfigureAwait(Boolean) for

Why not add an override to this with an:

Then you could add options like existing continue on captured context and maybe a return value for cancelation:

public async Task<string> DoCurlAsync()
{
    using (var httpClient = new HttpClient())
    using (var httpResponse = await httpClient.GetAsync("https://www.bynder.com").ConfigureAwait(false))
    {
        return await httpResponse
            .Content
            .ReadAsStringAsync()
            .ConfigureAwait(o => o
                    .ContinueOnCapturedContext(true)
                    .CancelationReturnValue(string.Empty));
    }
}
CyrusNajmabadi commented 2 years ago

@andreas-synnerdahl that would be a request for dotnet/runtime. Thanks!

timcassell commented 2 years ago

@andreas-synnerdahl I thought of ways to resolve this in the async method builder, but it's not possible to do that and also support finally clauses (which is what using translates to), because the C# compiler creates the state machine that handles the finally clauses. This feature requires language compiler support to adjust the compiled state machine.

[Edit] Also, this should be supported for all custom async task-like types, not just Task and ValueTask.

timcassell commented 2 years ago

I was just wondering, should direct cancelations have the same or lower precedence as exceptions?

Consider:

async Task<int> FuncAsync()
{
    try
    {
        throw new InvalidOperationException();
    }
    finally
    {
        await new CancellationToken(true).CancelAsyncIfCancellationRequested();
    }
}

Would the InvalidOperationException be discarded and the task canceled as if an OperationCanceledException were thrown? Or would the cancelation be ignored and the task faulted?

I think it's fairly obvious that the other way around is an easy choice, the exception would discard the cancelation.

After writing this out and thinking about it some more, I think that cancelations and exceptions should have the same precedence for consistency's sake. If the builder doesn't implement the SetCanceled method, thus causing the awaiter to throw, that would discard the InvalidOperationException and cancel the task. So the direct cancelation should retain the same behavior.

sharwell commented 1 year ago

Related to dotnet/roslyn#65863

timcassell commented 1 year ago

Perhaps TResult GetResult(out bool isCanceled); void GetResult(out bool isCanceled); overload methods could be used, rather than bool IsCanceled property.

[Edit] Although, maybe not if https://github.com/dotnet/roslyn/issues/65863 will be added with TResult GetResult(out Exception? exception);, in which case we'd still need to use the property to not clash (unless we also use TResult GetResult(out Exception? exception, out bool isCanceled);, but that's probably overcomplicating it).

RenderMichael commented 1 year ago

I think a new keyword is warranted for something whose purpose is to stop executing the method in the middle.

Consider await break.

timcassell commented 1 year ago

I think a new keyword is warranted for something whose purpose is to stop executing the method in the middle.

Consider await break.

  • It uses existing keywords.
  • await makes it clear this is related to the async-ness of the method.
  • break is conceptually similar in loops and switch statements.
  • Cancelling a task like this is not to be taken lightly. Making this feature verbose with a two-part keyword makes that clearer.

At first I thought I liked that, but then I realized it does not convey its intention. The idea is to propagate cancelation efficiently, not trigger cancelation. When I see break, I don't think "stop execution if...", I just think "stop execution".

I proposed new keywords in the original discussion, and they were rightfully shot down. Considering this is purely an optimization, and the behavior is expected to be the same as throw new OperationCanceledException(), the compiler could opt to only generate this code if it's not inside a try-catch block. New keywords should not be necessary just for an optimization.

[Edit] Actually, there is a behavior change of losing the async stacktrace... Perhaps a more sensical keyword could be introduced that conveys the intention. awaitcancelable or something like that (I don't really have any good ideas here).

RenderMichael commented 1 year ago

I think a nice pattern would be await break when (token.IsCancellationRequested);. I know it's a bit verbose, but there are a bunch of upsides

Perhaps you could write await break; by itself for unconditional cancellation.

timcassell commented 1 year ago

@RenderMichael Sure, that works to trigger cancelation. But that doesn't do much to propagate cancelation, which is the main point of this proposal. That would be extremely verbose to await a task with throws disabled just to check its canceled state and break. And that won't even work with ValueTasks and other awaitables that can't have their state checked after they are awaited.