Асинхронные потоки
Заметка
Эта статья является спецификацией компонентов. Спецификация служит проектным документом для функции. Это включает предлагаемые изменения спецификации, а также информацию, необходимую во время проектирования и разработки функции. Эти статьи публикуются до тех пор, пока предложенные изменения спецификации не будут завершены и включены в текущую спецификацию 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 в целом), заключающееся в том, что это, вероятно, вызывает барьер записи во время выполнения для результатов ссылочного типа.
Отмена
Существует несколько возможных подходов к поддержке отмены:
-
IAsyncEnumerable<T>
/IAsyncEnumerator<T>
не зависят от отмены:CancellationToken
не отображается нигде. Отмена достигается логически путем встраиванияCancellationToken
в перечисление и/или перечислитель в соответствующей форме, например, при вызове итератора, передачеCancellationToken
в качестве аргумента методу итератора и использовании его в теле итератора, как это делается с любым другим параметром. -
IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: вы передаетеCancellationToken
вGetAsyncEnumerator
, и последующиеMoveNextAsync
операции следуют ему насколько это возможно. -
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: Вы передаетеCancellationToken
каждому вызовуMoveNextAsync
отдельно. - 1 && 2. Вы внедряете
CancellationToken
в перечисление или перечислитель и передаетеCancellationToken
вGetAsyncEnumerator
. - 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
.
Существует два основных сценария потребления:
-
await foreach (var i in GetData(token)) ...
, где потребитель вызывает метод асинхронного итератора, -
await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
, где потребитель взаимодействует с заданным экземпляромIAsyncEnumerable
.
Мы обнаружили, что разумный компромисс для поддержки обоих сценариев удобным для обоих производителей и потребителей асинхронных потоков заключается в использовании специально аннотированного параметра в методе асинхронного итератора. Атрибут [EnumeratorCancellation]
используется для этой цели. Поместив этот атрибут в параметр, компилятор сообщает компилятору, что если маркер передается методу GetAsyncEnumerator
, этот маркер следует использовать вместо значения, первоначально передаваемого для параметра.
Рассмотрим IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default)
.
Реализутель этого метода может просто использовать параметр в теле метода.
Потребитель может использовать указанные выше шаблоны потребления:
- Если вы используете
GetData(token)
, то токен сохраняется в асинхронном перечисляемом объекте и будет использоваться в итерации. - Если вы используете
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
нельзя использовать в теле этих итераторов. Мы добавим поддержку.
Синтаксис
Существующая поддержка языка для итераторов определяет характер итератора метода на основе того, содержит ли он любые yield
s. То же самое будет верно для асинхронных итераторов. Такие асинхронные итераторы будут демаркатированы и отличаются от синхронных итераторов путем добавления 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 предоставляют различные виды структур буферизации данных. Язык не должен участвовать на этом этапе.
C# feature specifications