Runtime: Should / how should CancellationToken show up in IAsyncEnumerable-related interfaces?

Created on 8 Nov 2018  Â·  3Comments  Â·  Source: dotnet/runtime

Since the rest of the support is set, and since we've now merged these interfaces in support of .NET Core 3.0 previews, I'm opening a separate issue specifically to cover whether we should make any changes related to cancellation.

See https://github.com/dotnet/corefx/issues/32640#issuecomment-436316907.

api-approved area-System.Runtime

Most helpful comment

We discussed this again at length in C# language design, and came to the conclusion that we should add back the CancellationToken argument to GetAsyncEnumerator:
https://github.com/dotnet/csharplang/blob/master/meetings/2018/LDM-2018-11-28.md#async-iterators-and-await-foreach

That means we change this:
```C#
public interface IAsyncEnumerable
{
IAsyncEnumerator GetAsyncEnumerator();
}

to this:
```C#
public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

The language/compiler will do a minimal amount to support this:

  • await foreach will just use GetAsyncEnumerator(), relying on the default non-cancelable token. If a developer wants to pass a token in while enumerating, they can either skip the await foreach and just use GetAsyncEnumerator/MoveNextAsync/Current directly, or they can use a WithCancellation-ish extension method that we'll want to add, e.g. await foreach (var item in source.WithCancellation(token)) { … }. That extension will just return a struct that exposes the async enumerable pattern, but with the token baked into it.
  • For async iterators, the compiler will generate GetAsyncEnumerator to do cancellationToken.ThrowIfCancellationRequested(). Still needs discussion, but it may also generate such a call as part of each MoveNextAsync on the iterator.

This isn't a perfect solution, but it's the best of the options we're aware of, it enables composability and combinators and the like, and it leaves the door open for the compiler to do more in the future if it proves important. This leads to the following approach/guidance for developers:

  • If you're just writing internal async iterators for use in your own codebase, you can of course continue to pass CancellationTokens to your iterator methods and just use them as you would any other token, e.g.
    ```C#
    internal static async IAsyncEnumerable EnumerateStuff(CancellationToken cancellationToken)
    {
    await foreach (OtherStuff item in EnumerateOtherStuff(CancellationToken))
    {
    yield return GetStuff(item);
    }
    }
- However, that doesn't play nicely with anyone passing a token via the interface, so if you're exposing public APIs, the IAsyncEnumerable-returning method shouldn't take in the caller's CancellationToken, as it should instead be taken in via GetAsyncEnumerator.  The easiest way to write that is by writing the GetAsyncEnumerator method as an async iterator:
```C#
public static async IAsyncEnumerable<Stuff> EnumerateStuff() =>
    new EnumerateStuffIterator();

internal sealed class EnumerateStuffIterator : IAsyncEnumerable<Stuff>
{
    public async IAsyncEnumerator<Stuff> GetAsyncEnumerator(CancellationToken cancellationToken)
    {
        await foreach (OtherStuff item in EnumerateOtherStuff(cancellationToken))
        {
            yield return GetStuff(item);
        }
    }
}

While a small amount of additional boilerplate, this will then compose nicely with an ecosystem that provides the token to the interface call. One additional downside to this "easy" approach is it results in one more allocation that we could otherwise get away with, but our hope is that for an async iteration that one additional allocation should be nominal, and for critical things where it isn't, it's still possible to avoid it by writing the state machine manually.

I will open a separate issue for WithCancellation: we'll need to think through the interplay of that with ConfigureAwait.

cc: @onovotny, @jcouv, @terrajobst, @bartonjs

All 3 comments

We discussed this again at length in C# language design, and came to the conclusion that we should add back the CancellationToken argument to GetAsyncEnumerator:
https://github.com/dotnet/csharplang/blob/master/meetings/2018/LDM-2018-11-28.md#async-iterators-and-await-foreach

That means we change this:
```C#
public interface IAsyncEnumerable
{
IAsyncEnumerator GetAsyncEnumerator();
}

to this:
```C#
public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

The language/compiler will do a minimal amount to support this:

  • await foreach will just use GetAsyncEnumerator(), relying on the default non-cancelable token. If a developer wants to pass a token in while enumerating, they can either skip the await foreach and just use GetAsyncEnumerator/MoveNextAsync/Current directly, or they can use a WithCancellation-ish extension method that we'll want to add, e.g. await foreach (var item in source.WithCancellation(token)) { … }. That extension will just return a struct that exposes the async enumerable pattern, but with the token baked into it.
  • For async iterators, the compiler will generate GetAsyncEnumerator to do cancellationToken.ThrowIfCancellationRequested(). Still needs discussion, but it may also generate such a call as part of each MoveNextAsync on the iterator.

This isn't a perfect solution, but it's the best of the options we're aware of, it enables composability and combinators and the like, and it leaves the door open for the compiler to do more in the future if it proves important. This leads to the following approach/guidance for developers:

  • If you're just writing internal async iterators for use in your own codebase, you can of course continue to pass CancellationTokens to your iterator methods and just use them as you would any other token, e.g.
    ```C#
    internal static async IAsyncEnumerable EnumerateStuff(CancellationToken cancellationToken)
    {
    await foreach (OtherStuff item in EnumerateOtherStuff(CancellationToken))
    {
    yield return GetStuff(item);
    }
    }
- However, that doesn't play nicely with anyone passing a token via the interface, so if you're exposing public APIs, the IAsyncEnumerable-returning method shouldn't take in the caller's CancellationToken, as it should instead be taken in via GetAsyncEnumerator.  The easiest way to write that is by writing the GetAsyncEnumerator method as an async iterator:
```C#
public static async IAsyncEnumerable<Stuff> EnumerateStuff() =>
    new EnumerateStuffIterator();

internal sealed class EnumerateStuffIterator : IAsyncEnumerable<Stuff>
{
    public async IAsyncEnumerator<Stuff> GetAsyncEnumerator(CancellationToken cancellationToken)
    {
        await foreach (OtherStuff item in EnumerateOtherStuff(cancellationToken))
        {
            yield return GetStuff(item);
        }
    }
}

While a small amount of additional boilerplate, this will then compose nicely with an ecosystem that provides the token to the interface call. One additional downside to this "easy" approach is it results in one more allocation that we could otherwise get away with, but our hope is that for an async iteration that one additional allocation should be nominal, and for critical things where it isn't, it's still possible to avoid it by writing the state machine manually.

I will open a separate issue for WithCancellation: we'll need to think through the interplay of that with ConfigureAwait.

cc: @onovotny, @jcouv, @terrajobst, @bartonjs

@stephentoub Thank you for posting details. Could you make the last code sample more explicit regarding how the CancellationToken gets to the EnumerateStuffIterator.GetAsyncEnumerator()?

It should have been a parameter. Fixed.

Was this page helpful?
0 / 5 - 0 ratings