Skip to content

Proposal: IAsyncEnumerable<T>.WithCancellation extension method #28105

@stephentoub

Description

@stephentoub

Background

Per recent design revisions, IAsyncEnumerable<T>.GetAsyncEnumerator will now accept a CancellationToken. To get a cancellation token into the GetAsyncEnumerator call, code can use GetAsyncEnumerator explicitly, e.g.

IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator(cancellationToken);
try
{
     while (await enumerator.MoveNextAsync())
     {
         T current = enumerator.Current;}
}
finally
{
    await enumerator.DisposeAsync();
}

To use await foreach, as there’s no language-provided facility for passing a CancellationToken into the compiler-generated GetAsyncEnumerator call, to get a CancellationToken in we need to take advantage of the pattern-matching support await foreach supplies. By returning a struct that exposes the right pattern, we can smuggle a CancellationToken in, stored in that struct, such that when the compiler-generated code invokes that struct’s GetAsyncEnumerator(), it in turn calls GetAsyncEnumerator(_cancellationToken) on the underlying enumerable.

To make that simple, we should add a WithCancellation extension method on IAsyncEnumerable<T> that accepts the CancellationToken, allowing you to write:

await foreach (T item in enumerable.WithCancellation(cancellationToken))
{}

with that generating the equivalent of:

var enumerator = enumerable.WithCancellation(cancellationToken).GetAsyncEnumerator();
try
{
     while (await enumerator.MoveNextAsync())
     {
         T current = enumerator.Current;}
}
finally
{
    await enumerator.DisposeAsync();
}

However, we also need to deal with the ability to ConfigureAwait(false) an enumerable. We’ve already added the ConfigureAwait(bool) extension method for IAsyncEnumerable<T>, and we need to be able to allow developers to use both it and WithCancellation. Since both of these would return structs that by design do not implement IAsyncEnumerable<T>, we need another mechanism to allow chaining them, e.g.

await foreach (T item in enumerable.WithCancellation(cancellationToken).ConfigureAwait(false))
{}

Proposal

As part of adding the async-related interfaces, we also added a ConfigureAwait extension method and a new ConfiguredAsyncEnumerable struct type. We replace those with a slightly updated version:

-namespace System.Threading.Tasks
-{
-    public static class TaskExtensions // previously existing class
-    {
-        public static ConfiguredAsyncEnumerable<T> ConfigureAwait<T>(this IAsyncEnumerable<T> source, bool continueOnCapturedContext);
-    }
-}
-
-namespace System.Runtime.CompilerServices
-{
-    [StructLayout(LayoutKind.Auto)]
-    public readonly struct ConfiguredAsyncEnumerable<T>
-    {
-        public Enumerator GetAsyncEnumerator(CancellationToken cancellationToken = default);
-        public readonly struct Enumerator
-        {
-            public ConfiguredValueTaskAwaitable<bool> MoveNextAsync();
-            public T Current { get; }
-            public ConfiguredValueTaskAwaitable DisposeAsync();
-        }
-    }
-}
+namespace System.Threading.Tasks
+{
+  public static class TaskExtensions // previously existing class
+    {
+        public static ConfiguredCancelableAsyncEnumerable<T> WithCancellation<T>(this IAsyncEnumerable<T> source, CancellationToken cancellationToken);
+        public static ConfiguredCancelableAsyncEnumerable<T> ConfigureAwait<T>(this IAsyncEnumerable<T> source, bool continueOnCapturedContext);
+    }
+}
+
+namespace System.Runtime.CompilerServices
+{
+   [StructLayout(LayoutKind.Auto)]
+   public readonly struct ConfiguredCancelableAsyncEnumerable<T>
+    {
+        public ConfiguredCancelableAsyncEnumerable<T> WithCancellation(CancellationToken cancellationToken);
+        public ConfiguredCancelableAsyncEnumerable<T> ConfigureAwait(bool continueOnCapturedContext);
+
+        public Enumerator GetAsyncEnumerator();
+        public readonly struct Enumerator
+        {
+            public ConfiguredValueTaskAwaitable<bool> MoveNextAsync();
+            public T Current { get; }
+            public ConfiguredValueTaskAwaitable DisposeAsync();
+        }
+    }
+}

This let’s you write:

await foreach (T item in source) {}
await foreach (T item in source.ConfigureAwait(false)) {}
await foreach (T item in source.WithCancellation(token)) {}
await foreach (T item in source.ConfigureAwait(false).WithCancellation(token)) {}
await foreach (T item in source.WithCancellation(token).ConfigureAwait(false)) {}

It of course also lets you write something a bit non-sensical, like:

await foreach (T item in source.ConfigureAwait(false).ConfigureAwait(true).WithCancellation(token1).WithCancellation(token2)) {}

and in such a case we would just use the last value specified, e.g. this would end up being equivalent to:

await foreach (T item in source.ConfigureAwait(true).WithCancellation(token2)) {}

Note that the GetAsyncEnumerator() on the struct is defined to be parameterless (this assumes the compiler's pattern-matching support allows that... if not, it'll be defined to take a token as well). Since this struct is only ever meant to be used via the provided extension methods with the await foreach construct, there’s little use for also accepting a CancellationToken into GetAsyncEnumerator(), and allowing that would just be confusing, as that argument will be ignored by the implementation.

cc: @jcouv, @MadsTorgersen, @terrajobst, @bartonjs

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions