Поделиться через


Асинхронные потоки

Заметка

Эта статья является спецификацией компонентов. Спецификация служит проектным документом для функции. Это включает предлагаемые изменения спецификации, а также информацию, необходимую во время проектирования и разработки функции. Эти статьи публикуются до тех пор, пока предложенные изменения спецификации не будут завершены и включены в текущую спецификацию ECMA.

Может возникнуть некоторое несоответствие между спецификацией компонентов и завершенной реализацией. Эти различия фиксируются в соответствующих собраниях по проектированию языка (LDM).

Дополнительные сведения о процессе внедрения спецификаций функций в стандарт языка C# см. в статье о спецификациях .

Сводка

C# поддерживает методы итератора и асинхронные методы, но не поддерживает метод, который является итератором и асинхронным методом. Мы должны исправить это, позволив await использоваться в новой форме итератора async, которая вместо IEnumerable<T> или IEnumerator<T>возвращает IAsyncEnumerable<T> или IAsyncEnumerator<T>, с возможностью потребления IAsyncEnumerable<T> в новом await foreach. Интерфейс IAsyncDisposable также используется для асинхронной очистки.

Подробный дизайн

Интерфейсы

IAsyncDisposable

Было много обсуждений о IAsyncDisposable (например, https://github.com/dotnet/roslyn/issues/114) и действительно ли это хорошая идея. Однако это необходимая концепция для поддержки асинхронных итераторов. Так как блоки finally могут содержать awaitи так как блоки finally должны выполняться как часть удаления итераторов, нам необходимо асинхронное удаление. Это также полезно в случаях, когда очистка ресурсов может занимать некоторое время, например, при закрытии файлов (когда требуется сброс данных), отмене регистрации обратных вызовов и предоставлении способа узнать, когда отмена регистрации завершена, и т. д.

Следующий интерфейс добавляется в основные библиотеки .NET (например, System.Private.CoreLib / System.Runtime):

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

Как и в случае с Dispose, вызов DisposeAsync несколько раз является допустимым, а последующие вызовы после первого следует игнорировать, возвращая синхронно уже успешно завершенную задачу (DisposeAsync не должен быть потокобезопасным, и не требуется поддерживать параллельный вызов). Кроме того, типы могут реализовать как IDisposable, так и IAsyncDisposable, и если они делают, то это аналогично допустимо для вызова Dispose, а затем DisposeAsync или наоборот, но только первое должно быть значимым и последующим вызовами любого из них должно быть nop. Таким образом, если тип реализует оба метода, пользователям рекомендуется вызывать более релевантный метод один раз и только один раз на основе контекста: Dispose в синхронных контекстах и DisposeAsync в асинхронных.

(Как IAsyncDisposable взаимодействует с using является отдельным обсуждением. И освещение того, как он взаимодействует с foreach обрабатывается позже в этом предложении.)

Альтернативные варианты рассматриваются:

  • DisposeAsync принятия CancellationToken: в теории, хотя имеет смысл, что что-либо асинхронное может быть отменено, удаление заключается в очистке, завершении работы, освобождении ресурсов и т. д., что, как правило, не должно быть отменено. Очистка по-прежнему важна даже для отменённой работы. То же CancellationToken, которое привело к отмене фактической работы, обычно является тем же токеном, переданным в DisposeAsync, что делает DisposeAsync бесполезным, так как отмена работы приведет к тому, что DisposeAsync станет no-op. Если кто-то хочет избежать блокировки из-за ожидания разблокировки, они могут избегать блокировки при ожидании получения результата ValueTaskили ждать его только некоторое время.
  • DisposeAsync возврат Task: теперь, когда существует негенерический ValueTask и его можно создать из IValueTaskSource, возврат ValueTask из DisposeAsync позволяет повторно использовать существующий объект в качестве обещания, представляющего собой окончательное асинхронное завершение DisposeAsync, что экономит выделение Task в случае, если DisposeAsync завершается асинхронно.
  • Настройка DisposeAsync с помощью bool continueOnCapturedContext (ConfigureAwait). Хотя могут возникнуть проблемы, связанные с тем, как такая концепция предоставляется для using, foreachи других языковых конструкций, которые используют это, с точки зрения интерфейса, это фактически не выполняет никаких действий awaitи нечего настраивать... потребители ValueTask могут использовать его, как им угодно.
  • IAsyncDisposable наследование IDisposable: так как следует использовать только один или другой, нет смысла заставлять типы реализовывать оба.
  • IDisposableAsync вместо IAsyncDisposable: мы следовали принципу, что вещи и типы являются «асинхронными», в то время как операции выполняются асинхронно; поэтому у типов "Async" является префиксом, а у методов "Async" является суффиксом.

IAsyncEnumerable / IAsyncEnumerator

В основные библиотеки .NET добавляются два интерфейса:

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

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

Обычное потребление (без дополнительных языковых функций) будет выглядеть следующим образом:

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

Отброшенные варианты включают в себя:

  • Task<bool> MoveNextAsync(); T current { get; }. Использование Task<bool> позволило бы использовать кэшированный объект задачи для представления синхронных, успешных вызовов MoveNextAsync, однако для асинхронного завершения все равно потребуется выделение. Возвращая ValueTask<bool>, мы разрешаем объект перечислителя реализовать IValueTaskSource<bool> и использовать в качестве резервной копии ValueTask<bool>, возвращаемой из MoveNextAsync, что, в свою очередь, позволяет значительно сократить затраты.
  • ValueTask<(bool, T)> MoveNextAsync();: это не только сложнее для восприятия, но и означает, что T больше не может быть ковариантной.
  • ValueTask<T?> TryMoveNextAsync();: не ковариант.
  • Task<T?> TryMoveNextAsync();: не ковариантный, выделения по каждому вызову и т. д.
  • ITask<T?> TryMoveNextAsync();: не ковариант, выделения по каждому вызову и т. д.
  • ITask<(bool,T)> TryMoveNextAsync();: не ковариант, выделения по каждому вызову и т. д.
  • Task<bool> TryMoveNextAsync(out T result);. Результат out должен быть установлен, когда операция выполняется синхронно, а не когда она асинхронно завершает задачу, что может произойти через какое-то время в будущем, когда уже не будет возможности передать результат.
  • IAsyncEnumerator<T> не реализуйте IAsyncDisposable: мы могли бы выбрать разделение этих вариантов. Однако это усложняет некоторые другие области предложения, так как код должен учитывать возможность того, что перечислитель не предоставляет освобождение ресурсов, что затрудняет создание вспомогательных функций на основе шаблонов. Кроме того, обычно требуется удаление для перечислителей (например, любой асинхронный итератор C#, имеющий блок finally, большинство объектов, перебирающих данные из сетевого подключения и т. д.), и если в этом нет необходимости, метод можно легко реализовать как public ValueTask DisposeAsync() => default(ValueTask); с минимальными дополнительными затратами.
  • _ IAsyncEnumerator<T> GetAsyncEnumerator(): нет параметра маркера отмены.

В следующем подразделе рассматриваются альтернативные варианты, которые не были выбраны.

Жизнеспособная альтернатива:

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

    public interface IAsyncEnumerator<out T> : IAsyncDisposable
    {
        ValueTask<bool> WaitForNextAsync();
        T TryGetNext(out bool success);
    }
}

TryGetNext используется во внутреннем цикле для потребления элементов с помощью одного вызова интерфейса, пока они доступны синхронно. Если следующий элемент не может быть получен синхронно, возвращается значение false, и каждый раз, когда возвращается значение false, вызывающая сторона должна впоследствии вызвать WaitForNextAsync для ожидания следующего элемента или для определения, что он больше не появится. Обычное потребление (без дополнительных языковых функций) будет выглядеть следующим образом:

IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
    while (await enumerator.WaitForNextAsync())
    {
        while (true)
        {
            int item = enumerator.TryGetNext(out bool success);
            if (!success) break;
            Use(item);
        }
    }
}
finally { await enumerator.DisposeAsync(); }

Преимущество этого является двойное: одно незначительное и одно значительное.

  • незначительный: позволяет перечислителю поддерживать несколько потребителей. Могут возникнуть сценарии, в которых перечислитель может поддерживать несколько потребителей одновременно. Это невозможно сделать, если MoveNextAsync и Current разделены таким образом, что реализация не может сделать их использование атомарным. Напротив, этот подход предоставляет один метод TryGetNext, который поддерживает отправку перечислителя вперед и получение следующего элемента, поэтому перечислитель может включить атомарность при необходимости. Однако, скорее всего, такие сценарии также можно реализовать, предоставив каждому потребителю собственный перечислитель из общего перечислимого. Более того, мы не хотим обязывать, чтобы каждый перечислитель поддерживал параллельное использование, так как это добавило бы нетривиальные издержки в большинстве случаев, которые в этом не нуждаются, что означает, что потребитель интерфейса, как правило, не могли бы на это положиться.
  • Основной:Производительность. Подход MoveNextAsync/Current требует двух вызовов интерфейса для каждой операции, тогда как в лучшем случае для WaitForNextAsync/TryGetNext большинство итераций выполняются синхронно, что позволяет плотному внутреннему циклу с TryGetNext, так что у нас будет только один вызов интерфейса на каждую операцию. Это может иметь измеримое влияние в ситуациях, когда вызовы интерфейса доминируют в вычислениях.

Однако существуют нетривиальные недостатки, включая значительно повышенную сложность при ручном использовании, и повышенный риск внесения ошибок при их использовании. И хотя преимущества производительности отображаются в микробенчмарках, мы не считаем, что они будут значительными в подавляющем большинстве реальных сценариев использования. Если оказывается, что они есть, мы можем ввести второй набор интерфейсов в легкой форме.

Отброшенные варианты.

  • ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result);: параметры out не могут быть ковариантными. Здесь также существует небольшое влияние (проблема с шаблоном try в целом), заключающееся в том, что это, вероятно, вызывает барьер записи во время выполнения для результатов ссылочного типа.

Отмена

Существует несколько возможных подходов к поддержке отмены:

  1. IAsyncEnumerable<T> / IAsyncEnumerator<T> не зависят от отмены: CancellationToken не отображается нигде. Отмена достигается логически путем встраивания CancellationToken в перечисление и/или перечислитель в соответствующей форме, например, при вызове итератора, передаче CancellationToken в качестве аргумента методу итератора и использовании его в теле итератора, как это делается с любым другим параметром.
  2. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken): вы передаете CancellationToken в GetAsyncEnumerator, и последующие MoveNextAsync операции следуют ему насколько это возможно.
  3. IAsyncEnumerator<T>.MoveNextAsync(CancellationToken): Вы передаете CancellationToken каждому вызову MoveNextAsync отдельно.
  4. 1 && 2. Вы внедряете CancellationTokenв перечисление или перечислитель и передаете CancellationTokenв GetAsyncEnumerator.
  5. 1 && 3: Вы внедряете CancellationTokenв перечисляемую коллекцию или перечислитель и передаете CancellationTokenв MoveNextAsync.

С чисто теоретической точки зрения, (5) является самым надежным, так как (a) MoveNextAsync принятие CancellationToken позволяет наиболее точно контролировать над тем, что отменяется, и (b) CancellationToken это просто любой другой тип, который может передаваться в качестве аргумента в итераторы, включенные в произвольные типы и т. д.

Однако существует несколько проблем с этим подходом:

  • Как CancellationToken передается GetAsyncEnumerator и попадает в тело итератора? Мы могли бы предоставить новое ключевое слово iterator, которое можно было бы указать, чтобы получить доступ к CancellationToken переданному GetEnumerator, но а) это много дополнительных механизмов, б) мы делаем его очень первым классом гражданина, и c) 99% случае, казалось бы, тот же код, как вызов итератора, так и вызов GetAsyncEnumerator на нем, В этом случае он может просто передать CancellationToken в качестве аргумента в метод.
  • Как CancellationToken передается MoveNextAsync в текст метода? Это еще хуже, как если бы он исходил из iterator локального объекта, его значение может измениться между ожиданиями, что означает, что любому коду, зарегистрированному с маркером, потребуется отменить регистрацию перед ожиданием, а затем повторно зарегистрировать после; это также потенциально довольно дорого — производить такую регистрацию и отмену регистрации в каждом вызове MoveNextAsync, независимо от того, реализуется ли это компилятором в итераторе или разработчиком вручную.
  • Как разработчик отменяет цикл foreach? Если это делается путем предоставления CancellationToken перечислению или перечислителю, то или а) нам нужно поддерживать foreach"ing over перечислители, что делает их полноценными компонентами, и теперь нужно задуматься о создании экосистемы вокруг перечислителей (например, методов LINQ) или b) нам нужно внедрить CancellationToken в перечисление в любом случае путем наличия какого-либо метода расширения WithCancellation вне IAsyncEnumerable<T>, который будет хранить предоставленный токен, а затем передавать его в GetAsyncEnumerator оболочки перечисления, когда вызывается GetAsyncEnumerator в возвращенной структуре (игнорируя этот маркер). Или вы можете просто использовать CancellationToken, которые у вас есть в теле foreach.
  • Если будет поддержка понимания запросов, как CancellationToken, предоставленные GetEnumerator или MoveNextAsync, будут переданы в каждую клаузу? Самый простой способ заключается в том, чтобы условие захватило его, в этом случае любой токен, передаваемый в GetAsyncEnumerator/MoveNextAsync, игнорируется.

Более ранняя версия этого документа рекомендуется (1), но с тех пор мы переключились на (4).

Две основные проблемы с (1):

  • производители отменяемых перечислений должны реализовать некоторый стандартный код и могут только использовать поддержку компилятора для асинхронных итераторов для реализации метода IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken).
  • Скорее всего, многие производители будут склонны просто добавить параметр CancellationToken в свою асинхронную перечисленную сигнатуру, что помешает потребителям передавать маркер отмены, который они хотят, когда им предоставляется тип IAsyncEnumerable.

Существует два основных сценария потребления:

  1. await foreach (var i in GetData(token)) ..., где потребитель вызывает метод асинхронного итератора,
  2. await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ..., где потребитель взаимодействует с заданным экземпляром IAsyncEnumerable.

Мы обнаружили, что разумный компромисс для поддержки обоих сценариев удобным для обоих производителей и потребителей асинхронных потоков заключается в использовании специально аннотированного параметра в методе асинхронного итератора. Атрибут [EnumeratorCancellation] используется для этой цели. Поместив этот атрибут в параметр, компилятор сообщает компилятору, что если маркер передается методу GetAsyncEnumerator, этот маркер следует использовать вместо значения, первоначально передаваемого для параметра.

Рассмотрим IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default). Реализутель этого метода может просто использовать параметр в теле метода. Потребитель может использовать указанные выше шаблоны потребления:

  1. Если вы используете GetData(token), то токен сохраняется в асинхронном перечисляемом объекте и будет использоваться в итерации.
  2. Если вы используете givenIAsyncEnumerable.WithCancellation(token), то маркер, переданный в GetAsyncEnumerator, заменяет любой токен, сохраненный в асинхронном перечислении.

foreach

foreach будет дополнена для поддержки IAsyncEnumerable<T> в дополнение к существующей поддержке IEnumerable<T>. И он будет поддерживать эквивалент IAsyncEnumerable<T> как шаблон, если соответствующие члены предоставляются публично. В противном случае, будет использоваться сам интерфейс, чтобы обеспечить возможность расширений на базе структур, которые избегают выделения памяти, а также использовать альтернативные ожидаемые типы в качестве возвращаемого типа для MoveNextAsync и DisposeAsync.

Синтаксис

Использование синтаксиса:

foreach (var i in enumerable)

C# будет продолжать рассматривать enumerable как синхронный перечислитель, так что, даже если он предоставляет соответствующие API для асинхронных перечислителей (шаблон или реализация интерфейса), он будет учитывать только синхронные API.

Чтобы заставить foreach рассматривать только асинхронные API, await вставляется следующим образом:

await foreach (var i in enumerable)

Нет синтаксиса, который будет поддерживаться с помощью асинхронных или синхронных API; Разработчик должен выбрать в зависимости от используемого синтаксиса.

Семантика

Обработка инструкции await foreach во время компиляции сначала определяет тип коллекции , тип перечислителя и тип итерации для выражения (очень схоже с https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement). Это решение осуществляется следующим образом:

  • Если тип X выражения является dynamic или массивом, возникает ошибка и дальнейшие действия не выполняются.
  • В противном случае определите, имеет ли тип X соответствующий метод GetAsyncEnumerator:
    • Выполните поиск по членам для типа X с идентификатором GetAsyncEnumerator без аргументов типа. Если поиск члена не приводит к совпадению, приводит к неоднозначности или приводит к совпадению, которое не является группой методов, проверьте наличие перечисляемого интерфейса, как описано ниже.
    • Выполните разрешение перегрузки с помощью результирующей группы методов и пустого списка аргументов. Если разрешение перегрузки не приводит к применению применимых методов, приводит к неоднозначности или приводит к одному лучшему методу, но этот метод является статическим или не общедоступным, проверьте наличие перечисленного интерфейса, как описано ниже.
    • Если возвращаемый тип E метода GetAsyncEnumerator не является классом, структурой или типом интерфейса, создается ошибка и дальнейшие действия не выполняются.
    • Поиск членов выполняется на E с идентификатором Current без аргументов типа. Если поиск элемента не соответствует, результатом является ошибка, или результатом является что-либо, кроме свойства общедоступного экземпляра, которое разрешает чтение, создается ошибка, и дальнейшие действия не выполняются.
    • Поиск члена выполняется на E с идентификатором MoveNextAsync без аргументов типа. Если при поиске элемента совпадение не найдено, результатом является ошибка, или если результатом является что-либо, кроме группы методов, возникает ошибка, и никаких дальнейших действий не предпринимается.
    • Разрешение перегрузки выполняется в группе методов с пустым списком аргументов. Если разрешение перегрузки не приводит к применению применимых методов, приводит к неоднозначности или приводит к одному лучшему методу, но этот метод является статическим или не общедоступным, или его тип возвращаемого значения не ожидается в bool, возникает ошибка и дальнейшие действия не выполняются.
    • Тип коллекции X, тип перечислителя — E, а тип итерации — это тип свойства Current.
  • В противном случае проверьте наличие перечисленного интерфейса:
    • Если среди всех типов Tᵢ, для которых существует неявное преобразование из X в IAsyncEnumerable<ᵢ>, существует уникальный тип T таким образом, что T не является динамическим и для всех остальных Tᵢ существует неявное преобразование из IAsyncEnumerable<T> в IAsyncEnumerable<Tᵢ>, то тип коллекции является интерфейсом IAsyncEnumerable<T>, перечислитель — это интерфейс IAsyncEnumerator<T>, а тип итерации — T.
    • В противном случае, если существует несколько таких Tтипа, возникает ошибка и дальнейшие действия не выполняются.
  • В противном случае возникает ошибка, и дальнейшие действия не выполняются.

Приведенные выше шаги, если успешно, однозначно создают тип коллекции C, тип перечислителя E и тип итерации T.

await foreach (V v in x) «embedded_statement»

Затем разворачивается в:

{
    E e = ((C)(x)).GetAsyncEnumerator();
    try {
        while (await e.MoveNextAsync()) {
            V v = (V)(T)e.Current;
            «embedded_statement»
        }
    }
    finally {
        ... // Dispose e
    }
}

Тело блока finally строится следующим образом:

  • Если тип E имеет соответствующий метод DisposeAsync:
    • Выполните поиск членов типа E с идентификатором DisposeAsync и без аргументов типа. Если поиск участника не дает совпадения, создает неоднозначность или дает совпадение, которое не является группой методов, проверьте интерфейс освобождения, как описано ниже.
    • Выполните разрешение перегрузки с помощью результирующей группы методов и пустого списка аргументов. Если разрешение перегрузки не приводит к применению применимых методов, приводит к неоднозначности или приводит к одному лучшему методу, но этот метод является статическим или не общедоступным, проверьте интерфейс удаления, как описано ниже.
    • Если возвращаемый тип метода DisposeAsync не ожидается, создается ошибка и дальнейшие действия не выполняются.
    • Предложение finally расширяется до семантического эквивалента:
      finally {
          await e.DisposeAsync();
      }
    
  • В противном случае, если существует неявное преобразование из E в интерфейс System.IAsyncDisposable, то
    • Если E является типом ненулевого значения, то предложение finally расширяется до семантического эквивалента:
      finally {
          await ((System.IAsyncDisposable)e).DisposeAsync();
      }
    
    • В противном случае предложение finally расширяется до семантического эквивалента:
      finally {
          System.IAsyncDisposable d = e as System.IAsyncDisposable;
          if (d != null) await d.DisposeAsync();
      }
      
      за исключением того, что если E является типом значения или параметром типа, созданным для типа значения, преобразование e в System.IAsyncDisposable не должно привести к возникновению бокса.
  • В противном случае пункт finally расширяется до пустого блока:
    finally {
    }
    

НастройкаAwait

Эта компиляция, основанная на шаблонах, позволяет использовать ConfigureAwait для всех операций ожидания через метод расширения ConfigureAwait.

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

Это будет основано на типах, которые мы добавим в .NET, скорее всего, System.Threading.Tasks.Extensions.dll:

// Approximate implementation, omitting arg validation and the like
namespace System.Threading.Tasks
{
    public static class AsyncEnumerableExtensions
    {
        public static ConfiguredAsyncEnumerable<T> ConfigureAwait<T>(this IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext) =>
            new ConfiguredAsyncEnumerable<T>(enumerable, continueOnCapturedContext);

        public struct ConfiguredAsyncEnumerable<T>
        {
            private readonly IAsyncEnumerable<T> _enumerable;
            private readonly bool _continueOnCapturedContext;

            internal ConfiguredAsyncEnumerable(IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext)
            {
                _enumerable = enumerable;
                _continueOnCapturedContext = continueOnCapturedContext;
            }

            public ConfiguredAsyncEnumerator<T> GetAsyncEnumerator() =>
                new ConfiguredAsyncEnumerator<T>(_enumerable.GetAsyncEnumerator(), _continueOnCapturedContext);

            public struct ConfiguredAsyncEnumerator<T>
            {
                private readonly IAsyncEnumerator<T> _enumerator;
                private readonly bool _continueOnCapturedContext;

                internal ConfiguredAsyncEnumerator(IAsyncEnumerator<T> enumerator, bool continueOnCapturedContext)
                {
                    _enumerator = enumerator;
                    _continueOnCapturedContext = continueOnCapturedContext;
                }

                public ConfiguredValueTaskAwaitable<bool> MoveNextAsync() =>
                    _enumerator.MoveNextAsync().ConfigureAwait(_continueOnCapturedContext);

                public T Current => _enumerator.Current;

                public ConfiguredValueTaskAwaitable DisposeAsync() =>
                    _enumerator.DisposeAsync().ConfigureAwait(_continueOnCapturedContext);
            }
        }
    }
}

Обратите внимание, что этот подход не позволяет использовать ConfigureAwait со средствами перечисления на основе шаблонов, но, опять же, это уже тот случай, когда ConfigureAwait предоставляется только в качестве расширения для Task/Task<T>/ValueTask/ValueTask<T> и не может применяться к произвольным ожидаемым объектам, так как это имеет смысл только при применении к задачам (он управляет поведением, реализованным в поддержке продолжения задачи), и поэтому не имеет смысла при использовании шаблона, где ожидаемые элементы могут не являться задачами. Любой пользователь, возвращающий ожидаемые вещи, может обеспечить собственное пользовательское поведение в таких сложных сценариях.

(Если мы сможем придумать способ поддержки решения ConfigureAwait на уровне области видимости или сборки, то в этом не будет необходимости.)

Асинхронные итераторы

Язык или компилятор будет поддерживать создание IAsyncEnumerable<T>и IAsyncEnumerator<T>в дополнение к их потреблению. Сегодня язык поддерживает написание итератора, например:

static IEnumerable<int> MyIterator()
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            Thread.Sleep(1000);
            yield return i;
        }
    }
    finally
    {
        Thread.Sleep(200);
        Console.WriteLine("finally");
    }
}

но await нельзя использовать в теле этих итераторов. Мы добавим поддержку.

Синтаксис

Существующая поддержка языка для итераторов определяет характер итератора метода на основе того, содержит ли он любые yields. То же самое будет верно для асинхронных итераторов. Такие асинхронные итераторы будут демаркатированы и отличаются от синхронных итераторов путем добавления async в подпись, а затем должны иметь либо IAsyncEnumerable<T>, либо IAsyncEnumerator<T> в качестве возвращаемого типа. Например, приведенный выше пример может быть написан как асинхронный итератор следующим образом:

static async IAsyncEnumerable<int> MyIterator()
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            await Task.Delay(1000);
            yield return i;
        }
    }
    finally
    {
        await Task.Delay(200);
        Console.WriteLine("finally");
    }
}

Альтернативные варианты рассматриваются:

  • Не использовать async вподписи: использование async, скорее всего, технически требуется компилятором, так как он использует его для определения допустимости await в этом контексте. Но даже если это не обязательно, мы установили, что await можно использовать только в методах, помеченных как async, и, кажется, важно сохранить согласованность.
  • Включение пользовательских построителей для IAsyncEnumerable<T>: это то, что мы могли бы рассмотреть в будущем, но система сложная, и мы не поддерживаем это для синхронных аналогов.
  • Наличие ключевого слова iterator в подписи: асинхронные итераторы будут использовать async iterator в сигнатуре, и yield можно будет использовать только в методах async, включающих iterator; iterator затем станет необязательным для синхронных итераторов. В зависимости от вашей точки зрения, по сигнатуре метода становится ясно, допускается ли yield и действительно ли метод предназначен для возвращения экземпляров типа IAsyncEnumerable<T>, а не того, чтобы компилятор создавал его в зависимости от использования кода yield или нет. Но он отличается от синхронных итераторов, которые не требуют этого и не могут быть настроены так, чтобы требовать. Кроме того, некоторые разработчики не любят дополнительный синтаксис. Если бы мы разрабатывали его с нуля, мы, вероятно, сделали бы это обязательным, но на этом этапе гораздо важнее, чтобы асинхронные итераторы оставались похожими на синхронные итераторы.

LINQ

Существует более 200 перегрузок методов в классе System.Linq.Enumerable, все из которых работают на основе IEnumerable<T>; некоторые из них принимают IEnumerable<T>, некоторые из них производят IEnumerable<T>, и многие делают и то, и другое. Добавить поддержку LINQ для IAsyncEnumerable<T>, скорее всего, потребует дублирования всех этих перегрузок для них, приблизительно на 200 еще. И поскольку IAsyncEnumerator<T>, вероятно, будет более распространенной в качестве автономной сущности в асинхронном мире, чем IEnumerator<T> в синхронном мире, нам может потребоваться еще около 200 перегрузок, совместимых с IAsyncEnumerator<T>. Кроме того, большое количество перегрузок связано с предикатами (например, Where, которое принимает Func<T, bool>), и может быть желательно иметь перегрузки на основе IAsyncEnumerable<T>, которые работают с синхронными и асинхронными предикатами (например, Func<T, ValueTask<bool>> в дополнение к Func<T, bool>). Хотя это применимо не ко всем из примерно 400 новых перегрузок, можно грубо подсчитать, что это относится примерно к половине, то есть к 200 перегрузкам, всего к около 600 новым методам.

Это ошеломляющее количество API, что может еще больше, если рассматриваются библиотеки расширений, такие как интерактивные расширения (Ix). Но Ix уже имеет реализацию многих из них, и, кажется, не является большой причиной дублирования этой работы; Вместо этого мы должны помочь сообществу улучшить Ix и рекомендовать его, если разработчики хотят использовать LINQ с IAsyncEnumerable<T>.

Существует также проблема синтаксиса понимания запроса. Характер понимания запросов на основе шаблонов позволит им "просто работать" с некоторыми операторами, например, если Ix предоставляет следующие методы:

public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, TResult> func);
public static IAsyncEnumerable<T> Where(this IAsyncEnumerable<T> source, Func<T, bool> func);

затем этот код C# будет "просто работать":

IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
                               where item % 2 == 0
                               select item * 2;

Однако отсутствует синтаксис для понимания запросов, поддерживающий использование await в конструкциях, поэтому, если Ix добавлен, например:

public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);

потом это будет "работать как надо"

IAsyncEnumerable<string> result = from url in urls
                                  where item % 2 == 0
                                  select SomeAsyncMethod(item);

async ValueTask<int> SomeAsyncMethod(int item)
{
    await Task.Yield();
    return item * 2;
}

но невозможно было бы встроить await в текст предложения select. В качестве отдельной задачи, мы могли бы рассмотреть добавление выражений async { ... } в язык. В этом случае мы могли бы разрешить их использование в конструкциях запросов, и вышеупомянутое могло бы быть написано следующим образом:

IAsyncEnumerable<int> result = from item in enumerable
                               where item % 2 == 0
                               select async
                               {
                                   await Task.Yield();
                                   return item * 2;
                               };

или включить возможность использования await непосредственно в выражениях, например, за счет поддержки async from. Тем не менее, маловероятно, что дизайн здесь повлияет на остальной набор функций в одну или другую сторону, и это не особенно ценная область для инвестирования прямо сейчас, так что предлагается сейчас ничего дополнительно не делать.

Интеграция с другими асинхронными платформами

Интеграция с IObservable<T> и другими асинхронными платформами (например, реактивными потоками) выполняется на уровне библиотеки, а не на уровне языка. Например, все данные из IAsyncEnumerator<T> можно опубликовать в IObserver<T>, просто выполняя операцию await foreachпо перечислителю и передавая данные к наблюдателю с помощью операции OnNext, поэтому метод расширения AsObservable<T> возможен. Использование IObservable<T> в await foreach требует буферизации данных (в случае отправки другого элемента во время обработки предыдущего элемента), но такой адаптер push-pull можно легко реализовать, чтобы позволить IObservable<T> извлекаться с IAsyncEnumerator<T>. И т. д. Rx/Ix уже предоставляют прототипы таких реализаций, а библиотеки, такие как https://github.com/dotnet/corefx/tree/master/src/System.Threading.Channels предоставляют различные виды структур буферизации данных. Язык не должен участвовать на этом этапе.