Skip to content

Proposal: Public APIs for C# 8 async streams #27547

@stephentoub

Description

@stephentoub

We're at a point where we should start exposing the necessary library support for C# 8 async streams, aka async enumerables, aka async iterators. While we may find ourselves wanting to make further changes to these APIs, we've locked on what we plan to deliver initially, and we can adapt as necessary based on feedback.

namespace System
{
    public interface IAsyncDisposable
    {
        ValueTask DisposeAsync();
    }
}

namespace System.Collections.Generic
{
    public interface IAsyncEnumerable<out T>
    {
        IAsyncEnumerator<T> GetAsyncEnumerator();
    }

    public interface IAsyncEnumerator<out T> : IAsyncDisposable
    {
        ValueTask<bool> MoveNextAsync();
        T Current { get; }
    }
}

namespace System.Runtime.CompilerServices
{
    public struct AsyncIteratorMethodBuilder
    {
        public static AsyncIteratorMethodBuilder Create();

        public void MoveNext<TStateMachine>(ref TStateMachine stateMachine)
            where TStateMachine : IAsyncStateMachine;

        public void AwaitOnCompleted<TAwaiter, TStateMachine>(
            ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : INotifyCompletion
            where TStateMachine : IAsyncStateMachine;

        public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
            ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : ICriticalNotifyCompletion
            where TStateMachine : IAsyncStateMachine;

        public void Complete();
    }
}

Assembly:
We need to decide where we put these. My expectation is .NET Core we'd have all of this in System.Private.CoreLib, but we could also put it in System.Threading.Tasks.Extensions a new assembly. If we want to OOB this, it could be in both, with the .NET Core S.T.T.Extensions the new assembly just type forwarding to S.P.CoreLib as happens today for ValueTask.

A few notes:

  • Alternative WaitForNextAsync+TryGetNext current design. We spent a lot of time exploring this attractive alternative. It has perf advantages, but also non-trivial additional complexity when using the APIs directly rather than through compiler support (e.g. foreach await). We also have a design where we could light-up with the alternative API should we find that the perf benefits are desirable enough to introduce a second interface. As such, we're going with the simpler, more familiar, and easier to work with MoveNextAsync+Current design.
  • Thread safety. While these APIs are for asynchrony, that doesn't imply an instance of an enumerator can be used concurrently. Async enumerators are explicitly not thread safe, in terms of there being no way to correctly used MoveNextAsync+Current in an atomic fashion (without higher-level locking that provided the thread safety), but also in terms of accessing MoveNextAsync again before having consumed a previous calls ValueTask; doing so is erroneous and has undefined behavior. So, too, is accessing DisposeAsync after having called MoveNextAsync but without having consumed its ValueTask.
  • ValueTask instead of Task. The original design called for MoveNextAsync to return Task<bool> and DisposeAsync to return Task. That works well when MoveNextAsync and DisposeAsync complete synchronously, but when they complete asynchronously, it requires allocation of another object to represent the eventual completion of the async operation. By instead returning ValueTask<bool> and ValueTask, an implementation can choose to implement IValueTaskSource and reuse the same object repeatedly for one call after another; in this fashion, for example, the compiler-generated type that's returned from an iterator can serve as the enumerable, as the enumerator, and as the promise for every asynchronously-completing MoveNextAsync and DisposeAsync call made on that enumerator, such that the whole async enumerable mechanism can incur overhead of a single allocation.
  • Cancellation. Cancellation is provided external to the interfaces. If you want operations on the enumerable to be cancelable, you need to pass a CancellationToken to the thing creating the enumerable, such that the token can be embedded into the enumerable and used in its operation. You can of course choose to cancel awaits by awaiting something that itself represents both the MoveNextAsync ValueTask<bool> and a CancellationToken, but that would only cancel the await, not the underlying operation being awaited. As for IAsyncDisposable, while in theory it makes sense that anything async can be canceled, disposal is about cleanup, closing things out, freeing resources, etc., which is generally not something that should be canceled; cleanup is still important for work that's canceled. The same CancellationToken that caused the actual work to be canceled would typically be the same token passed to DisposeAsync, making DisposeAsync worthless because cancellation of the work would cause DisposeAsync to be a nop. If someone wants to avoid being blocked waiting for disposal, they can avoid waiting on the resulting ValueTask, or wait on it only for some period of time.
  • Naming. Async is used in the various method names (e.g. DisposeAsync) even though the type also includes Async so that a type might implement both the synchronous and asynchronous counterparts and easily differentiate them.
  • AsyncIteratorMethodBuilder. The compiler could get away with using the existing AsyncTaskMethodBuilder or AsyncVoidMethodBuilder types, but these both have negative impact that can be avoided by using a new, specially-designed type. AsyncTaskMethodBuilder allocates a Task to represent the async method, but that Task goes unused in an iterator. AsyncVoidMethodBuilder interacts with SynchronizationContext, because async void methods need to do so (e.g. calling OperationStarted and OperationCompleted on the current SynchronizationContext if there is one). And some of the methods are poorly named, e.g. Start makes sense when talking about starting an async method, but not when talking about iterating with an iterator. As such, we introduce a new type tailored to async iterators. Create just returns a builder that the compiler can use, which is likely just default(AsyncIteratorMethodBuilder), but the method might also do additional optional work, like tracing. MoveNext pushes the state machine forward, effectively just calling stateMachine.MoveNext(), but doing so with the appropriate handling of ExecutionContext. AwaitOnCompleted and AwaitUnsafeOnCompleted are exactly what they are on the existing builders. And Complete just serves to notify the builder that the iterator has finished iterating: technically this isn't necessary, and it may just be a nop, but it gives us a hook to be able to do things like tracing/logging should we choose to do so. (We could decide not to include this.)
  • Implementing sync and async interfaces. It's fine for a single type to implement both the sync and async counterparts, e.g. IEnumerable<T> and IAsyncEnumerable<T>. When consuming manually, the developer can easily distinguish which is being used based on naming (e.g. GetEnumerator vs GetAsyncEnumerator), and when consuming via the compiler, the compiler will provide syntax for differentiation (e.g. foreach vs foreach await).

What else will we want?
I've opened several additional issues to cover related support we'll want to consider:

  • Extensions for ConfigureAwait (https://github.com/dotnet/corefx/issues/32684). The compiler will support pattern-based foreach await in addition to binding to the interface, and we can use that to enable the implicit awaits on MoveNextAsync and DisposeAsync to be done using ConfigureAwait(false) by having an extension method like public static ConfiguredAsyncEnumerable<T> ConfigureAwait<T>(this IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext), where the returned ConfiguredAsyncEnumerable<T> will propagate that through to a ConfiguredAsyncEnumerator<T>, and its MoveNextAsync and DisposeAsync will return ConfiguredValueTaskAwaitables.
  • ManualResetValueTaskSource (https://github.com/dotnet/corefx/issues/32664). If MoveNextAsync and DisposeAsync were defined to return Tasks, then the compiler could use TaskCompletionSource<T> to implement those async operations. But as it's returning ValueTask, and as we're doing so to enable object reuse, the compiler will be using its own implementation of IValueTaskSource. To greatly simplify that and to encapsulate all of the relevant logic, we should productize the ManualResetValueTaskSource/ManualResetValueTaskSourceLogic helper type from https://github.com/dotnet/corefx/blob/master/src/Common/tests/System/Threading/Tasks/Sources/ManualResetValueTaskSource.cs.
  • IAsyncDisposable implementations (https://github.com/dotnet/corefx/issues/32665). With IAsyncDisposable exposed, we'll want to implement on a variety of types in coreclr/corefx where the type could benefit from having an asynchronous disposable capability in addition to an existing synchronous ability.

cc: @jcouv, @MadsTorgersen, @jaredpar, @terrajobst, @tarekgh, @kouvel

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions