Flux asynchrones
Remarque
Cet article est une spécification de fonctionnalité. La spécification sert de document de conception pour la fonctionnalité. Il inclut les modifications de spécification proposées, ainsi que les informations nécessaires pendant la conception et le développement de la fonctionnalité. Ces articles sont publiés jusqu’à ce que les modifications de spécification proposées soient finalisées et incorporées dans la spécification ECMA actuelle.
Il peut y avoir des différences entre la spécification de la fonctionnalité et l’implémentation terminée. Ces différences sont consignées dans les notes pertinentes de la réunion de conception linguistique (LDM).
Vous pouvez en savoir plus sur le processus d’adoption des speclets de fonctionnalités dans la norme de langage C# dans l’article sur les spécifications .
Résumé
C# prend en charge les méthodes itératrices et les méthodes asynchrones, mais ne prend pas en charge une méthode qui est à la fois itératrice et asynchrone. Nous devrions corriger cela en autorisant l’utilisation de await
dans une nouvelle forme d’itérateur de async
, qui retourne un IAsyncEnumerable<T>
ou IAsyncEnumerator<T>
plutôt qu’un IEnumerable<T>
ou un IEnumerator<T>
, avec IAsyncEnumerable<T>
consommable dans un nouveau await foreach
. Une interface IAsyncDisposable
est également utilisée pour activer le nettoyage asynchrone.
Discussion connexe
Conception détaillée
Interfaces
IAsyncDisposable
Il y a eu beaucoup de discussions sur IAsyncDisposable
(par exemple, https://github.com/dotnet/roslyn/issues/114) et s’il s’agit d’une bonne idée. Toutefois, il s’agit d’un concept requis pour ajouter la prise en charge des itérateurs asynchrones. Étant donné que les blocs finally
peuvent contenir des await
s, et étant donné que les blocs finally
doivent être exécutés dans le cadre de la suppression des itérateurs, nous avons besoin d'un nettoyage asynchrone. C'est également généralement utile à tout moment où le nettoyage des ressources pourrait prendre du temps, par exemple, fermer des fichiers (nécessitant des vidages), annuler l'enregistrement des rappels et fournir un moyen de savoir quand la désinscription est terminée, etc.
L’interface suivante est ajoutée aux bibliothèques .NET principales (par exemple, System.Private.CoreLib / System.Runtime) :
namespace System
{
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
}
Comme avec Dispose
, l’appel de DisposeAsync
plusieurs fois est acceptable, et les appels suivants après le premier doivent être traités comme des opérations nulles, renvoyant une tâche réussie terminée de manière synchrone (DisposeAsync
n’a pas besoin d’être thread-safe, cependant, et n’a pas besoin de prendre en charge l’appel simultané). En outre, les types peuvent implémenter à la fois IDisposable
et IAsyncDisposable
, et s’ils le font, il est de même acceptable d’appeler Dispose
, puis DisposeAsync
ou vice versa, mais seuls les premiers doivent être significatifs et les appels ultérieurs de l’un ou l’autre doivent être un nop. Par conséquent, si un type implémente les deux, les consommateurs sont encouragés à appeler une seule fois et une seule fois la méthode la plus pertinente en fonction du contexte, Dispose
dans des contextes synchrones et DisposeAsync
dans des méthodes asynchrones.
(Comment IAsyncDisposable
interagit avec using
est une discussion distincte. Et la couverture de la façon dont elle interagit avec foreach
est gérée plus loin dans cette proposition.)
Alternatives prises en compte :
-
DisposeAsync
acceptant unCancellationToken
: en théorie, il est logique que tout async puisse être annulé ; la suppression consiste à nettoyer, à fermer les choses, à libérer des ressources, etc., ce qui n’est généralement pas quelque chose qui doit être annulé ; le nettoyage est toujours important pour le travail annulé. La mêmeCancellationToken
qui a entraîné l’annulation du travail effectif serait en général le même jeton passé àDisposeAsync
, rendantDisposeAsync
inutile, car l’annulation du travail impliquerait queDisposeAsync
soit un no-op. Si quelqu’un veut éviter d’être bloqué en attendant d’élimination, il peut éviter d’attendre leValueTask
résultant, ou attendre seulement pendant une certaine période de temps. DisposeAsync
renvoyer unTask
: maintenant qu’unValueTask
non générique existe et peut être construit à partir d’unIValueTaskSource
, le retour deValueTask
à partir deDisposeAsync
permet à un objet existant d’être réutilisé comme promesse représentant la fin asynchrone éventuelle deDisposeAsync
, enregistrant une allocation deTask
dans le cas oùDisposeAsync
se termine de manière asynchrone.- Configuration de
DisposeAsync
avec unbool continueOnCapturedContext
(ConfigureAwait
) : même s'il peut y avoir des problèmes liés à la façon dont un tel concept est exposé àusing
,foreach
et d'autres constructions de langage qui le consomment, du point de vue de l'interface, il n'y a pas réellement deawait
et il n'y a rien à configurer... Les consommateurs deValueTask
peuvent la consommer comme ils le souhaitent. IAsyncDisposable
hériter deIDisposable
: étant donné que seul un ou l’autre doit être utilisé, il n’est pas judicieux de forcer les types à implémenter les deux.IDisposableAsync
au lieu deIAsyncDisposable
: nous avons suivi la convention de nommer les choses/types comme « quelque chose d'asynchrone » alors que les opérations se font « de manière asynchrone », ainsi les types ont « Async » comme préfixe et les méthodes ont « Async » comme suffixe.
IAsyncEnumerable / IAsyncEnumerator
Deux interfaces sont ajoutées aux bibliothèques .NET principales :
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; }
}
}
La consommation classique (sans fonctionnalités de langage supplémentaires) ressemble à ceci :
IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
while (await enumerator.MoveNextAsync())
{
Use(enumerator.Current);
}
}
finally { await enumerator.DisposeAsync(); }
Options écartées prises en compte :
Task<bool> MoveNextAsync(); T current { get; }
: L'utilisation deTask<bool>
permettrait d'utiliser un objet de tâche mis en cache pour représenter des appels synchrones et réussisMoveNextAsync
, mais une allocation serait toujours nécessaire pour l'achèvement asynchrone. En retournantValueTask<bool>
, nous permettons à l’objet énumérateur d’implémenter lui-mêmeIValueTaskSource<bool>
et d’être utilisé comme support pour leValueTask<bool>
retourné parMoveNextAsync
, ce qui permet à son tour de réduire considérablement les surcharges.ValueTask<(bool, T)> MoveNextAsync();
: Ce n’est pas seulement plus difficile à consommer, mais cela signifie queT
ne peut plus être covariant.ValueTask<T?> TryMoveNextAsync();
: pas covariant.Task<T?> TryMoveNextAsync();
: pas covariant, allocations sur chaque appel, etc.ITask<T?> TryMoveNextAsync();
: pas covariant, allocations sur chaque appel, etc.ITask<(bool,T)> TryMoveNextAsync();
: pas covariant, allocations sur chaque appel, etc.Task<bool> TryMoveNextAsync(out T result);
: le résultatout
doit être défini lorsque l’opération retourne de façon synchrone, et non lorsqu’elle termine de manière asynchrone la tâche potentiellement longtemps à l’avenir, à quel moment il n’y aurait aucun moyen de communiquer le résultat.IAsyncEnumerator<T>
ne pas implémenterIAsyncDisposable
: nous pourrions choisir de les séparer. Toutefois, cela complique certains autres domaines de la proposition, car le code doit ensuite être en mesure de traiter la possibilité qu'un énumérateur ne fournisse pas de libération, ce qui rend difficile la rédaction d'assistants basés sur des modèles. En outre, il est courant que les énumérateurs aient besoin d’être supprimés (par exemple, tout itérateur asynchrone C# qui a un bloc final, la plupart des éléments énumérant des données à partir d’une connexion réseau, etc.), et si ce n’est pas le cas, il est simple d’implémenter la méthode uniquement commepublic ValueTask DisposeAsync() => default(ValueTask);
avec une surcharge supplémentaire minimale.- _
IAsyncEnumerator<T> GetAsyncEnumerator()
: aucun paramètre de jeton d’annulation.
La sous-section suivante décrit les alternatives qui n’ont pas été choisies.
Alternative viable :
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
est utilisé dans une boucle interne pour consommer des éléments avec un seul appel d’interface tant qu’ils sont disponibles de manière synchrone. Lorsque l’élément suivant ne peut pas être récupéré de façon synchrone, il retourne false et, chaque fois qu’il retourne false, un appelant doit ensuite appeler WaitForNextAsync
pour attendre que l’élément suivant soit disponible, soit pour déterminer qu’il n’y aura jamais d’autre élément. La consommation classique (sans fonctionnalités de langage supplémentaires) ressemble à ceci :
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(); }
L’avantage de cela est double, un mineur et un majeur :
- Mineur : Permet à un énumérateur de prendre en charge plusieurs consommateurs. Il peut y avoir des scénarios où il est utile pour un énumérateur de prendre en charge plusieurs consommateurs simultanés. Cela ne peut pas être obtenu lorsque
MoveNextAsync
etCurrent
sont séparés de telle sorte qu’une implémentation ne puisse pas rendre leur utilisation atomique. En revanche, cette approche fournit une méthode uniqueTryGetNext
qui prend en charge l'envoi de l'énumérateur vers l'avant et l'obtention de l'élément suivant, permettant ainsi à l'énumérateur d'activer l'atomicité si souhaité. Toutefois, il est probable que de tels scénarios puissent également être activés en donnant à chaque consommateur son propre énumérateur à partir d'une collection énumérable partagée. En outre, nous ne voulons pas appliquer que chaque énumérateur prend en charge l’utilisation simultanée, car cela ajouterait des surcharges non triviales à la majorité des cas qui ne le nécessitent pas, ce qui signifie qu’un consommateur de l’interface n’a généralement pas pu s’appuyer sur cette façon. - Majeur : Performance. L’approche
MoveNextAsync
/Current
nécessite deux appels d’interface par opération, alors que le meilleur cas pourWaitForNextAsync
/TryGetNext
est que la plupart des itérations se terminent de manière synchrone, ce qui permet une boucle interne étroite avecTryGetNext
, de sorte que nous n’avons qu’un seul appel d’interface par opération. Cela peut avoir un impact mesurable dans les situations où les appels d’interface dominent le calcul.
Toutefois, il existe des inconvénients non triviaux, notamment une complexité significativement accrue lors de la consommation manuelle de ceux-ci, et une probabilité plus élevée d’introduire des bogues lors de leur utilisation. Et bien que les avantages en matière de performances apparaissent dans les microbenchmarks, nous ne pensons pas qu’ils seront impactants dans la grande majorité de l’utilisation réelle. S'il s'avère que c'est le cas, nous pouvons introduire un deuxième ensemble d'interfaces de manière progressive.
Options ignorées prises en compte :
ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result);
:out
paramètres ne peuvent pas être covariants. Il y a aussi un impact mineur ici (un problème lié au modèle try en général), car cela entraîne probablement une barrière d'écriture à l'exécution pour les résultats de type référence.
Annulation
Il existe plusieurs approches possibles pour prendre en charge l’annulation :
-
IAsyncEnumerable<T>
/IAsyncEnumerator<T>
sont indépendantes de l’annulation :CancellationToken
n’apparaît nulle part. L’annulation est obtenue en intégrant logiquement leCancellationToken
dans l’énumérable et/ou l’énumérateur de manière appropriée, par exemple lors de l’appel d’un itérateur, en passant leCancellationToken
en tant qu’argument à la méthode de l’itérateur et en l’utilisant dans le corps de l’itérateur, comme pour tout autre paramètre. -
IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: Vous passez unCancellationToken
àGetAsyncEnumerator
, et les opérations deMoveNextAsync
suivantes le respectent autant que possible. -
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: Vous passez unCancellationToken
à chaque appelMoveNextAsync
individuel. - 1 && 2 : Vous intégrez des
CancellationToken
dans votre énumérable/énumérateur et transmettez desCancellationToken
dansGetAsyncEnumerator
. - 1 && 3 : Vous intégrez des
CancellationToken
dans votre énumérable/énumérateur et transmettez desCancellationToken
dansMoveNextAsync
.
Du point de vue purement théorique, (5) est le plus robuste, en ce que (a) MoveNextAsync
acceptant un CancellationToken
permet le contrôle le plus précis sur ce qui est annulé, et (b) CancellationToken
est tout autre type qui peut être passé en tant qu'argument aux itérateurs, incorporé dans des types arbitraires, etc.
Toutefois, il existe plusieurs problèmes avec cette approche :
- Comment un
CancellationToken
passé àGetAsyncEnumerator
se retrouve-t-il dans le corps de l’itérateur ? Nous pourrions exposer un nouveau mot cléiterator
qui servirait à faire passer l’accès auCancellationToken
àGetEnumerator
, mais a) cela impliquerait beaucoup de mécanismes supplémentaires, b) nous en ferions un citoyen de première classe, et c) dans 99 % des cas, cela semblerait être le même code à la fois appelant un itérateur et appelantGetAsyncEnumerator
par-dessus. Dans ce cas, on peut donc simplement passer leCancellationToken
en tant qu’argument dans la méthode. - Comment un
CancellationToken
passé àMoveNextAsync
peut-il entrer dans le corps de la méthode ? Cela est encore pire, comme s'il était exposé à partir d’un objet localiterator
, sa valeur pourrait changer entre les opérations attendues, ce qui signifie que tout code inscrit auprès du jeton devrait se désinscrire avant ces opérations, puis se réinscrire après celles-ci ; il pourrait s’avérer très coûteux d'effectuer ce type d'inscription et de désinscription dans chaque appelMoveNextAsync
, que ce soit implémenté par le compilateur dans un itérateur ou par un développeur manuellement. - Comment un développeur annule-t-il une boucle de
foreach
? Si cette opération est effectuée en donnant unCancellationToken
à un énumérable/énumérateur, alors a) nous devons prendre en charge leforeach
via des énumérateurs, ce qui les élève au rang de citoyens de première classe dans le code et implique que vous devez maintenant commencer à réfléchir à un écosystème construit autour des énumérateurs (par exemple, les méthodes LINQ) ou b) nous devons incorporer leCancellationToken
dans l’énumérable de toute façon en utilisant une méthode d’extensionWithCancellation
deIAsyncEnumerable<T>
qui stockerait le jeton fourni, puis le transmettrait dans l’GetAsyncEnumerator
de l’énumérable encapsulé lorsque l’GetAsyncEnumerator
sur la structure retournée est appelé (en ignorant ce jeton). Ou bien, vous pouvez simplement utiliser leCancellationToken
dont vous disposez dans le corps de la boucle foreach. - Si/quand les compréhensions des requêtes sont prises en charge, comment le
CancellationToken
fourni àGetEnumerator
ouMoveNextAsync
serait-il transmis dans chaque clause ? La méthode la plus simple serait simplement que la clause le capture. À ce moment-là n'importe quel jeton passé àGetAsyncEnumerator
/MoveNextAsync
est ignoré.
Une version antérieure de ce document recommandait (1), mais nous sommes depuis passés à (4).
Les deux principaux problèmes avec (1) :
- les producteurs d’énumérables annulables doivent implémenter un code réutilisable et peuvent seulement profiter de la prise en charge du compilateur pour les itérateurs asynchrones afin d’implémenter une méthode
IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken)
. - il est probable que de nombreux producteurs soient tentés d’ajouter simplement un paramètre
CancellationToken
à leur signature asynchrone énumérable, ce qui empêchera les consommateurs de transmettre le jeton d’annulation qu’ils souhaitent lorsqu’ils reçoivent un typeIAsyncEnumerable
.
Il existe deux scénarios de consommation principaux :
await foreach (var i in GetData(token)) ...
où le consommateur appelle la méthode itérateur asynchrone,-
await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
où le consommateur traite d’une instanceIAsyncEnumerable
donnée.
Nous constatons qu’un compromis raisonnable pour prendre en charge les deux scénarios d’une manière pratique pour les producteurs et les consommateurs de flux asynchrones consiste à utiliser un paramètre spécialement annoté dans la méthode async-itérateur. L’attribut [EnumeratorCancellation]
est utilisé à cet effet. Le placement de cet attribut sur un paramètre indique au compilateur que si un jeton est passé à la méthode GetAsyncEnumerator
, ce jeton doit être utilisé au lieu de la valeur passée initialement pour le paramètre.
Prenez le cas de IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default)
.
L’implémenteur de cette méthode peut simplement utiliser le paramètre dans le corps de la méthode.
Le consommateur peut utiliser les deux modèles de consommation ci-dessus :
- si vous utilisez
GetData(token)
, alors le jeton est enregistré dans l’énumérable asynchrone et sera utilisé dans l’itération. - si vous utilisez
givenIAsyncEnumerable.WithCancellation(token)
, le jeton passé àGetAsyncEnumerator
remplace tous les jetons enregistrés dans l’énumérable asynchrone.
foreach
foreach
sera augmentée pour prendre en charge IAsyncEnumerable<T>
en plus de son support existant pour IEnumerable<T>
. Et il prendra en charge l’équivalent de IAsyncEnumerable<T>
en tant que modèle si les membres pertinents sont exposés publiquement ; sinon, il utilisera directement l’interface pour permettre des extensions basées sur des structures qui peuvent éviter l'allocation, tout en utilisant d’autres awaitables comme type de retour de MoveNextAsync
et DisposeAsync
.
Syntaxe
Utilisation de la syntaxe :
foreach (var i in enumerable)
C# continuera à traiter enumerable
comme énumérable synchrone, de sorte que même s’il expose les API pertinentes pour les énumérables asynchrones (exposant le modèle ou implémentant l’interface), il ne prend en compte que les API synchrones.
Pour forcer foreach
à considérer uniquement les API asynchrones, await
est insérée comme suit :
await foreach (var i in enumerable)
Aucune syntaxe n’est fournie qui prend en charge l’utilisation de l’api asynchrone ou de synchronisation ; le développeur doit choisir en fonction de la syntaxe utilisée.
Sémantique
Le traitement au moment de la compilation d’une instruction await foreach
détermine d’abord le type de collection , type d’énumérateur et type d’itération de l’expression (très similaire à https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement). Cette détermination se poursuit comme suit :
- Si le type
X
de expression estdynamic
ou un type de tableau, une erreur est générée et aucune autre étape n’est effectuée. - Sinon, déterminez si le type
X
a une méthodeGetAsyncEnumerator
appropriée :- Effectuer une recherche de membre sur le type
X
avec l'identifiantGetAsyncEnumerator
et aucun argument de type. Si la recherche de membre ne produit pas de correspondance, ou génère une ambiguïté ou produit une correspondance qui n’est pas un groupe de méthodes, recherchez une interface énumérable, comme décrit ci-dessous. - Effectuez une résolution de surcharge à l’aide du groupe de méthodes résultant et d’une liste d’arguments vides. Si la résolution de surcharge ne donne lieu à aucune méthode applicable, engendre une ambiguïté, ou n'identifie qu'une seule meilleure méthode qui est statique ou non publique, vérifiez la présence d'une interface énumérable comme décrit ci-dessous.
- Si le type de retour
E
de la méthodeGetAsyncEnumerator
n’est pas une classe, un struct ou un type d’interface, une erreur est générée et aucune autre étape n’est effectuée. - La recherche de membre est effectuée sur
E
avec l’identificateurCurrent
et aucun argument de type. Si la recherche de membre ne produit aucune correspondance, le résultat est une erreur ou le résultat est autre chose qu’une propriété d’instance publique qui autorise la lecture, une erreur est générée et aucune autre étape n’est effectuée. - La recherche de membre est effectuée sur
E
avec l’identificateurMoveNextAsync
et aucun argument de type. Si la recherche de membre ne produit aucune correspondance, le résultat est une erreur ou le résultat est autre chose qu’un groupe de méthodes, une erreur est générée et aucune autre étape n’est effectuée. - La résolution de surcharge est effectuée sur le groupe de méthodes avec une liste d’arguments vide. Si la résolution de surcharge ne produit aucune méthode applicable, génère une ambiguïté ou génère une méthode optimale, mais cette méthode est statique ou non publique, ou son type de retour n’est pas attendu dans
bool
, une erreur est générée et aucune autre étape n’est effectuée. - Le type de collection est
X
, le type d’énumérateur estE
et le type d’itération est le type de la propriétéCurrent
.
- Effectuer une recherche de membre sur le type
- Sinon, recherchez une interface énumérable :
- Si, parmi tous les types
Tᵢ
pour lesquels il existe une conversion implicite deX
enIAsyncEnumerable<ᵢ>
, il existe un type uniqueT
de sorte queT
n’est pas dynamique et pour tous les autresTᵢ
il existe une conversion implicite deIAsyncEnumerable<T>
enIAsyncEnumerable<Tᵢ>
, le type de collection est l’interfaceIAsyncEnumerable<T>
, le type d’énumérateur est l’interfaceIAsyncEnumerator<T>
, et le type d’itération estT
. - Sinon, s’il existe plusieurs types de ce type
T
, une erreur est générée et aucune autre étape n’est effectuée.
- Si, parmi tous les types
- Sinon, une erreur est générée et aucune autre procédure n’est effectuée.
Les étapes ci-dessus, si elles réussissent, produisent sans ambiguïté un type de collection C
, type d’énumérateur E
et type d’itération T
.
await foreach (V v in x) «embedded_statement»
est ensuite étendu à :
{
E e = ((C)(x)).GetAsyncEnumerator();
try {
while (await e.MoveNextAsync()) {
V v = (V)(T)e.Current;
«embedded_statement»
}
}
finally {
... // Dispose e
}
}
Le corps du bloc finally
est construit en suivant les étapes suivantes :
- Si le type
E
a une méthodeDisposeAsync
appropriée :- Effectuez une recherche de membre sur le type
E
avec l’identificateurDisposeAsync
et sans arguments de type. Si la recherche de membre ne produit pas de correspondance, ou qu’elle génère une ambiguïté ou produit une correspondance qui n’est pas un groupe de méthodes, vérifiez l’interface de suppression comme décrit ci-dessous. - Effectuez une résolution de surcharge à l’aide du groupe de méthodes résultant et d’une liste d’arguments vides. Si la résolution de surcharge n’entraîne aucune méthode applicable, entraîne une ambiguïté, ou aboutit à une seule meilleure méthode, mais que cette méthode est soit statique, soit non publique, vérifiez l'interface de disposition comme décrit ci-dessous.
- Si le type de retour de la méthode
DisposeAsync
n’est pas attendu, une erreur est générée et aucune autre étape n’est effectuée. - La clause
finally
est étendue à l’équivalent sémantique de :
finally { await e.DisposeAsync(); }
- Effectuez une recherche de membre sur le type
- Sinon, s’il existe une conversion implicite de
E
vers l’interfaceSystem.IAsyncDisposable
,- Si
E
est un type valeur non nullable, la clausefinally
est étendue à l’équivalent sémantique de :
finally { await ((System.IAsyncDisposable)e).DisposeAsync(); }
- Sinon, la clause
finally
est étendue à l’équivalent sémantique de :
sauf que, sifinally { System.IAsyncDisposable d = e as System.IAsyncDisposable; if (d != null) await d.DisposeAsync(); }
E
est un type valeur ou un paramètre de type instancié en type valeur, alors la conversion dee
enSystem.IAsyncDisposable
ne doit pas provoquer l'emboxage.
- Si
- Sinon, la clause
finally
est transformée en un bloc vide :finally { }
ConfigureAwait
Cette compilation basée sur des modèles permet d'appliquer ConfigureAwait
sur tous les awaits, via une méthode d’extension ConfigureAwait
:
await foreach (T item in enumerable.ConfigureAwait(false))
{
...
}
Ceci sera fondé sur les types que nous ajouterons également à .NET, probablement pour 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);
}
}
}
}
Notez que cette approche n’permettra pas ConfigureAwait
d’être utilisée avec des énumérables basées sur des modèles, mais il est déjà vrai que le ConfigureAwait
n’est exposé qu’en tant qu’extension sur Task
/Task<T>
/ValueTask
/ValueTask<T>
et ne peut pas être appliqué à des éléments attendus arbitraires, car il n’est logique que lorsqu’il est appliqué aux tâches (il contrôle un comportement implémenté dans la prise en charge de continuation de la tâche), et donc n’a pas de sens lors de l’utilisation d’un modèle où les choses attendues peuvent ne pas être des tâches. Toute personne retournant des éléments susceptibles d’être attendus peut fournir son propre comportement personnalisé dans de tels scénarios avancés.
(Si nous pouvons trouver un moyen de prendre en charge une solution ConfigureAwait
au niveau de la portée ou de l'assemblage, cela ne sera pas nécessaire.)
Itérateurs asynchrones
Le langage/compilateur prend en charge la production de IAsyncEnumerable<T>
et de IAsyncEnumerator<T>
, ainsi que leur consommation. Aujourd’hui, la langue prend en charge l’écriture d’un itérateur comme suit :
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");
}
}
mais await
ne peut pas être utilisé dans le corps de ces itérateurs. Nous ajouterons ce support.
Syntaxe
La prise en charge du langage existant pour les itérateurs déduit la nature d'itérateur de la méthode en fonction de la présence de yield
dans celle-ci. Il en va de même pour les itérateurs asynchrones. Ces itérateurs asynchrones seront démarcatés et différenciés des itérateurs synchrones via l’ajout de async
à la signature, et doivent également avoir IAsyncEnumerable<T>
ou IAsyncEnumerator<T>
comme type de retour. Par exemple, l’exemple ci-dessus peut être écrit en tant qu’itérateur asynchrone comme suit :
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");
}
}
Alternatives prises en compte :
- Ne pas utiliser
async
dans la signature: l’utilisation deasync
est probablement techniquement requise par le compilateur, car elle l’utilise pour déterminer siawait
est valide dans ce contexte. Mais même s’il n’est pas obligatoire, nous avons établi queawait
ne peut être utilisé que dans les méthodes marquées commeasync
, et il semble important de maintenir la cohérence. - Activation de constructeurs personnalisés pour
IAsyncEnumerable<T>
: c’est une chose sur laquelle nous pourrions nous pencher à l’avenir, mais les mécaniques impliquées sont compliquées et nous ne la prenons pas en charge pour les équivalents synchrones. - Avoir un mot clé
iterator
dans lede signature : les itérateurs asynchrones utiliseraient desasync iterator
dans la signature, etyield
ne pouvait être utilisé que dans les méthodesasync
qui incluaientiterator
;iterator
sera ensuite rendu facultatif sur les itérateurs synchrones. Selon votre point de vue, cela a l’avantage de rendre la chose très claire par la signature de la méthode siyield
est autorisé et si la méthode est réellement destinée à retourner des instances de typeIAsyncEnumerable<T>
, plutôt que le compilateur n’en fabrique une selon que le code utilise ou nonyield
. Cela est cependant différent des itérateurs synchrones, qui ne peuvent pas être rendus obligatoires. De plus, certains développeurs n’aiment pas la syntaxe supplémentaire. Si nous devions le concevoir à partir de zéro, nous le rendrions probablement obligatoire, mais à ce stade, il y a beaucoup plus de valeur à garder les itérateurs asynchrones proches des itérateurs synchrones.
LINQ
Il y a plus de 200 surcharges de méthodes sur la classe System.Linq.Enumerable
et toutes fonctionnent par rapport à IEnumerable<T>
; certaines d'entre elles acceptent IEnumerable<T>
, certaines d'entre elles produisent IEnumerable<T>
, et beaucoup remplissent ces deux fonctions. L’ajout de la prise en charge LINQ pour IAsyncEnumerable<T>
impliquerait probablement de dupliquer toutes ces surcharges, soit environ 200 supplémentaires. Et étant donné que IAsyncEnumerator<T>
est susceptible d’être plus courant en tant qu’entité autonome dans le monde asynchrone que IEnumerator<T>
ne l'est dans le monde synchrone, nous pourrions potentiellement avoir besoin d'environ 200 autres surcharges qui fonctionnent avec IAsyncEnumerator<T>
. De plus, un grand nombre de surcharges traitent des prédicats (par exemple, Where
qui accepte un Func<T, bool>
), et il peut être souhaitable d’avoir des surcharges basées sur des IAsyncEnumerable<T>
qui traitent à la fois des prédicats synchrones et asynchrones (par exemple, Func<T, ValueTask<bool>>
en plus de Func<T, bool>
). Bien que cela ne s’applique pas à environ 400 nouvelles surcharges, un calcul approximatif est qu’il serait applicable à la moitié, ce qui signifie environ 200 surcharges supplémentaires, soit un total de ~600 nouvelles méthodes.
Il s’agit d’un nombre important d’API, ce qui peut être encore plus important lorsque des bibliothèques d’extensions comme Interactive Extensions (Ix) sont prises en compte. Mais Ix a déjà une implémentation de beaucoup d’entre eux, et il ne semble pas être une grande raison de dupliquer ce travail ; nous devrions plutôt aider la communauté à améliorer Ix et le recommander lorsque les développeurs souhaitent utiliser LINQ avec IAsyncEnumerable<T>
.
Il existe également le problème de syntaxe de compréhension des requêtes. La nature basée sur le modèle des compréhensions de requête leur permettrait de « travailler simplement » avec certains opérateurs, par exemple si Ix fournit les méthodes suivantes :
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);
alors ce code C# va « fonctionner simplement » :
IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select item * 2;
Toutefois, il n’existe aucune syntaxe de compréhension des requêtes qui prend en charge l’utilisation de await
dans les clauses. Par exemple, si Ix a été ajouté :
public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);
alors cela « fonctionnerait simplement » :
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;
}
cependant, il n’y aurait aucun moyen de l’écrire avec l’inline await
dans la clause select
. En guise d’effort distinct, nous pourrions envisager d’ajouter des expressions async { ... }
à la langue, moment auquel nous pourrions leur permettre d'être utilisés dans les compréhensions de requête et ainsi, l'exemple ci-dessus pourrait s'écrire comme suit :
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select async
{
await Task.Yield();
return item * 2;
};
ou pour permettre à await
d’être utilisé directement dans des expressions, par exemple en prenant en charge async from
. Toutefois, il est peu probable qu'une conception ici ait un impact significatif sur le reste de l'ensemble des fonctionnalités, et ce n'est pas un investissement de grande valeur pour le moment, donc la proposition est de ne rien faire de plus pour l'instant.
Intégration à d’autres frameworks asynchrones
L’intégration à IObservable<T>
et à d’autres frameworks asynchrones (par exemple, les flux réactifs) est effectuée au niveau de la bibliothèque plutôt qu’au niveau du langage. Par exemple, toutes les données d’un IAsyncEnumerator<T>
peuvent être publiées dans un IObserver<T>
simplement en appliquant await foreach
sur l'énumérateur et en transmettant les données à l'observateur avec OnNext
, de sorte qu'une méthode d'extension AsObservable<T>
est possible. La consommation d’un IObservable<T>
dans un await foreach
nécessite la mise en mémoire tampon des données (au cas où un autre élément est envoyé pendant le traitement de l’élément précédent), mais un tel adaptateur push-pull peut facilement être implémenté pour permettre à un IObservable<T>
d’être tiré au moyen d’un IAsyncEnumerator<T>
. Etc. Rx/Ix fournissent déjà des prototypes de telles implémentations et des bibliothèques comme https://github.com/dotnet/corefx/tree/master/src/System.Threading.Channels fournissent différents types de structures de données de mise en mémoire tampon. La langue n’a pas besoin d’être impliquée à ce stade.
C# feature specifications