Skip to content

[Proposal]: Cancel Async Functions Without Throw #4565

@timcassell

Description

@timcassell

Cancel Async Functions Without Throw

  • Proposed
  • Prototype: Not Started
  • Implementation: Not Started
  • Specification: Not Started

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:
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#:
public class M
{
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct _d__0 : IAsyncStateMachine
    {
        public int _1__state;

        public AsyncTaskMethodBuilder<int> _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<int> FuncAsync(CancellationToken token)
    {
        _d__0 stateMachine = default(_d__0);
        stateMachine._t__builder = AsyncTaskMethodBuilder<int>.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#:
public class M
{
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct _d__0 : IAsyncStateMachine
    {
        public int _1__state;

        public AsyncTaskMethodBuilder<int> _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<int> FuncAsync(CancellationToken token)
    {
        _d__0 stateMachine = default(_d__0);
        stateMachine._t__builder = AsyncTaskMethodBuilder<int>.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions