Cancel async functions without throw #4450
-
I originally posted an idea at dotnet/runtime#48482 but realized that wouldn't work because I didn't account for awaiting things within finally blocks. To summarize: Exceptions are slow and should not be used to control program flow. The aging Note: I don't have any experience with async iterators, so this is for regular async methods. I was hoping to be able to do this without adding any new keywords, but I think that will be necessary for this purpose. Currently the 2 keywords to control async flow are Consider this async method that is valid today: public async Task<int> Func(CancellationToken token)
{
await Task.Delay(10);
token.ThrowIfCancellationRequested(); // Cancel the task if the token is canceled.
return 100;
} We could adjust that to work without throwing like this: public async Task<int> Func(CancellationToken token)
{
await Task.Delay(10);
if (token.IsCancellationRequested)
{
cancelthrow; // Cancel the task if the token is canceled.
}
return 100;
} That's a little more verbose, but it would be able to cancel the task much more efficiently. We could write an extension method to do that inline: public async Task<int> Func(CancellationToken token)
{
await Task.Delay(10);
cancelawait token.GetCancelAwaitable(); // Cancel the task if the token is canceled.
return 100;
} And that extension looks like this: public static class CancellationTokenExtensions
{
public struct CancellationTokenCancelAwaitable : INotifyCompletion, IAsyncCanceler
{
private readonly CancellationToken _token;
public CancellationTokenCancelAwaitable(CancellationToken token)
{
_token = token;
}
public CancellationTokenCancelAwaitable GetAwaiter() => this;
public bool IsCompleted => true; // Never wait
public bool IsCanceled => _token.IsCancellationRequested; // Is the token canceled?
public void GetResult() { } // Do nothing if the token is not canceled.
public void OnCompleted(Action continuation) => throw new NotImplementedException(); // Don't need to implement since we never wait.
}
public static CancellationTokenCancelAwaitable GetCancelAwaitable(this CancellationToken token)
{
return new CancellationTokenCancelAwaitable(token);
}
} Notice how it utilizes the already existing duck-typing to create an awaitable type. This just took it a step further and added the public interface IAsyncCanceler
{
bool IsCanceled { get; }
}
if (awaiter.IsCompleted)
{
GetResult();
}
else
{
awaiter.OnCompleted(GetResult);
}
void GetResult()
{
awaiter.GetResult();
ContinueExecution();
}
if (awaiter.IsCompleted)
{
GetResultOrCancel();
}
else
{
awaiter.OnCompleted(GetResultOrCancel);
}
void GetResultOrCancel()
{
if (awaiter.IsCanceled)
{
CancelTask();
}
else
{
awaiter.GetResult();
ContinueExecution();
}
} So we could utilize the "awaitable" part to perform a more complex operation, such an waiting for another task to complete then canceling the current task without executing any more code after the public async Task<int> Func(CancellationToken token)
{
cancelawait WaitThenCancel().GetCancelAwaitable(); // Wait for the task to complete, then cancel this task if it's canceled.
return 100;
async Task WaitThenCancel()
{
await Task.Delay(10);
cancelthrow; // Cancel the task.
}
} I'll leave out the rest of the extension for that, but the public bool IsCompleted => _task.Status == TaskStatus.RanToCompletion | _task.Status == TaskStatus.Canceled | _task.Status == TaskStatus.Faulted;
public bool IsCanceled => _task.Status == TaskStatus.Canceled;
public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation()); The cancelation would be propagated to the task the same way public async Task<int> Func(CancellationToken token)
{
try
{
await Task.Delay(10);
cancelthrow; // Cancel the task.
return 100; // Unreachable code.
}
finally
{
await Task.Delay(10); // Delay the cancelation.
throw new Exception(); // Actually, we're going to fault the task instead of cancel.
}
} Now, what do the new public struct PromiseMethodBuilder<T>
{
private AsyncPromiseRef _promise;
public Promise<T> Task
{
get { return new Promise<T>(_promise, _promise.Id); }
}
public static PromiseMethodBuilder<T> Create()
{
return new PromiseMethodBuilder<T>()
{
_promise = AsyncPromiseRef.GetOrCreate()
};
}
public void SetException(Exception exception)
{
if (exception is OperationCanceledException)
{
_promise.SetCanceled(exception);
}
else
{
_promise.SetException(exception);
}
}
public void SetResult(T result)
{
_promise.SetResult(result);
}
public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine
{
awaiter.OnCompleted(_promise.GetContinuation(ref stateMachine));
}
[SecuritySafeCritical]
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
awaiter.UnsafeOnCompleted(_promise.GetContinuation(ref stateMachine));
}
public void Start<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IAsyncStateMachine
{
stateMachine.MoveNext();
}
public void SetStateMachine(IAsyncStateMachine stateMachine) { }
}
public void SetCancelation()
{
_promise.SetCanceled();
} For backwards compatibility, this method is optional, and as mentioned above, the And that's it... but wait! What if you want your custom task-like type to handle custom cancelations? With the current public void SetCancelation<TCanceler>(TCanceler canceler)
{
SetCancelation();
} How this works is, when an public async Promise<int> Func(CancellationToken token)
{
await Task.Delay(10);
if (token.IsCancellationRequested)
{
cancelthrow -1; // Cancel the promise with `-1` as the reason if the token is canceled.
}
return 100;
} This This particular syntax should be easy for the compiler to handle in most cases, but it can get more complicated if there are any |
Beta Was this translation helpful? Give feedback.
Replies: 6 comments 38 replies
-
It's not really clear to me that problem this is solving. |
Beta Was this translation helpful? Give feedback.
-
I think there is actually room for the compiler to optimize wihout doing language changes: Consider the following super simple async function: using System;
using System.Threading.Tasks;
public class C {
public async Task<int> M(bool b) {
await Task.Yield();
if (b)
throw new Exception();
return 42;
}
} This translates into a state machine that looks like this: using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
public class C
{
private struct Md__0 : IAsyncStateMachine
{
public int _1__state;
public AsyncTaskMethodBuilder<int> t__builder;
public bool b;
private YieldAwaitable.YieldAwaiter u__1;
public void MoveNext()
{
int num = _1__state;
int result;
try
{
YieldAwaitable.YieldAwaiter awaiter;
if (num != 0)
{
awaiter = Task.Yield().GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (_1__state = 0);
u__1 = awaiter;
t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
}
else
{
awaiter = u__1;
u__1 = default(YieldAwaitable.YieldAwaiter);
num = (_1__state = -1);
}
awaiter.GetResult();
if (b)
{
throw new Exception();
}
result = 42;
}
catch (Exception exception)
{
_1__state = -2;
t__builder.SetException(exception);
return;
}
_1__state = -2;
t__builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
t__builder.SetStateMachine(stateMachine);
}
}
[AsyncStateMachine(typeof(Md__0))]
public Task<int> M(bool b)
{
Md__0 stateMachine = default(Md__0);
stateMachine.t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.b = b;
stateMachine._1__state = -1;
stateMachine.t__builder.Start(ref stateMachine);
return stateMachine.t__builder.Task;
}
} We throw an exception, and then immediately catch it, move to a new state of the statemachine, and return. The compiler could spot that this will always happen, and instead generate the following equivalent code: using System;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
public class C
{
private struct Md__0 : IAsyncStateMachine
{
public int _1__state;
public AsyncTaskMethodBuilder<int> t__builder;
public bool b;
private YieldAwaitable.YieldAwaiter u__1;
public void MoveNext()
{
int num = _1__state;
int result;
try
{
YieldAwaitable.YieldAwaiter awaiter;
if (num != 0)
{
awaiter = Task.Yield().GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (_1__state = 0);
u__1 = awaiter;
t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
}
else
{
awaiter = u__1;
u__1 = default(YieldAwaitable.YieldAwaiter);
num = (_1__state = -1);
}
awaiter.GetResult();
if (b)
{
_1__state = -2;
t__builder.SetException(ExceptionDispatchInfo.SetCurrentStackTrace(new Exception()));
return;
}
result = 42;
}
catch (Exception exception)
{
_1__state = -2;
t__builder.SetException(exception);
return;
}
_1__state = -2;
t__builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
t__builder.SetStateMachine(stateMachine);
}
}
[AsyncStateMachine(typeof(Md__0))]
public Task<int> M(bool b)
{
Md__0 stateMachine = default(Md__0);
stateMachine.t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.b = b;
stateMachine._1__state = -1;
stateMachine.t__builder.Start(ref stateMachine);
return stateMachine.t__builder.Task;
}
} We could do even better if awaitables has a custom using System;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
public class C
{
private struct Md__0 : IAsyncStateMachine
{
public int _1__state;
public AsyncTaskMethodBuilder<int> t__builder;
public bool b;
private YieldAwaitable.YieldAwaiter u__1;
public void MoveNext()
{
int num = _1__state;
int result;
try
{
YieldAwaitable.YieldAwaiter awaiter;
if (num != 0)
{
awaiter = Task.Yield().GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (_1__state = 0);
u__1 = awaiter;
t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
}
else
{
awaiter = u__1;
u__1 = default(YieldAwaitable.YieldAwaiter);
num = (_1__state = -1);
}
if (awaiter.Exception is {} exception)
{
_1__state = -2;
t__builder.SetException(exception);
return;
}
awaiter.GetResult();
if (b)
{
_1__state = -2;
t__builder.SetException(ExceptionDispatchInfo.SetCurrentStackTrace(new Exception()));
return;
}
result = 42;
}
catch (Exception exception)
{
_1__state = -2;
t__builder.SetException(exception);
return;
}
_1__state = -2;
t__builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
t__builder.SetStateMachine(stateMachine);
}
}
[AsyncStateMachine(typeof(Md__0))]
public Task<int> M(bool b)
{
Md__0 stateMachine = default(Md__0);
stateMachine.t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.b = b;
stateMachine._1__state = -1;
stateMachine.t__builder.Start(ref stateMachine);
return stateMachine.t__builder.Task;
}
} This would avoid rethrowing and recatching an exception at every level of an async stack trace every time it's awaited. However I don't believe any language changes are needed here, only compiler changes, plus libraries would have to start adding the |
Beta Was this translation helpful? Give feedback.
-
I agree that this is an obvious source of pain that needs to be resolved by the language and/or runtime. Failure to handle the cancellation case efficiently is the single most prevalent pain point in attempting to debug our own project (Roslyn.sln), to the point that I really haven't even tried to debug real instances in months (maybe years). Under the current language limitations, I handle the cancellation case by using PerfView to identify the primary locations where exceptionally large numbers of exceptions get thrown. Often these are cases where a single high-level work item branches out to many smaller work items. Relocating the cancellation exception from all the small work items to the shared high-level work item reduces the number of cases where exceptions are thrown. An example can be seen in dotnet/roslyn#19652. Two changes are needed to resolve this properly:
Without a proposal to fix the second item, the most straightforward resolution is to disallow the use of |
Beta Was this translation helpful? Give feedback.
-
I would not be opposed to a couple of targeted changes to the compiler/API/runtime (not language). Specifically:
This would allow |
Beta Was this translation helpful? Give feedback.
-
What if we combine @YairHalberstadt and @CyrusNajmabadi 's ideas? Given this C# code public class C
{
public async Task<int> M()
{
try
{
await Task.Yield();
}
catch (InvalidOperationException)
{
Console.WriteLine("Invalid");
}
catch (ArgumentException e)
{
Console.WriteLine(e.ToString());
}
return 42;
}
} Produces this equivalent state machine public class C
{
private struct Md__0 : IAsyncStateMachine
{
public int _1__state;
public AsyncTaskMethodBuilder<int> t__builder;
private YieldAwaitable.YieldAwaiter u__1;
public void MoveNext()
{
int num = _1__state;
int result;
try
{
try
{
YieldAwaitable.YieldAwaiter awaiter;
if (num != 0)
{
awaiter = Task.Yield().GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (_1__state = 0);
u__1 = awaiter;
t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
}
else
{
awaiter = u__1;
u__1 = default(YieldAwaitable.YieldAwaiter);
num = (_1__state = -1);
}
awaiter.GetResult();
}
catch (InvalidOperationException)
{
Console.WriteLine("Invalid");
}
catch (ArgumentException ex2)
{
Console.WriteLine(ex2.ToString());
}
result = 42;
}
catch (Exception exception)
{
_1__state = -2;
t__builder.SetException(exception);
return;
}
_1__state = -2;
t__builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
t__builder.SetStateMachine(stateMachine);
}
}
[AsyncStateMachine(typeof(Md__0))]
public Task<int> M()
{
Md__0 stateMachine = default(Md__0);
stateMachine.t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine._1__state = -1;
stateMachine.t__builder.Start(ref stateMachine);
return stateMachine.t__builder.Task;
}
} We can rejigger it to this public class C
{
private struct Md__0 : IAsyncStateMachine
{
public int _1__state;
public AsyncTaskMethodBuilder<int> t__builder;
private YieldAwaitable.YieldAwaiter u__1;
public void MoveNext()
{
int num = _1__state;
int result;
try
{
Exception ex = null;
try
{
YieldAwaitable.YieldAwaiter awaiter;
if (num != 0)
{
awaiter = Task.Yield().GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (_1__state = 0);
u__1 = awaiter;
t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
}
else
{
awaiter = u__1;
u__1 = default(YieldAwaitable.YieldAwaiter);
num = (_1__state = -1);
}
ex = awaiter.Exception;
if (ex == null)
{
awaiter.GetResult();
}
}
catch (Exception e)
{
ex = e;
}
if (ex != null)
{
if (ex is InvalidOperationException)
{
Console.WriteLine("Invalid");
}
else if (ex is ArgumentException ex2)
{
Console.WriteLine(ex2.ToString());
}
else
{
_1__state = -2;
t__builder.SetException(ex);
return;
}
}
result = 42;
}
catch (Exception exception)
{
_1__state = -2;
t__builder.SetException(exception);
return;
}
_1__state = -2;
t__builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
t__builder.SetStateMachine(stateMachine);
}
}
[AsyncStateMachine(typeof(Md__0))]
public Task<int> M()
{
Md__0 stateMachine = default(Md__0);
stateMachine.t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine._1__state = -1;
stateMachine.t__builder.Start(ref stateMachine);
return stateMachine.t__builder.Task;
}
} As per @CyrusNajmabadi 's idea, the compiler would check for try/catch blocks and transform them into |
Beta Was this translation helpful? Give feedback.
I think there is actually room for the compiler to optimize wihout doing language changes:
Consider the following super simple async function:
This translates into a state machine that looks like this: