Pourquoi, pourquoi et quand utiliser ValueTask

Cette traduction a été réalisée grâce au bon commentaire 0x1000000 .

image


Le .NET Framework 4 a introduit l'espace System.Threading.Tasks et, avec lui, la classe Task. Ce type et la tâche <TResult> générée par celui-ci attendent depuis longtemps avant d'être reconnus par les normes dans .NET comme les aspects clés du modèle de programmation asynchrone introduit en C # 5 avec ses instructions async / attente. Dans cet article, je parlerai de nouveaux types de ValueTask / ValueTask <TResult>, conçus pour améliorer les performances des méthodes asynchrones dans les cas où la surcharge d'allocation de mémoire devrait être prise en compte.


Tâche


La tâche agit dans différents rôles, mais le principal est la «promesse» (promesse), un objet représentant l'achèvement possible d'une opération. Vous lancez une opération et obtenez un objet Task pour celle-ci, qui sera exécuté lorsque l'opération sera terminée, ce qui peut se produire en mode synchrone dans le cadre de l'initialisation de l'opération (par exemple, la réception de données qui sont déjà dans le tampon), en mode asynchrone avec exécution au moment où vous obtenez Task (recevoir des données non pas du tampon, mais très rapidement), ou en mode asynchrone, mais après avoir déjà Task (recevoir des données d'une ressource distante). Étant donné que l'opération peut se terminer de manière asynchrone, vous pouvez soit bloquer le flux d'exécution, attendre le résultat (ce qui rend souvent l'asynchronie de l'appel vide de sens), soit créer une fonction de rappel qui sera activée une fois l'opération terminée. Dans .Net 4, la création d'un rappel est implémentée par les méthodes ContinueWith de l'objet Task, qui illustrent explicitement ce modèle en acceptant une fonction déléguée pour l'exécuter après l'exécution de la tâche:


SomeOperationAsync().ContinueWith(task => { try { TResult result = task.Result; UseResult(result); } catch (Exception e) { HandleException(e); } }); 

Mais dans le .NET Framework 4.5 et C # 5, les objets Task peuvent simplement être appelés par l'opérateur wait, ce qui facilite l'obtention du résultat d'une opération asynchrone, et le code généré optimisé pour les options ci-dessus fonctionnera correctement dans tous les cas lorsque l'opération se terminera en mode synchrone, asynchrone rapide ou asynchrone avec le rappel:


 TResult result = await SomeOperationAsync(); UseResult(result); 

La tâche est une classe très flexible et présente un certain nombre d'avantages. Par exemple, vous pouvez effectuer plusieurs fois l'attente pour n'importe quel nombre de consommateurs à la fois. Vous pouvez le mettre dans une collection (dictionnaire) pour des attentes répétées à l'avenir, pour l'utiliser comme cache des résultats des appels asynchrones. Vous pouvez bloquer l'exécution en attendant la fin de la tâche si nécessaire. Et vous pouvez écrire et appliquer diverses opérations sur des objets de tâche (parfois appelés «combinateurs»), par exemple, «le cas échéant» pour attendre de manière asynchrone la première exécution de plusieurs tâches.
Mais cette flexibilité devient superflue dans le cas le plus courant: il suffit d'appeler l'opération asynchrone et d'attendre la fin de la tâche:


 TResult result = await SomeOperationAsync(); UseResult(result); 

Ici, nous n'avons pas besoin d'attendre l'exécution plusieurs fois. Nous n'avons pas besoin de nous assurer que les attentes sont compétitives. Nous n'avons pas besoin d'effectuer un verrouillage synchrone. Nous n'écrirons pas de combinateurs. Nous attendons juste que la promesse d'une opération asynchrone soit terminée. En fin de compte, c'est la façon dont nous écrivons le code synchrone (par exemple, TResult result = SomeOperation ();), et il est normalement traduit en async / wait.


De plus, Task a une faiblesse potentielle, en particulier lorsqu'un grand nombre d'instances sont créées, et un débit et des performances élevés sont des exigences clés - Task est une classe. Cela signifie que toute opération qui avait besoin d'une tâche est obligée de créer et de placer un objet, et plus il y a d'objets créés, plus le garbage collector (GC) est chargé, et ce travail consomme des ressources que nous pourrions dépenser pour quelque chose de plus utile.


Les bibliothèques d'exécution et système aident à atténuer ce problème dans de nombreuses situations. Par exemple, si nous écrivons une méthode comme celle-ci:


 public async Task WriteAsync(byte value) { if (_bufferedCount == _buffer.Length) { await FlushAsync(); } _buffer[_bufferedCount++] = value; } 

en règle générale, il y aura suffisamment d'espace libre dans le tampon et l'opération sera exécutée de manière synchrone. Lorsque cela se produit, il n'est pas nécessaire de faire quoi que ce soit avec la tâche, qui doit être renvoyée, car il n'y a pas de valeur de retour, cela utilise la tâche comme l'équivalent d'une méthode synchrone renvoyant une valeur vide (void). Par conséquent, l'environnement peut simplement mettre en cache une tâche non générique et l'utiliser à plusieurs reprises comme résultat de l'exécution pour toute méthode asynchrone qui se termine de manière synchrone (ce singleton mis en cache peut être obtenu via Task.CompletedTask). Ou, par exemple, vous écrivez:


 public async Task<bool> MoveNextAsync() { if (_bufferedCount == 0) { await FillBuffer(); } return _bufferedCount > 0; } 

et en général, attendez-vous à ce que les données soient déjà dans le tampon, de sorte que la méthode vérifie simplement la valeur de _bufferedCount, voit qu'elle est supérieure à 0 et renvoie true; et uniquement s'il n'y a pas encore de données dans le tampon, vous devez effectuer une opération asynchrone. Et comme il n'y a que deux résultats possibles de type booléen (vrai et faux), il n'y a que deux objets Task possibles qui sont nécessaires pour représenter ces résultats, l'environnement peut mettre en cache ces objets et les renvoyer avec la valeur correspondante sans allouer de mémoire. Uniquement en cas d'achèvement asynchrone, la méthode devra créer une nouvelle tâche, car elle devra être renvoyée avant que le résultat de l'opération ne soit connu.

L'environnement fournit la mise en cache pour certains autres types, mais il n'est pas réaliste de mettre en cache tous les types possibles. Par exemple, la méthode suivante:


 public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; } 

sera également souvent exécuté de manière synchrone. Mais contrairement à une variante avec un résultat de type booléen, cette méthode renvoie Int32, qui a environ 4 milliards de valeurs, et la mise en cache de toutes les variantes de Task <int> nécessitera des centaines de gigaoctets de mémoire. L'environnement fournit un petit cache pour la tâche <int>, mais un ensemble très limité de valeurs, par exemple, si cette méthode se termine de manière synchrone (les données sont déjà dans le tampon) avec la valeur de retour de 4, ce sera une tâche mise en cache, mais si la valeur 42 est retournée, vous devrez en créer une nouvelle Tâche <int>, similaire à l'appel de Task.FromResult (42).


De nombreuses méthodes de bibliothèque tentent de résoudre ce problème en fournissant leur propre cache. Par exemple, une surcharge dans le .NET Framework 4.5 de la méthode MemoryStream.ReadAsync se termine toujours de manière synchrone, car il lit les données de la mémoire. ReadAsync renvoie une tâche <int>, où un résultat Int32 indique le nombre d'octets lus. Cette méthode est souvent utilisée dans une boucle, souvent avec le même nombre d'octets requis pour chaque appel, et souvent ce besoin est entièrement satisfait. Ainsi, pour les appels répétés à ReadAsync, il est raisonnable de s'attendre à ce que la tâche <int> revienne de manière synchrone avec la même valeur que lors de l'appel précédent. Par conséquent, un MemoryStream crée un cache pour un objet qui est retourné lors du dernier appel réussi. Et dans le prochain appel, si le résultat est répété, il renverra l'objet mis en cache et sinon, en créera un nouveau avec Task.FromResult, l'enregistrera dans le cache et le renverra.


Néanmoins, il existe de nombreux autres cas où l'opération est effectuée de manière synchrone, mais l'objet Task <TResult> est obligé d'être créé.


ValueTask <TResult> et exécution synchrone


Tout cela a nécessité l'implémentation d'un nouveau type dans .NET Core 2.0, qui était disponible dans les versions précédentes de .NET dans le package NuGet System.Threading.Tasks.Extensions: ValueTask <TResult>.
ValueTask <TResult> a été créé dans .NET Core 2.0 en tant que structure capable d'encapsuler TResult et Task <TResult>. Cela signifie qu'elle peut être renvoyée par la méthode async, et si cette méthode est exécutée de manière synchrone et réussie, vous n'avez pas besoin de placer d'objet sur le tas: vous pouvez simplement initialiser cette structure ValueTask <TResult> avec la valeur TResult et la renvoyer. Seulement dans le cas d'une exécution asynchrone, l'objet Task <TResult> sera placé, et ValueTask <TResult> l'enveloppera (pour minimiser la taille de la structure et optimiser le cas d'une exécution réussie, la méthode async, qui se termine par une exception non prise en charge, placera également la tâche <TResult>, donc ValueTask <TResult> encapsule également simplement la tâche <TResult>, et ne comporte pas de champ supplémentaire pour stocker l'exception).


Sur cette base, une méthode comme MemoryStream.ReadAsync, mais renvoyant une ValueTask <int>, ne devrait pas traiter la mise en cache, mais peut à la place être écrite comme ceci:


 public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count) { try { int bytesRead = Read(buffer, offset, count); return new ValueTask<int>(bytesRead); } catch (Exception e) { return new ValueTask<int>(Task.FromException<int>(e)); } } 

ValueTask <TResult> et exécution asynchrone


La possibilité d'écrire une méthode asynchrone qui peut se terminer de manière synchrone sans avoir besoin de placement supplémentaire pour le résultat est une grande victoire. C'est pourquoi ValueTask <TResult> a été ajouté dans .NET Core 2.0, et de nouvelles méthodes susceptibles d'être utilisées dans des applications qui nécessitent des performances sont désormais annoncées avec le retour de ValueTask <TResult> au lieu de Task <TResult>. Par exemple, lorsque nous avons ajouté une nouvelle surcharge ReadAsync de la classe Stream à .NET Core 2.1, afin de pouvoir transmettre la mémoire au lieu de l'octet [], nous y retournons le type ValueTask <int>. Sous cette forme, les objets Stream (dans lesquels la méthode ReadAsync est très souvent exécutée de manière synchrone, comme dans l'exemple précédent pour le MemoryStream) peuvent être utilisés avec beaucoup moins d'allocation de mémoire.

Cependant, lorsque nous travaillons avec des services à très large bande passante, nous voulons toujours éviter l'allocation de mémoire autant que possible, ce qui signifie également réduire et éliminer l'allocation de mémoire le long de la route d'exécution asynchrone.
Dans le modèle en attente, pour toute opération qui se termine de manière asynchrone, nous avons besoin de la possibilité de renvoyer un objet qui représente la fin possible de l'opération: l'appelant doit rediriger le rappel qui sera initié à la fin de l'opération, et cela nécessite un objet unique dans le tas, qui peut servir de canal de transfert pour cette opération particulière. En même temps, cela ne veut rien dire si cet objet sera réutilisé une fois l'opération terminée. Si cet objet peut être réutilisé, l'API peut organiser un cache pour un ou plusieurs de ces objets et l'utiliser pour des opérations séquentielles, dans le sens de ne pas utiliser le même objet pour plusieurs opérations asynchrones intermédiaires, mais pour un accès non concurrentiel.
Dans .NET Core 2.1, la classe ValueTask <TResult> a été améliorée pour prendre en charge le regroupement et la réutilisation similaires. Au lieu de simplement encapsuler TResult ou Task <TResult>, une classe révisée peut encapsuler une nouvelle interface IValueTaskSource <TResult>. Cette interface fournit les fonctionnalités de base requises pour accompagner une opération asynchrone avec un objet ValueTask <TResult> de la même manière que la tâche <TResult>:


 public interface IValueTaskSource<out TResult> { ValueTaskSourceStatus GetStatus(short token); void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags); TResult GetResult(short token); } 

La méthode GetStatus est utilisée pour implémenter des propriétés telles que ValueTask <TResult> .IsCompleted, qui renvoie des informations indiquant si une opération asynchrone est effectuée ou terminée, et comment elle est terminée (réussie ou non). La méthode OnCompleted est utilisée par l'objet en attente pour attacher un rappel afin de poursuivre l'exécution à partir du point d'attente une fois l'opération terminée. Et la méthode GetResult est nécessaire pour obtenir le résultat de l'opération, donc après la fin de l'opération, l'appelant peut obtenir l'objet TResult ou passer toute exception levée.


La plupart des développeurs n'ont pas besoin de cette interface: les méthodes renvoient simplement un objet ValueTask <TResult>, qui peut être créé comme encapsuleur pour un objet qui implémente cette interface, et la méthode appelante restera dans le noir. Cette interface est destinée aux développeurs qui doivent éviter l'allocation de mémoire lors de l'utilisation d'une API critique pour les performances.


Il existe plusieurs exemples d'une telle API dans .NET Core 2.1. Les méthodes les plus connues sont Socket.ReceiveAsync et Socket.SendAsync avec de nouvelles surcharges ajoutées en 2.1, par exemple


 public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default); 

Cette surcharge renvoie une ValueTask <int>. Si l'opération se termine de manière synchrone, il peut simplement renvoyer une ValueTask <int> avec la valeur correspondante:


 int result = …; return new ValueTask<int>(result); 

Lorsqu'il est terminé de manière asynchrone, il peut utiliser un objet du pool qui implémente l'interface:


 IValueTaskSource<int> vts = …; return new ValueTask<int>(vts); 

L'implémentation Socket prend en charge un tel objet dans le pool pour la réception et un pour la transmission, car il ne peut pas y avoir plus d'un objet pour chaque direction en attente d'être exécuté en même temps. Ces surcharges n'allouent pas de mémoire, même dans le cas d'une opération asynchrone. Ce comportement est plus apparent dans la classe NetworkStream.
Par exemple, dans .NET Core 2.1 Stream fournit:


 public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken); 

qui est redéfini dans NetworkStream. La méthode NetworkStream.ReadAsync utilise simplement la méthode Socket.ReceiveAsync, de sorte que les gains dans Socket sont diffusés sur NetworkStream et que NetworkStream.ReadAsync n'alloue pas réellement de mémoire non plus.


Tâche de valeur non partagée


Lorsque ValueTask <TResult> est apparu dans .NET Core 2.0, seul le cas d'exécution synchrone y a été optimisé afin d'exclure le placement de l'objet Task <TResult> si la valeur TResult est déjà prête. Cela signifiait que la classe générique ValueTask n'était pas nécessaire: pour le cas d'une exécution synchrone, le singleton Task.CompletedTask pouvait simplement être renvoyé par la méthode, ce qui était fait implicitement par l'environnement dans les méthodes asynchrones renvoyant Task.


Cependant, avec l'obtention d'opérations asynchrones sans allouer de mémoire, l'utilisation de la ValueTask non partagée est redevenue pertinente. Dans .NET Core 2.1, nous avons introduit les génériques ValueTask et IValueTaskSource. Ils fournissent des équivalents directs pour les versions génériques, pour une utilisation similaire, avec uniquement une valeur de retour vide.


Implémenter IValueTaskSource / IValueTaskSource <T>


La plupart des développeurs ne devraient pas implémenter ces interfaces. De plus, ce n'est pas si facile. Si vous décidez de le faire, plusieurs implémentations dans .NET Core 2.1 peuvent servir de point de départ, par exemple:


  • AwaitableSocketAsyncEventArgs
  • AsyncOperation <TResult>
  • DefaultPipeReader

Pour faciliter cela, dans .NET Core 3.0, nous prévoyons d'introduire toute la logique nécessaire incluse dans le type ManualResetValueTaskSourceCore <TResult>, une structure qui peut être incorporée dans un autre objet qui implémente IValueTaskSource <TResult> et / ou IValueTaskSource, afin qu'elle puisse être déléguée à Cette structure constitue l'essentiel de la fonctionnalité. Vous pouvez en savoir plus à ce sujet sur https://github.com/dotnet/corefx/issues/32664 dans le référentiel dotnet / corefx.


Modèles d'application ValueTasks


À première vue, la portée de ValueTask et ValueTask <TResult> est beaucoup plus limitée que Task et Task <TResult>. C'est bien, et même attendu, car la principale façon de les utiliser est simplement d'utiliser l'opérateur wait.


Cependant, comme ils peuvent encapsuler des objets qui sont réutilisés, il existe des restrictions importantes à leur utilisation par rapport à Task et Task <TResult>, si vous vous écartez de la manière habituelle d'attendre simplement. Dans les cas généraux, les opérations suivantes ne doivent jamais être effectuées avec ValueTask / ValueTask <TResult>:


  • Attente répétée ValueTask / ValueTask <TResult> L'objet résultat peut déjà être supprimé et utilisé dans une autre opération. En revanche, Task / Task <TResult> ne passe jamais d'un état terminé à un état incomplet, vous pouvez donc vous y attendre autant de fois que nécessaire et obtenir le même résultat à chaque fois.
  • Attente parallèle ValueTask / ValueTask <TResult> L'objet résultat attend un traitement avec un seul rappel d'un consommateur à la fois, et essayer d'attendre à partir de différents flux en même temps peut facilement conduire à des courses et à de subtiles erreurs de programme. De plus, il s'agit également d'un cas plus spécifique de la précédente opération de «réattente» non valide. En comparaison, Task / Task <TResult> fournit un nombre illimité d'attente parallèle.
  • Utilisation de .GetAwaiter (). GetResult () lorsque l'opération n'est pas encore terminée. L' implémentation de IValueTaskSource / IValueTaskSource n'a pas besoin de prise en charge du verrouillage tant que l'opération n'est pas terminée, et très probablement ne le fera pas, donc une telle opération entraînera certainement une course et probablement ne s'exécutera pas comme le prévoit la méthode appelante. Task / Task <TResult> bloque le thread appelant jusqu'à ce que la tâche soit terminée.

Si vous avez reçu une ValueTask ou ValueTask <TResult>, mais que vous devez effectuer l'une de ces trois opérations, vous pouvez utiliser .AsTask (), obtenir Task / Task <TResult>, puis travailler avec l'objet reçu. Après cela, vous ne pouvez plus utiliser cette ValueTask / ValueTask <TResult>.


En bref, la règle est la suivante: lorsque vous utilisez ValueTask / ValueTask <TResult>, vous devez soit l'attendre directement (éventuellement avec .ConfigureAwait (false)), soit appeler AsTask () et ne plus l'utiliser:


 //   ,  ValueTask<int> public ValueTask<int\> SomeValueTaskReturningMethodAsync(); ... // GOOD int result = await SomeValueTaskReturningMethodAsync(); // GOOD int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false); // GOOD Task<int> t = SomeValueTaskReturningMethodAsync().AsTask(); // WARNING ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); //       , //     // BAD: await   ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt; // BAD: await  (    ) ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt); // BAD:  GetAwaiter().GetResult(),     ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = vt.GetAwaiter().GetResult(); 

Il y a un autre modèle avancé que les programmeurs peuvent appliquer, j'espère, seulement après une mesure minutieuse et l'obtention d'avantages significatifs. Les classes ValueTask / ValueTask <TResult> ont plusieurs propriétés qui signalent l'état actuel de l'opération, par exemple, la propriété IsCompleted renvoie true si l'opération est terminée (c'est-à-dire qu'elle ne s'exécute plus et s'est terminée avec succès ou non) et la propriété IsCompletedSuccessfully renvoie true, uniquement s'il s'est terminé avec succès (en attendant et en recevant le résultat, il n'a pas levé d'exception). Pour les threads d'exécution les plus exigeants, où le développeur souhaite éviter les coûts qui surviennent en mode asynchrone, ces propriétés peuvent être vérifiées avant une opération qui détruit réellement l'objet ValueTask / ValueTask <TResult>, par exemple attendre, .AsTask (). Par exemple, dans l'implémentation de SocketsHttpHandler dans .NET Core 2.1, le code lit à partir de la connexion et reçoit une ValueTask <int>. Si cette opération est effectuée de manière synchrone, nous n'avons pas à nous soucier de la résiliation anticipée de l'opération. Mais s'il s'exécute de manière asynchrone, nous devons raccorder le traitement d'interruption afin que la demande d'interruption rompt la connexion. Comme il s'agit d'un morceau de code très stressant, si le profilage montre la nécessité du petit changement suivant, il peut être structuré comme ceci:


 int bytesRead; { ValueTask<int> readTask = _connection.ReadAsync(buffer); if (readTask.IsCompletedSuccessfully) { bytesRead = readTask.Result; } else { using (_connection.RegisterCancellation()) { bytesRead = await readTask; } } } 

Chaque nouvelle méthode d'API asynchrone doit-elle renvoyer un ValueTask / ValueTask <TResult>?


Pour répondre brièvement: non, par défaut, il vaut toujours la peine de choisir Tâche / Tâche <Résultat>.
Comme souligné ci-dessus, la tâche et la tâche <TResult> sont plus faciles à utiliser correctement que ValueTask et ValueTask <TResult>, et tant que les exigences de performances ne l'emportent pas sur les exigences pratiques, la tâche et la tâche <TResult> sont préférées. De plus, il y a de petits coûts associés au retour de ValueTask <TResult> au lieu de Task <TResult>, c'est-à-dire que les microbenchmarks montrent qu'attendre la tâche <TResult> est plus rapide qu'attendre ValueTask <TResult>. Ainsi, si vous utilisez la mise en cache des tâches, par exemple, votre méthode renvoie Tâche ou Tâche, pour des performances il vaut la peine de rester avec Tâche ou Tâche. Les objets ValueTask / ValueTask <TResult> occupent plusieurs mots en mémoire. Par conséquent, lorsqu'ils sont attendus et que leurs champs sont réservés dans la machine d'état appelant la méthode async, ils y occuperont plus de mémoire.

- ValueTask/ValueTask<TResult> : ) , await, ) , ) , . , / .


ValueTask ValueTask<TResult>?


.NET , Task/Task<TResult>, , ValueTask/ValueTask<TResult>, , . – IAsyncEnumerator<T>, .NET Core 3.0. IEnumerator<T> MoveNext, bool, IAsyncEnumerator<T> MoveNextAsync. , , Task, . , , , ( ), await foreach, ValueTask. , . C# , , , .

Source: https://habr.com/ru/post/fr458828/


All Articles