Pourquoi utiliser Task <T> sur ValueTask <T> en C #?

168

À partir de C # 7.0, les méthodes asynchrones peuvent renvoyer ValueTask <T>. L'explication dit qu'il devrait être utilisé lorsque nous avons un résultat mis en cache ou que nous simulons une asynchrone via un code synchrone. Cependant, je ne comprends toujours pas quel est le problème avec l'utilisation de ValueTask toujours ou en fait pourquoi async / await n'a pas été construit avec un type valeur depuis le début. Quand ValueTask échouerait-il à faire le travail?

Stilgar
la source
7
Je soupçonne que cela a à voir avec les avantages ValueTask<T>(en termes d'allocations) de ne pas se matérialiser pour des opérations qui sont réellement asynchrones (car dans ce cas, il ValueTask<T>faudra toujours une allocation de tas). Il y a aussi la question d' Task<T>avoir beaucoup d'autres supports au sein des bibliothèques.
Jon Skeet
3
Les bibliothèques existantes @JonSkeet sont un problème, mais cela soulève la question si Task aurait-il dû être ValueTask depuis le début? Les avantages peuvent ne pas exister lors de son utilisation pour des trucs asynchrones réels, mais est-ce dangereux?
Stilgar
8
Voir github.com/dotnet/corefx/issues/4708#issuecomment-160658188 pour plus de sagesse que je ne pourrais en transmettre :)
Jon Skeet
3
@JoelMueller l'intrigue s'épaissit :)
Stilgar

Réponses:

245

À partir de la documentation de l'API (souligné par nous)

Les méthodes peuvent renvoyer une instance de ce type de valeur lorsqu'il est probable que le résultat de leurs opérations sera disponible de manière synchrone et lorsque la méthode devrait être invoquée si fréquemment que le coût d'allocation d'un nouveau Task<TResult>pour chaque appel sera prohibitif.

Il y a des compromis à utiliser au ValueTask<TResult>lieu de a Task<TResult>. Par exemple, alors que a ValueTask<TResult>peut aider à éviter une allocation dans le cas où le résultat réussi est disponible de manière synchrone, il contient également deux champs alors que a Task<TResult>comme type de référence est un champ unique. Cela signifie qu'un appel de méthode finit par renvoyer deux champs de données au lieu d'un, ce qui représente plus de données à copier. Cela signifie également que si une méthode qui renvoie l'un de ceux-ci est attendue dans une asyncméthode, la machine à états de cette asyncméthode sera plus grande en raison de la nécessité de stocker la structure composée de deux champs au lieu d'une seule référence.

En outre, pour des utilisations autres que la consommation du résultat d'une opération asynchrone via await, ValueTask<TResult>peut conduire à un modèle de programmation plus alambiqué, qui peut à son tour conduire à plus d'allocations. Par exemple, considérons une méthode qui pourrait renvoyer soit a Task<TResult>avec une tâche mise en cache comme résultat commun, soit a ValueTask<TResult>. Si le consommateur du résultat veut l'utiliser comme un Task<TResult>, comme pour l'utiliser avec dans des méthodes comme Task.WhenAllet Task.WhenAny, le ValueTask<TResult>devrait d'abord être converti en une Task<TResult>utilisation AsTask, ce qui conduit à une allocation qui aurait été évitée si une mise en cache Task<TResult>avait été utilisée en premier lieu.

En tant que tel, le choix par défaut pour toute méthode asynchrone doit être de retourner un Taskou Task<TResult>. Ce n’est que si l’analyse des performances en vaut la peine d’ ValueTask<TResult>utiliser un Task<TResult>.

Stephen Cleary
la source
7
@MattThomas: Cela permet d'économiser une Taskallocation unique (qui est petite et bon marché de nos jours), mais au prix de rendre l' allocation existante de l' appelant plus grande et de doubler la taille de la valeur de retour (impact sur l'allocation du registre). Bien qu'il s'agisse d'un choix clair pour un scénario de lecture tamponné, je ne recommande pas de l'appliquer par défaut à toutes les interfaces.
Stephen Cleary
1
Droite, soit Taskou ValueTaskpeut être utilisé comme type de retour synchrone (avec Task.FromResult). Mais il y a toujours de la valeur (heh) ValueTasksi vous vous attendez à ce que quelque chose soit synchrone. ReadByteAsyncétant un exemple classique. Je crois qu'il a ValueTask été créé principalement pour les nouveaux «canaux» (flux d'octets de bas niveau), probablement également utilisés dans le noyau ASP.NET où les performances comptent vraiment.
Stephen Cleary
1
Oh je sais que lol, je me demandais simplement si vous aviez quelque chose à ajouter à ce commentaire spécifique;)
julealgon
2
Ce PR change- t- il le solde en préférant ValueTask? (ref: blog.marcgravell.com/2019/08/… )
stuartd
2
@stuartd: Pour l'instant, je recommanderais toujours d'utiliser Task<T>par défaut. Ceci est uniquement dû au fait que la plupart des développeurs ne sont pas familiarisés avec les restrictions ValueTask<T>(en particulier, la règle «une seule consommation» et la règle «pas de blocage»). Cela dit, si tous les développeurs de votre équipe sont bons avec ValueTask<T>, alors je recommanderais une directive de préférence au niveau de l'équipe ValueTask<T>.
Stephen Cleary
104

Cependant, je ne comprends toujours pas quel est le problème avec l'utilisation de ValueTask toujours

Les types Struct ne sont pas gratuits. La copie de structures plus grandes que la taille d'une référence peut être plus lente que la copie d'une référence. Le stockage de structures plus volumineuses qu'une référence prend plus de mémoire que le stockage d'une référence. Les structures supérieures à 64 bits peuvent ne pas être enregistrées lorsqu'une référence peut être enregistrée. Les avantages d'une pression de collecte plus faible ne peuvent pas dépasser les coûts.

Les problèmes de performance doivent être abordés avec une discipline d'ingénierie. Fixez-vous des objectifs, mesurez vos progrès par rapport aux objectifs, puis décidez comment modifier le programme si les objectifs ne sont pas atteints, en mesurant en cours de route pour vous assurer que vos changements sont réellement des améliorations.

pourquoi async / await n'a pas été construit avec un type valeur depuis le début.

awaita été ajouté à C # longtemps après que le Task<T>type existe déjà. Il aurait été quelque peu pervers d'inventer un nouveau type alors qu'il en existait déjà un. Et awaita traversé de nombreuses itérations de conception avant de se fixer sur celui qui a été expédié en 2012. Le parfait est l'ennemi du bien; mieux vaut expédier une solution qui fonctionne bien avec l'infrastructure existante, puis s'il y a une demande des utilisateurs, apporter des améliorations plus tard.

Je note également que la nouvelle fonctionnalité permettant aux types fournis par l'utilisateur d'être la sortie d'une méthode générée par le compilateur ajoute un risque et une charge de test considérables. Lorsque les seules choses que vous pouvez retourner sont nulles ou une tâche, l'équipe de test n'a pas à envisager de scénario dans lequel un type absolument fou est retourné. Tester un compilateur signifie déterminer non seulement les programmes que les gens sont susceptibles d'écrire, mais aussi les programmes qu'il est possible d'écrire, car nous voulons que le compilateur compile tous les programmes légaux, pas seulement tous les programmes sensés. C'est cher.

Quelqu'un peut-il expliquer quand ValueTask échouerait à faire le travail?

Le but de la chose est d'améliorer les performances. Il ne fait pas le travail s'il n'améliore pas de manière mesurable et significative les performances. Il n'y a aucune garantie que ce sera le cas.

Eric Lippert
la source
23

ValueTask<T>n'est pas un sous-ensemble de Task<T>, c'est un sur-ensemble .

ValueTask<T>est une union discriminée d'un T et d'un Task<T>, ce qui le rend libre d'allocation pour ReadAsync<T>renvoyer de manière synchrone une valeur T dont il dispose (contrairement à using Task.FromResult<T>, qui doit allouer une Task<T>instance). ValueTask<T>est attendue, donc la plupart des consommations d'instances seront impossibles à distinguer d'un Task<T>.

ValueTask, étant une structure, permet d'écrire des méthodes asynchrones qui n'allouent pas de mémoire lorsqu'elles s'exécutent de manière synchrone sans compromettre la cohérence de l'API. Imaginez avoir une interface avec une méthode de retour de tâche. Chaque classe implémentant cette interface doit renvoyer une tâche même si elle s'exécute de manière synchrone (avec un peu de chance en utilisant Task.FromResult). Vous pouvez bien sûr avoir 2 méthodes différentes sur l'interface, une synchrone et une asynchrone mais cela nécessite 2 implémentations différentes pour éviter «sync over async» et «async over sync».

Ainsi, il vous permet d'écrire une méthode soit asynchrone ou synchrone, plutôt que d'écrire une méthode par ailleurs identique pour chacune. Vous pouvez l'utiliser partout où vous l'utilisez, Task<T>mais cela n'ajoute souvent rien.

Eh bien, cela ajoute une chose: cela ajoute une promesse implicite à l'appelant que la méthode utilise réellement la fonctionnalité supplémentaire ValueTask<T>fournie. Personnellement, je préfère choisir des types de paramètres et de retours qui en disent le plus possible à l'appelant. Ne revenez pas IList<T>si l'énumération ne peut pas fournir de décompte; ne revenez pas IEnumerable<T>si c'est possible. Vos consommateurs ne devraient pas avoir à consulter de documentation pour savoir laquelle de vos méthodes peut raisonnablement être appelée de manière synchrone et laquelle ne le peut pas.

Je ne vois pas les futurs changements de conception comme un argument convaincant. Bien au contraire: si une méthode change sa sémantique, elle doit interrompre la compilation jusqu'à ce que tous les appels à celle-ci soient mis à jour en conséquence. Si cela est considéré comme indésirable (et croyez-moi, je suis sensible au désir de ne pas casser la construction), envisagez la gestion des versions de l'interface.

C'est essentiellement à cela que sert le typage fort.

Si certains des programmeurs qui conçoivent des méthodes asynchrones dans votre boutique ne sont pas en mesure de prendre des décisions éclairées, il peut être utile d'affecter un mentor senior à chacun de ces programmeurs moins expérimentés et de faire une révision hebdomadaire du code. S'ils se trompent, expliquez pourquoi cela devrait être fait différemment. C'est une surcharge pour les seniors, mais cela amènera les juniors à la vitesse beaucoup plus rapidement que de simplement les jeter dans le grand bain et de leur donner une règle arbitraire à suivre.

Si le type qui a écrit la méthode ne sait pas si elle peut être appelée de manière synchrone, qui sur Terre le fait?!

Si vous avez autant de programmeurs inexpérimentés qui écrivent des méthodes asynchrones, ces mêmes personnes les appellent-elles également? Sont-ils qualifiés pour déterminer eux-mêmes lesquels sont sûrs d'appeler async, ou vont-ils commencer à appliquer une règle tout aussi arbitraire à la façon dont ils appellent ces choses?

Le problème ici n'est pas vos types de retour, ce sont les programmeurs qui sont placés dans des rôles pour lesquels ils ne sont pas prêts. Cela a dû arriver pour une raison, donc je suis sûr que cela ne peut pas être trivial à réparer. Décrire ce n'est certainement pas une solution. Mais chercher un moyen de faire passer le problème au-delà du compilateur n'est pas non plus une solution.

15ee8f99-57ff-4f92-890c-b56153
la source
4
Si je vois une méthode revenir ValueTask<T>, je suppose que le gars qui a écrit la méthode l'a fait parce que la méthode utilise en fait la fonctionnalité qui ValueTask<T>ajoute. Je ne comprends pas pourquoi vous pensez qu'il est souhaitable que toutes vos méthodes aient le même type de retour. Quel est le but là-bas?
15ee8f99-57ff-4f92-890c-b56153
2
L'objectif 1 serait de permettre une mise en œuvre modifiée. Que faire si je ne mets pas en cache le résultat maintenant mais que j'ajoute du code de mise en cache plus tard? L'objectif 2 serait d'avoir une ligne directrice claire pour une équipe où tous les membres ne comprennent pas quand ValueTask est utile. Faites-le toujours s'il n'y a aucun mal à l'utiliser.
Stilgar
6
À mon avis, c'est presque comme si toutes vos méthodes revenaient, objectcar peut-être qu'un jour vous voudrez qu'elles reviennent intau lieu de string.
15ee8f99-57ff-4f92-890c-b56153
3
Peut-être, mais si vous posez la question "pourquoi renverriez-vous jamais une chaîne au lieu d'un objet", je peux facilement y répondre en soulignant le manque de sécurité de type. Les raisons de ne pas utiliser ValueTask partout semblent être beaucoup plus subtiles.
Stilgar
3
@Stilgar Une chose que je dirais: les problèmes subtils devraient vous inquiéter plus que les plus évidents.
15ee8f99-57ff-4f92-890c-b56153
20

Il y a quelques changements dans .Net Core 2.1 . À partir de .net core 2.1 ValueTask peut représenter non seulement les actions terminées synchrones mais aussi l'asynchrone terminée. De plus, nous recevons du type non générique ValueTask.

Je laisserai le commentaire de Stephen Toub qui est lié à votre question:

Nous devons encore formaliser les conseils, mais je pense que ce sera quelque chose comme ça pour la surface de l'API publique:

  • La tâche offre la plus grande convivialité.

  • ValueTask fournit le plus d'options d'optimisation des performances.

  • Si vous écrivez une interface / méthode virtuelle que d'autres remplaceront, ValueTask est le bon choix par défaut.

  • Si vous vous attendez à ce que l'API soit utilisée sur des chemins actifs où les allocations seront importantes, ValueTask est un bon choix.

  • Sinon, lorsque les performances ne sont pas essentielles, choisissez Tâche par défaut, car il offre de meilleures garanties et une meilleure convivialité.

Du point de vue de l'implémentation, la plupart des instances ValueTask renvoyées seront toujours sauvegardées par Task.

La fonctionnalité peut être utilisée non seulement dans le .net core 2.1. Vous pourrez l'utiliser avec le package System.Threading.Tasks.Extensions .

unsafePtr
la source
1
Plus de Stephen aujourd'hui: blogs.msdn.microsoft.com/dotnet/2018/11/07/…
Mike Cheel