dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.38k stars 4.75k forks source link

Proposal: Add Task.WhenAny overloads with CancellationToken #37505

Open pentp opened 4 years ago

pentp commented 4 years ago

Background and Motivation

It's a relatively common pattern to await on tasks which don't support cancellation (yet). One workaround is to pass new Task(() => {}, cancellationToken) as one of the tasks to Task.WhenAny, but I've also seen a variant where the token registration sets a TCS result and the TCS.Task is passed to WhenAny and even more complicated variants, but all of them are pretty inefficient and make the code unwieldy. There are somewhat similar WaitAny overloads for sync-over-async code already.

Proposed API

namespace System.Threading.Tasks {
public class Task {
  // omitted "public static" for clarity

  Task<Task> WhenAny(IEnumerable<Task> tasks);
+ Task<Task?> WhenAny(IEnumerable<Task> tasks, CancellationToken cancellationToken);
  Task<Task> WhenAny(Task task1, Task task2);
+ Task<Task?> WhenAny(Task task1, Task task2, CancellationToken cancellationToken);
  Task<Task> WhenAny(params Task[] tasks);
+ Task<Task?> WhenAny(params Task[] tasks, CancellationToken cancellationToken);

  Task<Task<TResult>> WhenAny<TResult>(IEnumerable<Task<TResult>> tasks);
+ Task<Task<TResult>?> WhenAny<TResult>(IEnumerable<Task<TResult>> tasks, CancellationToken cancellationToken);
  Task<Task<TResult>> WhenAny<TResult>(Task<TResult> task1, Task<TResult> task2);
+ Task<Task<TResult>?> WhenAny<TResult>(Task<TResult> task1, Task<TResult> task2, CancellationToken cancellationToken);
  Task<Task<TResult>> WhenAny<TResult>(params Task<TResult>[] tasks);
+ Task<Task<TResult>?> WhenAny<TResult>(Task<TResult>[] tasks, CancellationToken cancellationToken);

// single task variants that don't have a counterpart without the CT parameter
+ Task<Task?> WhenAny(Task task, CancellationToken cancellationToken);
+ Task<Task<TResult>?> WhenAny<TResult>(Task<TResult> task, CancellationToken cancellationToken);
}}

Usage Examples

Task<int> TryGetData(CancellationToken ct)
{
    Task<int> task1 = service.GetData();
    Task<int> task2 = cache.GetData();

    return Task.WhenAny(task1, task2, ct).Unwrap();
}

Alternative Designs

Instead of setting a null result when canceled, could instead mark the WhenAny task as canceled, but this is inefficient since it throws cancellation exceptions in the common case where WhenAny is directly awaited. Also this has no benefits when Unwrap is used as it behaves almost the same for null task results and cancelled tasks (the only difference is cancellationToken value in the task state).

Alternative #27722: The same effect could be achieved with a general purpose WithCancellation API on tasks, which would support the previous example like this: return Task.WhenAny(task1.WithCancellation(ct), task2.WithCancellation(ct)).Unwrap(); This could be more efficient actually, especially when the token isn't cancellable or the result is synchronously available already.

Risks

Aside from the API count increase, I see the main risk that if new CancellationTokenSource instances are created and used with these APIs, it would be quite easy to end up not disposing them properly (or disposing them too early). To avoid such pitfalls for the most common cases, a general purpose WithTimeout(int/TimeSpan) API on Task would be very useful, but it would cover only timeouts instead of all cancellations...

ghost commented 4 years ago

Tagging subscribers to this area: @tarekgh Notify danmosemsft if you want to be subscribed.

stephentoub commented 4 years ago

Dup of https://github.com/dotnet/runtime/issues/27722?

pentp commented 4 years ago

Not entirely, it's a separate proposal specifically for WhenAny

GSPP commented 4 years ago

Came here to report this. My use case: I am repeatedly subscribing to a bunch of operations that represent events. Most of these subscriptions are unsubscribed fairly quickly.

If the WhenAny task cannot be canceled then continuations will keep piling up indefinitely. That's why I'd like to pass in a token to cancel the WhenAny task and drop the continuations.

pentp commented 4 years ago

Updated proposal with some minor fixes and switched to null Task result instead of canceled Tasks (moved to alternatives section).

jnm2 commented 4 years ago

Null task results could lead people to await directly without checking for null in codebases that aren't on C# 8 with NRTs enabled. Given the signature, I'd have expected a canceled outer task rather than a succeeded task with a result of null.