Partage via


Flux asynchrones

Remarque

Cet article est une spécification de fonctionnalité. La spécification sert de document de conception pour la fonctionnalité. Elle inclut les changements de spécification proposés, ainsi que les informations nécessaires à la conception et au développement de la fonctionnalité. Ces articles sont publiés jusqu'à ce que les changements proposés soient finalisés et incorporés dans la spécification ECMA actuelle.

Il peut y avoir des différences entre la spécification de la fonctionnalité et l'implémentation réalisée. Ces différences sont consignées dans les notes pertinentes de la réunion de conception linguistique (LDM).

Pour en savoir plus sur le processus d'adoption des speclets de fonctionnalité dans la norme du langage C#, consultez l'article sur les spécifications.

Problème phare : https://github.com/dotnet/csharplang/issues/43

Récapitulatif

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. Pour résoudre ce problème, nous devrions autoriser l’utilisation d’await dans une nouvelle forme d’itérateur async, qui renverra IAsyncEnumerable<T> ou IAsyncEnumerator<T> au lieu d’IEnumerable<T> ou IEnumerator<T>, avec IAsyncEnumerable<T> consommable dans un nouvel await foreach. Une interface IAsyncDisposable est également utilisée pour activer le nettoyage asynchrone.

Conception détaillée

Interfaces

IAsyncDisposable

Il y a eu beaucoup de discussions sur IAsyncDisposable (par exemple, https://github.com/dotnet/roslyn/issues/114). On s’est demandé s’il s’agissait d’une bonne idée. Toutefois, il s’agit d’un concept à ajouter pour prendre en charge les itérateurs asynchrones. Étant donné que les blocs finally peuvent contenir des awaits, 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 à n’appeler qu’une seule fois la méthode la plus pertinente en fonction du contexte, Dispose dans les contextes synchrones et DisposeAsync dans les contextes asynchrones.

(La manière dont IAsyncDisposable interagit avec using fait l’objet d’une discussion distincte. La façon dont il interagit avec foreach est traitée plus loin dans cette proposition.)

Alternatives prises en compte :

  • DisposeAsync acceptant un CancellationToken : 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ême CancellationToken qui a entraîné l’annulation du travail effectif serait en général le même jeton passé à DisposeAsync, rendant DisposeAsync inutile, car l’annulation du travail impliquerait que DisposeAsync soit un no-op. Si quelqu’un veut éviter d’être bloqué en attendant d’élimination, il peut éviter d’attendre le ValueTask résultant, ou attendre seulement pendant une certaine période de temps.
  • DisposeAsync renvoyant un objet Task : maintenant qu’il existe un objet ValueTask non générique et qu’il peut être construit à partir d’un élément IValueTaskSource, renvoyer ValueTask à partir de DisposeAsync permet à un objet existant d’être réutilisé comme promesse représentant la fin asynchrone éventuelle de DisposeAsync, enregistrant une allocation de Task dans le cas où DisposeAsync se terminerait de manière asynchrone.
  • Configuration de DisposeAsync avec un bool 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 de await et il n'y a rien à configurer... Les consommateurs de ValueTask peuvent la consommer comme ils le souhaitent.
  • IAsyncDisposable héritant d’IDisposable : étant donné que seul l’un ou l’autre doit être utilisé, il n’est pas judicieux de forcer les types à implémenter les deux.
  • IDisposableAsync au lieu de IAsyncDisposable: 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 typique (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 de Task<bool> permettrait d'utiliser un objet de tâche mis en cache pour représenter des appels synchrones et réussis MoveNextAsync, mais une allocation serait toujours nécessaire pour l'achèvement asynchrone. En renvoyant ValueTask<bool>, nous permettons à l’objet énumérateur d’implémenter lui-même IValueTaskSource<bool> et d’être utilisé comme support pour l’élément ValueTask<bool> renvoyé par MoveNextAsync, 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 que T ne peut plus être covariant.
  • ValueTask<T?> TryMoveNextAsync(); : non covariant.
  • Task<T?> TryMoveNextAsync(); : non covariant, allocations à chaque appel, etc.
  • ITask<T?> TryMoveNextAsync(); : non covariant, allocations à chaque appel, etc.
  • ITask<(bool,T)> TryMoveNextAsync(); : non covariant, allocations à chaque appel, etc.
  • Task<bool> TryMoveNextAsync(out T result); : le résultat out doit être défini lorsque l’opération renvoie la tâche de façon synchrone, et non lorsqu’elle termine de manière asynchrone la tâche potentiellement à très long terme, lorsqu’il n’y a plus aucun moyen de communiquer le résultat.
  • IAsyncEnumerator<T> n’implémentant pas IAsyncDisposable : 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, avec tout itérateur asynchrone C# qui a un bloc final, avec 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 sous la forme public 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 renvoie false et, chaque fois qu’il renvoie false, un appelant doit ensuite appeler WaitForNextAsync pour attendre que l’élément suivant soit disponible ou pour déterminer qu’il n’y aura jamais d’autre élément. La consommation typique (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 n’est pas possible lorsque MoveNextAsync et Current sont séparés de telle sorte qu’une implémentation ne peut pas rendre leur utilisation atomique. En revanche, cette approche fournit une méthode unique TryGetNext 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 imposer la prise en charge de l’utilisation simultanée par tous les énumérateurs, 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 ne pourrait généralement pas s’appuyer dessus de toute façon.
  • Majeur : Performance. L’approche MoveNextAsync/Current nécessite deux appels d’interface par opération, alors que le meilleur scénario pour WaitForNextAsync/TryGetNext implique que la plupart des itérations se terminent de manière synchrone, ce qui permet une boucle interne étroite avec TryGetNext, de sorte que nous n’ayons 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 figurent dans les microbenchmarks, nous ne pensons pas qu’ils auront un impact majeur dans la grande majorité des cas d’utilisation réels. S'il s'avère que c'est le cas, nous pouvons introduire un deuxième ensemble d'interfaces de manière progressive.

Options écartées prises en compte :

  • ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result); : les paramètres out 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 :

  1. IAsyncEnumerable<T> / IAsyncEnumerator<T> sont indépendantes de l’annulation : CancellationToken n’apparaît nulle part. L’annulation est obtenue en intégrant logiquement le CancellationToken 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 le CancellationToken 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.
  2. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken): Vous passez un CancellationToken à GetAsyncEnumerator, et les opérations de MoveNextAsync suivantes le respectent autant que possible.
  3. IAsyncEnumerator<T>.MoveNextAsync(CancellationToken) : Vous passez un CancellationToken à chaque appel MoveNextAsync individuel.
  4. 1 && 2 : Vous intégrez des CancellationToken dans votre énumérable/énumérateur et transmettez des CancellationToken dans GetAsyncEnumerator.
  5. 1 && 3 : Vous intégrez des CancellationToken dans votre énumérable/énumérateur et transmettez des CancellationToken dans MoveNextAsync.

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 au CancellationToken à 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 appelant GetAsyncEnumerator par-dessus. Dans ce cas, on peut donc simplement passer le CancellationToken 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 local iterator, 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 appel MoveNextAsync, 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 foreach ? Si cette opération est effectuée en donnant un CancellationToken à un énumérable/énumérateur, alors a) nous devons prendre en charge le foreach 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 le CancellationToken dans l’énumérable de toute façon en utilisant une méthode d’extension WithCancellation de IAsyncEnumerable<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 le CancellationToken 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 ou MoveNextAsync 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 type IAsyncEnumerable.

Il existe deux scénarios de consommation principaux :

  1. await foreach (var i in GetData(token)) ... où le consommateur appelle la méthode itérateur asynchrone,
  2. await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ... où le consommateur traite d’une instance IAsyncEnumerable donnée.

Nous constatons qu’un compromis raisonnable pour prendre en charge ces 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 de l’itérateur asynchrone. L’attribut [EnumeratorCancellation] est utilisé à cet effet. Le placement de cet attribut au niveau d’un paramètre indique au compilateur que si un jeton est transmis à la méthode GetAsyncEnumerator, ce jeton devra être utilisé au lieu de la valeur transmise 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 :

  1. si vous utilisez GetData(token), alors le jeton est enregistré dans l’énumérable asynchrone et sera utilisé dans l’itération.
  2. si vous utilisez givenIAsyncEnumerable.WithCancellation(token), le jeton transmis à GetAsyncEnumerator remplace tous les jetons enregistrés dans l’énumérable asynchrone.

foreach

foreach sera amélioré pour prendre en charge IAsyncEnumerable<T> en plus de la prise en charge existante d’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

suivant 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 prendra en compte que les API synchrones.

Pour forcer foreach à ne prendre en compte que les API asynchrones, await est inséré comme suit :

await foreach (var i in enumerable)

Aucune syntaxe prenant en charge l’utilisation des API asynchrone ou synchrones n’est fournie. Le développeur devra 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, le type d’énumérateur et le 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 a lieu comme suit :

  • Si le type X d’expression est dynamic 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éthode GetAsyncEnumerator appropriée :
    • Effectuer une recherche de membre sur le type X avec l'identifiant GetAsyncEnumerator et aucun argument de type. Si la recherche de membre ne produit pas de correspondance, 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éthode GetAsyncEnumerator 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.
    • Une recherche de membre est effectuée sur E avec l'identificateur Current et sans argument de type. Si la recherche de membre ne produit aucune correspondance, si le résultat est une erreur ou si le résultat est autre 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.
    • Une recherche de membre est effectuée sur E avec l'identificateur MoveNextAsync et sans argument de type. Si la recherche de membre ne produit aucune correspondance, si le résultat est une erreur ou si le résultat est autre 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 unique, mais que cette méthode est statique ou non publique, ou que 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 est E et le type d’itération est le type de la propriété Current.
  • Sinon, recherchez une interface énumérable :
    • Si, parmi tous les types Tᵢ pour lesquels il existe une conversion implicite de X en IAsyncEnumerable<ᵢ>, il existe un type unique T de sorte que T n’est pas dynamique et si, pour tous les autres Tᵢ, il existe une conversion implicite de IAsyncEnumerable<T> en IAsyncEnumerable<Tᵢ>, le type de collection est l’interface IAsyncEnumerable<T>, le type d’énumérateur est l’interface IAsyncEnumerator<T> et le type d’itération est T.
    • Sinon, s’il existe plusieurs types T, une erreur est générée et aucune autre étape n’est effectuée.
  • Sinon, une erreur est générée et aucune autre autre étape n’est effectuée.

Les étapes ci-dessus, si elles aboutissent, produisent sans ambiguïté un type de collection C, une type d’énumérateur E et un 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 ci-dessous :

  • Si le type E a une méthode DisposeAsync appropriée :
    • Effectuer une recherche de membre sur le type E avec l'identifiant DisposeAsync et aucun argument de type. Si la recherche de membre ne produit pas de correspondance, génère une ambiguïté ou produit une correspondance qui n’est pas un groupe de méthodes, recherchez l’interface disposable, 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();
      }
    
  • Sinon, s’il existe une conversion implicite de E vers l’interface System.IAsyncDisposable :
    • Si E est un type de valeur non nullable, la clause finally est étendue à l’équivalent sémantique de :
      finally {
          await ((System.IAsyncDisposable)e).DisposeAsync();
      }
    
    • Sinon, la clause finally est étendue à l’équivalent sémantique de :
      finally {
          System.IAsyncDisposable d = e as System.IAsyncDisposable;
          if (d != null) await d.DisposeAsync();
      }
      
      sauf que, si E est un type valeur ou un paramètre de type instancié en type valeur, alors la conversion de e en System.IAsyncDisposable ne doit pas provoquer l'emboxage.
  • 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 ne permet pas l’utilisation de ConfigureAwait avec des énumérables basées sur des modèles, mais il est déjà vrai que 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é à des tâches (il contrôle un comportement implémenté dans la prise en charge de la continuation de la tâche), et il n’a donc pas de sens lors de l’utilisation d’un modèle où les éléments attendus 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, le langage 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 cette prise en charge.

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émarqués et différenciés des itérateurs synchrones via l’ajout de async à la signature, et devront également avoir IAsyncEnumerable<T> ou IAsyncEnumerator<T> comme type de retour. Par exemple, l’exemple ci-dessus peut être écrit sous forme d’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 : techniquement, l’utilisation d’async est probablement requise par le compilateur, car elle l’utilise pour déterminer si await est valide dans ce contexte. Mais même s’il n’est pas obligatoire, nous avons établi qu’await ne peut être utilisé que dans les méthodes marquées comme async, 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 la signature : les itérateurs asynchrones utiliseraient un async iterator dans la signature, et yield ne pourrait être utilisé que dans les méthodes async qui incluaient iterator ; iterator serait 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 si yield est autorisé et si la méthode est réellement destinée à retourner des instances de type IAsyncEnumerable<T>, plutôt que le compilateur n’en fabrique une selon que le code utilise ou non yield. 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 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, qui pourra être encore plus important lorsque des bibliothèques d’extensions comme Interactive Extensions (Ix) seront prises en compte. Mais Ix a déjà une implémentation pour beaucoup d’entre eux, et il ne semble pas y avoir de bonne raison de dupliquer ces efforts ; 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 la syntaxe de compréhension des requêtes. La nature des compréhensions de requête, basée sur des modèles, 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);

ce code C# « fonctionnera 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 d’await dans les clauses. Par exemple, si Ix ajoutait :

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

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 foreachsur 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. Le langage n’a pas besoin d’être impliqué à ce stade.