ValueTask - pourquoi, pourquoi et comment?

Préface à la traduction


Contrairement aux articles scientifiques, les articles de ce type sont difficiles à traduire "près du texte", et beaucoup d'adaptation doit être faite. Pour cette raison, je m'excuse pour certaines libertés, pour ma part, dans le traitement du texte de l'article d'origine. Je suis guidé par un seul objectif: rendre la traduction compréhensible, même si par endroits elle s'écarte fortement de l'article d'origine. Je serais reconnaissant pour les critiques constructives et les corrections / ajouts à la traduction.


Présentation


L'espace de noms System.Threading.Tasks et la classe Task ont été introduits pour la première fois dans le .NET Framework 4. Depuis lors, ce type, et sa classe dérivée Task<TResult> , sont fermement entrés dans la pratique de la programmation en .NET et sont devenus des aspects clés du modèle asynchrone. implémenté en C # 5, avec son async/await . Dans cet article, je vais parler des nouveaux types de ValueTask/ValueTask<TResult> qui ont été introduits pour améliorer les performances du code asynchrone, dans les cas où la surcharge de mémoire joue un rôle clé.



Tâche


Task sert plusieurs objectifs, mais le principal est la "promesse" - un objet qui représente la capacité d'attendre la fin d'une opération. Vous lancez l'opération et obtenez la Task . Cette Task sera terminée lorsque l'opération elle-même sera terminée. Dans ce cas, il existe trois options:


  1. L'opération se termine de manière synchrone dans le thread initiateur. Par exemple, lors de l'accès à certaines données qui sont déjà dans le tampon .
  2. L'opération est effectuée de manière asynchrone, mais parvient à se terminer au moment où l' initiateur reçoit la Task . Par exemple, lors d'un accès rapide à des données qui n'ont pas encore été mises en mémoire tampon
  3. L'opération est effectuée de manière asynchrone et se termine une fois que l' initiateur a reçu la Task Un exemple est la réception de données sur un réseau .

Pour obtenir le résultat d'un appel asynchrone, le client peut soit bloquer le thread appelant en attendant la fin, ce qui contredit souvent l'idée d'asynchronie, soit fournir une méthode de rappel qui sera exécutée à la fin de l'opération asynchrone. Le modèle de rappel dans .NET 4 a été présenté explicitement, à l'aide de la méthode ContinueWith d'un objet de la classe Task , qui a reçu un délégué qui a été appelé à la fin de l'opération asynchrone.


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

Avec .NET Frmaework 4.5 et C # 5, l'obtention du résultat d'une opération asynchrone a été simplifiée en introduisant les mots clés async/await et le mécanisme derrière eux. Ce mécanisme, le code généré, est capable d'optimiser tous les cas mentionnés ci-dessus, en gérant correctement l'achèvement malgré le chemin dans lequel il a été atteint.


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

La classe Task est assez flexible et présente plusieurs avantages. Par exemple, vous pouvez "attendre" plusieurs fois un objet de cette classe, vous pouvez vous attendre au résultat de manière compétitive, par n'importe quel nombre de consommateurs. Les instances d'une classe peuvent être stockées dans un dictionnaire pour n'importe quel nombre d'appels ultérieurs, dans le but d '«attendre» à l'avenir. Les scénarios décrits vous permettent de considérer les objets Task comme une sorte de cache de résultats obtenus de manière asynchrone. De plus, Task offre la possibilité de bloquer le thread en attente jusqu'à la fin de l'opération si le script l'exige. Il y a aussi le soi-disant. combinateurs pour diverses stratégies pour attendre la fin des ensembles de tâches, par exemple, "Task.WhenAny" - attente asynchrone pour la fin de la première des nombreuses tâches.


Mais, néanmoins, le cas d'utilisation le plus courant consiste simplement à démarrer une opération asynchrone et à attendre le résultat de son exécution. Un cas aussi simple, assez courant, ne nécessite pas la flexibilité ci-dessus:


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

Ceci est très similaire à la façon dont nous écrivons du code synchrone (par exemple TResult result = SomeOperation(); ). Cette option est naturellement traduite en async/await .


De plus, pour tous ses avantages, le type de Task a un défaut potentiel. Task est une classe, ce qui signifie que chaque opération qui crée une instance d'une tâche alloue un objet sur le tas. Plus nous créons d'objets, plus le GC requiert de travail et plus les ressources sont dépensées pour le travail du ramasse-miettes, ressources qui pourraient être utilisées à d'autres fins. Cela devient un problème évident pour le code, dans lequel, d'une part, les instances de Task sont créées souvent, et d'autre part, ce qui augmente les exigences de débit et de performances.


Le runtime et les bibliothèques principales, dans de nombreuses situations, parviennent à atténuer cet effet. Par exemple, si vous écrivez une méthode comme celle ci-dessous:


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

et, le plus souvent, il y aura suffisamment d'espace dans le tampon, l'opération se terminera de manière synchrone. Si c'est le cas, la tâche renvoyée n'a rien de spécial, il n'y a pas de valeur de retour et l'opération est déjà terminée. En d'autres termes, nous avons affaire à Task , l'équivalent d'une opération void synchrone. Dans de telles situations, le runtime met simplement en cache l'objet Task et l'utilise à chaque fois comme résultat pour toute async Task - une méthode qui se termine de manière synchrone ( Task.ComletedTask ). Un autre exemple, disons que vous écrivez:


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

Supposons, de la même manière, que dans la plupart des cas, il y a des données dans le tampon. La méthode vérifie _bufferedCount , voit que la variable est supérieure à zéro et renvoie true . Ce n'est que si, au moment de la vérification, les données n'étaient pas mises en mémoire tampon, une opération asynchrone est requise. Quoi qu'il en soit, il n'y a que deux résultats logiques possibles ( true et false ), et seulement deux états de retour possibles via la Task<bool> . En fonction de l'achèvement synchrone ou asynchrone, mais avant de quitter la méthode, le runtime met en cache deux instances de Task<bool> (une pour true et une pour false ), et renvoie celle souhaitée, en évitant les allocations supplémentaires. La seule option lorsque vous devez créer un nouvel objet Task<bool> est un cas d'exécution asynchrone, qui se termine après le "retour". Dans ce cas, la méthode doit créer un nouvel objet Task<bool> , car au moment de la sortie du procédé, le résultat de l'achèvement de l'opération n'est pas encore connu. L'objet retourné doit être unique, car il stockera finalement le résultat de l'opération asynchrone.


Il existe d'autres exemples de mise en cache similaire lors de l'exécution. Mais une telle stratégie n'est pas applicable partout. Par exemple, la méthode:


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

se termine également souvent de manière synchrone. Mais, contrairement à l'exemple précédent, cette méthode renvoie un résultat entier qui a environ quatre milliards de valeurs possibles. Pour mettre en cache la Task<int> , dans cette situation, des centaines de gigaoctets de mémoire seraient nécessaires. L'environnement ici prend également en charge un petit cache pour la Task<int> , pour plusieurs petites valeurs. Ainsi, par exemple, si l'opération se termine de manière synchrone (les données sont présentes dans le tampon), avec un résultat de 4, le cache sera utilisé. Mais si le résultat, bien que synchrone, l'achèvement est 42, un nouvel objet Task<int> sera créé, semblable à l'appel de Task.FromResult(42) .


De nombreuses implémentations de bibliothèque tentent d'atténuer ces situations en prenant en charge leurs propres caches. Un exemple est la surcharge de MemoryStream.ReadAsync . Cette opération, introduite dans le .NET Framework 4.5, se termine toujours de manière synchrone, car ce n'est qu'une lecture de mémoire. ReadAsync renvoie une Task<int> où le résultat entier représente le nombre d'octets lus. Très souvent, dans le code, une situation se produit lorsque ReadAsync utilisé dans une boucle. De plus, s'il y a les symptômes suivants:


  • Le nombre d'octets demandés ne change pas pour la plupart des itérations de la boucle;
  • Dans la plupart des itérations, ReadAsync peut lire le nombre d'octets demandé.

En d'autres ReadAsync , pour les appels répétés, ReadAsync s'exécute de manière synchrone et renvoie un objet Task<int> , avec le même résultat d'une itération à l'autre. Il est logique que MemoryStream cache la dernière tâche terminée avec succès, et pour tous les appels suivants, si le nouveau résultat correspond au précédent, il renvoie une instance du cache. Si le résultat ne correspond pas, alors Task.FromResult utilisé pour créer une nouvelle instance, qui, à son tour, est également mise en cache avant de revenir.


Mais, néanmoins, il existe de nombreux cas où une opération est forcée de créer de nouveaux objets Task<TResult> , même lorsqu'elle est terminée de manière synchrone.


ValueTask <TResult> et achèvement synchrone


Tout cela a finalement motivé l'introduction d'un nouveau type de ValueTask<TResult> dans .NET Core 2.0. En outre, via le package nuget System.Threading.Tasks.Extensions , ce type a été rendu disponible dans d'autres versions de .NET.


ValueTask<TResult> été introduit dans .NET Core 2.0 en tant que structure capable d' TResult ou Task<TResult> . Cela signifie que les objets de ce type peuvent être renvoyés à partir de la méthode async . Le premier avantage de l'introduction de ce type est immédiatement visible: si la méthode s'est terminée avec succès et de manière synchrone, il n'est pas nécessaire de créer quelque chose sur le tas, juste assez pour créer une instance de ValueTask<TResult> avec la valeur de résultat. Seulement si la méthode se termine de manière asynchrone, nous devons créer une Task<TResult> . Dans ce cas, ValueTask<TResult> utilisé comme un wrapper sur Task<TResult> . La décision de rendre ValueTask<TResult> capable d'agréger la Task<TResult> été prise dans le but d'optimiser: en cas de succès et en cas d'échec, la méthode asynchrone crée la Task<TResult> , du point de vue de l'optimisation de la mémoire, il est préférable d'agréger la Task<TResult> objet Task<TResult> lui-même Task<TResult> que de conserver des champs supplémentaires dans ValueTask<TResult> pour divers cas d'achèvement (par exemple, pour stocker une exception).


Compte tenu de ce qui précède, il n'est plus nécessaire de mettre en cache dans des méthodes telles que MemoryStream.ReadAsync ci-dessus, mais peut être implémenté comme suit:


 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 terminaison asynchrone


Avoir la possibilité d'écrire des méthodes asynchrones qui ne nécessitent pas d'allocations de mémoire supplémentaires pour le résultat, avec achèvement synchrone, est vraiment un gros plus. Comme indiqué ci-dessus, c'était l'objectif principal de l'introduction du nouveau type ValueTask<TResult> dans .NET Core 2.0. Toutes les nouvelles méthodes qui devraient être utilisées sur les "routes chaudes" utilisent désormais ValueTask<TResult> au lieu de Task<TResult> comme type de retour. Par exemple, une nouvelle surcharge de la méthode ReadAsync pour Stream , dans .NET Core 2.1 (qui prend Memory<byte> au lieu de byte[] comme paramètre), retourne une instance de ValueTask<int> . Cela a permis de réduire considérablement le nombre d'allocations lorsque vous travaillez avec des flux (très souvent, la méthode ReadAsync termine de manière synchrone, comme dans l'exemple avec MemoryStream ).


Cependant, lors du développement de services à large bande passante, dans lesquels la terminaison asynchrone n'est pas rare, nous devons faire de notre mieux pour éviter des allocations supplémentaires.


Comme mentionné précédemment, dans le modèle async/await , toute opération qui se termine de manière asynchrone doit retourner un objet unique afin d'attendre la fin. Unique car il servira de canal pour effectuer des rappels. Notez, cependant, que cette construction ne dit rien sur la question de savoir si l'objet d'attente renvoyé peut être réutilisé après la fin de l'opération asynchrone. Si un objet peut être réutilisé, l'API peut gérer un pool pour ces types d'objets. Mais, dans ce cas, ce pool ne peut pas prendre en charge l'accès simultané - un objet du pool passera de l'état "terminé" à l'état "non terminé" et vice versa.


Pour prendre en charge la possibilité de travailler avec de tels pools, l' IValueTaskSource<TResult> été ajoutée à .NET Core 2.1 et la ValueTask<TResult> été développée: désormais, les objets de ce type peuvent encapsuler non seulement des objets de type TResult ou Task<TResult> , mais aussi instances de IValueTaskSource<TResult> . La nouvelle interface fournit des fonctionnalités de base qui permettent ValueTask<TResult> objets ValueTask<TResult> de fonctionner avec IValueTaskSource<TResult> de la même manière qu'avec la Task<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); } 

GetStatus destiné à être utilisé dans la ValueTask<TResult>.IsCompleted/IsCompletedSuccessfully - vous permet de savoir si l'opération s'est terminée ou non (avec succès ou non). OnCompleted utilisé dans ValueTask<TResult> pour déclencher un rappel. GetResult utilisé pour obtenir le résultat ou pour lever une exception.


Il est peu probable que la plupart des développeurs aient besoin de gérer l' IValueTaskSource<TResult> , car les méthodes asynchrones, lorsqu'elles sont renvoyées, la cachent derrière l' ValueTask<TResult> . L'interface elle-même est principalement destinée à ceux qui développent des API hautes performances et cherche à éviter un travail inutile avec un groupe.


Dans .NET Core 2.1, il existe plusieurs exemples de ce type d'API. Le plus célèbre d'entre eux est les nouvelles surcharges des méthodes Socket.ReceiveAsync et Socket.SendAsync . Par exemple:


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

Les objets de type ValueTask<int> sont utilisés comme valeur de retour.
Si la méthode se termine de manière synchrone, elle retourne une ValueTask<int> avec la valeur correspondante:


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

Si l'opération se termine de manière asynchrone, un objet mis en cache est utilisé qui implémente l' IValueTaskSource<TResult> :


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

L'implémentation Socket prend en charge un objet mis en cache pour la réception et un autre pour l'envoi de données, à condition que chacun d'eux soit utilisé sans concurrence (non, par exemple, l'envoi de données concurrentiel). Cette stratégie réduit la quantité de mémoire supplémentaire allouée, même en cas d'exécution asynchrone.
L'optimisation décrite de Socket dans .NET Core 2.1 a eu un impact positif sur les performances de NetworkStream . Sa surcharge est la méthode ReadAsync de la classe Stream :


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

délègue simplement le travail à la méthode Socket.ReceiveAsync . L'augmentation de l'efficacité de la méthode socket, en termes de travail avec la mémoire, augmente l'efficacité de la méthode NetworkStream .


ValueTask non générique


Plus tôt, j'ai noté à plusieurs reprises que l'objectif initial de ValueTask<T> , dans .NET Core 2.0, était d'optimiser les cas d'achèvement synchrone des méthodes avec un résultat "non vide". Cela signifie qu'il n'était pas nécessaire d'avoir une ValueTask non typée: en cas de complétion synchrone, les méthodes utilisent un singleton via la propriété Task.CompletedTask , et le runtime pour les méthodes de async Task est également implicitement reçu.


Mais, avec l'avènement de la capacité à éviter les allocations inutiles et avec l'exécution asynchrone, le besoin d'une ValueTask non typée ValueTask redevenu pertinent. Pour cette raison, dans .NET Core 2.1, nous avons introduit ValueTask et IValueTaskSource non IValueTaskSource . Ce sont des analogues des types génériques correspondants, et sont utilisés de la même manière, mais pour les méthodes avec un retour vide ( void ).


Implémenter IValueTaskSource / IValueTaskSource <T>


La plupart des développeurs n'auront pas besoin d'implémenter ces interfaces. Et leur mise en œuvre n'est pas une tâche facile. Si vous décidez que vous devez les implémenter vous-même, alors, dans .NET Core 2.1, plusieurs implémentations peuvent servir d'exemples:



Pour simplifier ces tâches (implémentations de IValueTaskSource / IValueTaskSource<T> ), nous prévoyons d'introduire le type ManualResetValueTaskSourceCore<TResult> dans .NET Core 3.0. Cette structure encapsulera toute la logique nécessaire. L' ManualResetValueTaskSourceCore<TResult> peut être utilisée dans un autre objet qui implémente IValueTaskSource<TResult> et / ou IValueTaskSource et lui déléguer la plupart du travail. Vous pouvez en savoir plus à ce sujet sur ttps: //github.com/dotnet/corefx/issues/32664.


Le bon modèle pour utiliser ValueTasks


Même un examen superficiel ValueTask que ValueTask et ValueTask<TResult> plus limités que Task et Task<TResult> . Et cela est normal, voire souhaitable, car leur objectif principal est d'attendre la fin de l'exécution asynchrone.


En particulier, des limitations importantes surviennent du fait que ValueTask et ValueTask<TResult> peuvent agréger des objets réutilisables. En général, les opérations suivantes * NE doivent JAMAIS être effectuées lors de l'utilisation de ValueTask / ValueTask<TResult> * ( permettez-moi de reformuler par "Jamais" *):


  • N'utilisez jamais le même objet ValueTask / ValueTask<TResult> plusieurs reprises

Motivation: Les instances de Task et de Task<TResult> ne passent jamais de l'état "terminé" à l'état "incomplet", nous pouvons les utiliser pour attendre le résultat autant de fois que nous le souhaitons - après l'achèvement, nous obtiendrons toujours le même résultat. Au contraire, depuis ValueTask / ValueTask<TResult> , ils peuvent agir comme des wrappers sur les objets réutilisés, ce qui signifie que leur état peut changer, car l'état des objets réutilisés change par définition - pour passer de "terminé" à "incomplet" et vice versa.


  • Ne ValueTask jamais à ValueTask / ValueTask&lt;TResult&gt; en mode compétitif.

Motivation: un objet encapsulé s'attend à fonctionner avec un seul rappel, à partir d'un seul consommateur à la fois, et essayer de concurrencer anticipé peut facilement conduire à des conditions de concurrence et à de subtiles erreurs de programmation. Attentes concurrentielles, c'est l'une des options décrites ci-dessus aux attentes multiples . Notez que la Task / Task<TResult> permet un nombre quelconque d'attentes concurrentielles.


  • N'utilisez jamais .GetAwaiter().GetResult() tant que l'opération .GetAwaiter().GetResult() terminée .

Motivation: les implémentations de IValueTaskSource / IValueTaskSource<TResult> ne doivent pas prendre en charge le verrouillage tant que l'opération n'est pas terminée. Le blocage, en effet, conduit à une condition de concurrence, il est peu probable que ce soit le comportement attendu de la part du consommateur. Alors que Task / Task<TResult> vous permet de le faire, bloquant ainsi le thread appelant jusqu'à la fin de l'opération.


Mais que se passe-t-il si, néanmoins, vous devez effectuer l'une des opérations décrites ci-dessus et que la méthode appelée renvoie des instances de ValueTask / ValueTask<TResult> ? Dans de tels cas, ValueTask / ValueTask<TResult> fournit la méthode .AsTask() . En appelant cette méthode, vous obtiendrez une instance de Task / Task<TResult> , et vous pouvez déjà effectuer l'opération nécessaire avec elle. La réutilisation de l'objet d'origine après avoir appelé .AsTask() n'est pas autorisée .


: ValueTask / ValueTask<TResult> , ( await ) (, .ConfigureAwait(false) ), .AsTask() , ValueTask / ValueTask<TResult> .


 // Given this ValueTask<int>-returning method… 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(); ... // storing the instance into a local makes it much more likely it'll be misused, // but it could still be ok // BAD: awaits multiple times ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt; // BAD: awaits concurrently (and, by definition then, multiple times) ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt); // BAD: uses GetAwaiter().GetResult() when it's not known to be done ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = vt.GetAwaiter().GetResult(); 

, "", , ( , ).


ValueTask / ValueTask<TResult> , . , IsCompleted true , ( , ), — false , IsCompletedSuccessfully true . " " , , , , , . await / .AsTask() .Result . , SocketsHttpHandler .NET Core 2.1, .ReadAsync , ValueTask<int> . , , , . , .. . Parce que , , , , :


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

, .. ValueTask<int> , .Result , await , .


API ValueTask / ValueTask<TResult>?


, . Task / ValueTask<TResult> .


, Task / Task<TResult> . , "" / , Task / Task<TResult> . , , ValueTask<TResult> Task<TResult> : , , await Task<TResult> ValueTask<TResult> . , (, API Task Task<bool> ), , , Task ( Task<bool> ). , ValueTask / ValueTask<TResult> . , async-, ValueTask / ValueTask<TResult> , .


, ValueTask / ValueTask<TResult> , :


  1. , API ,
  2. API ,
  3. , , , .

, abstract / virtual , , / ?


Et ensuite?


.NET, API, Task / Task<TResult> . , , API c ValueTask / ValueTask<TResult> , . IAsyncEnumerator<T> , .NET Core 3.0. IEnumerator<T> MoveNext , . — IAsyncEnumerator<T> MoveNextAsync . , Task<bool> , , . , , , ( ), , , await foreach -, , MoveNextAsync , ValueTask<bool> . , , , " " , . , C# , .


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


All Articles