Chemins chauds sans allocation : comment le client .NET de Zaris utilise ValueTask
La plupart du temps, Task<T> est exactement le bon type de retour pour une méthode asynchrone, et vous ne devriez pas y réfléchir à deux fois. Mais il y a un endroit précis où il vous coûte discrètement : un chemin chaud qui se termine de façon synchrone, appelé des millions de fois.
Voici le mécanisme. Task<T> est un type référence. Chaque appel qui en renvoie un alloue un objet Task sur le tas — même lorsque l'opération se termine de façon synchrone. Un succès de cache servi directement depuis un tampon local, un awaitable déjà terminé : rien de tout cela ne requiert une vraie asynchronie, mais vous payez quand même un objet sur le tas pour porter le résultat. Une allocation n'est rien. Au rythme d'appel d'un client à haut débit — une boucle serrée effectuant des millions de petits Get et Put — c'est un flux régulier de petites allocations sur le tas, et ce flux alimente le ramasse-miettes. Plus de collectes gen-0, plus de CPU dépensé en ramasse-miettes au lieu de votre travail, plus de gigue dans vos latences de queue.
Le client .NET de Zaris s'attaque à cela exactement là où ça compte et nulle part ailleurs. Cet article porte sur cette décision : quelles méthodes ont changé, pourquoi, les règles que vous devez suivre pour les utiliser correctement, et — délibérément — quelles méthodes nous avons laissées intactes.
Ce qui a changé
Les opérations mono-clé de chemin chaud du client renvoient désormais ValueTask / ValueTask<T> au lieu de Task / Task<T>. Il s'agit de Get, Put, Delete, Expire, Persist, TTL, et du constructeur de chaîne fluide.
ValueTask<T> est une struct. Lorsque l'opération se termine de façon synchrone, elle enveloppe le résultat déjà connu en ligne — zéro allocation sur le tas sur ce chemin rapide. Lorsque l'opération est véritablement asynchrone, elle se replie sur l'enveloppement d'un vrai Task sous le capot, de sorte que vous ne perdez rien dans le cas asynchrone. Vous obtenez gratuitement le chemin sans allocation quand le travail est déjà fait, et un comportement asynchrone correct quand il ne l'est pas.
Voici la forme du changement :
// Before — a Task<T> is allocated on every call, even a synchronous cache hit.
Task<ReadResult<Order>> GetAsync<T>(string key);
Task PutAsync<T>(string key, T value);
// After — a struct that allocates nothing when the result is already available.
ValueTask<ReadResult<Order>> GetAsync<T>(string key);
ValueTask PutAsync<T>(string key, T value);
Pour le cas de très loin le plus courant, votre code appelant ne change pas du tout — vous vous contentez de l'await :
// Reads exactly as before; allocates nothing when the value is served locally.
ReadResult<Order> result = await client.GetAsync<Order>("order:42");
if (result.Found)
Process(result.Value);
await client.ExpireAsync("session:abc", TimeSpan.FromMinutes(30));
Les règles que vous devez suivre
ValueTask achète sa performance en renonçant à une partie de la flexibilité de Task, et il impose ce compromis par des règles. Enfreignez-les et vous obtenez un comportement indéfini, non une exception amicale. Trois choses à intérioriser :
- Attendez-le au plus une fois. Un
ValueTaskpeut envelopper un objet mis en pool ou réutilisé. L'attendre deux fois — ou l'attendre après avoir déjà consommé le résultat — n'est pas autorisé et peut renvoyer n'importe quoi ou lever une exception. - Ne touchez pas à
.Resultavant qu'il soit terminé. Contrairement àTask, lire le résultat d'unValueTaskavant qu'il ait fini n'est pas une attente bloquante — c'est simplement invalide. - Besoin de l'utiliser plus d'une fois ? Convertissez d'abord en
Task. Si vous devez stocker le résultat, l'attendre à plusieurs endroits ou en exécuter plusieurs en concurrence, appelezAsTask()et travaillez avec leTaskà partir de là.
Cette dernière est l'échappatoire pratique. Dès l'instant où vous voulez conserver l'opération en attente, faites ceci :
// Wrong: awaiting the same ValueTask twice.
ValueTask<ReadResult<Order>> pending = client.GetAsync<Order>("order:42");
var a = await pending;
var b = await pending; // undefined behavior — do not do this.
// Right: materialize a Task when you need to reuse or fan out.
Task<ReadResult<Order>> t = client.GetAsync<Order>("order:42").AsTask();
var first = await t;
var second = await t; // fine — Task is safe to await repeatedly.
// Right: running several concurrently — convert each, then combine.
Task<ReadResult<Order>> a = client.GetAsync<Order>("order:1").AsTask();
Task<ReadResult<Order>> b = client.GetAsync<Order>("order:2").AsTask();
await Task.WhenAll(a, b);
L'habitude la plus simple qui vous garde en sécurité : await le ValueTask immédiatement, là même où vous l'appelez. Faites cela et vous n'enfreindrez jamais aucune des règles. Ce n'est que lorsque vous voulez délibérément stocker ou réutiliser l'opération que vous recourez à AsTask() — et vous voilà de retour en terrain Task familier.
Une rupture unique et délibérée
Changer le type de retour d'une méthode de Task<T> à ValueTask<T> change sa signature, ce qui en fait un changement d'API cassant au niveau binaire. Nous l'avons fait exprès, et nous l'avons fait une fois, car c'est précisément le genre de changement que vous ne voulez pas faire deux fois. Recompilez contre le nouveau client et votre source compile presque certainement sans changement — les sites await sont identiques ; c'est la référence d'assembly compilée qui doit être recompilée.
Point crucial, le protocole de transport n'a pas changé. C'est purement une optimisation côté client, en processus, concernant la façon dont les résultats reviennent à travers votre propre code. Les serveurs ne sont pas affectés — il n'y a pas de redéploiement de cluster requis pour ce changement, et un client compilé contre les nouvelles signatures parle aux mêmes nœuds via le même protocole qu'avant.
Ce que nous avons laissé sur Task — et pourquoi
L'autre moitié de la décision est la moitié que nous n'avons pas changée. Les opérations groupées, les transactions et les scans restent délibérément sur Task. Ce ne sont pas des primitives de chemin chaud, par appel — ce sont des opérations plus grandes, moins fréquentes, à granularité plus grossière. Une seule écriture groupée ou une transaction fait déjà un travail substantiel, donc une allocation de Task amortie sur l'ensemble est véritablement négligeable. Et ce sont exactement les opérations où la flexibilité de Task fait ses preuves : vous voulez souvent les stocker, les attendre à plus d'un endroit, ou en combiner plusieurs avec Task.WhenAll. Leur imposer la discipline d'attente unique de ValueTask échangerait une véritable valeur ergonomique contre une allocation que vous ne remarqueriez jamais.
C'est toute la leçon, et elle se généralise au-delà de ce seul client. Les micro-allocations sur un chemin véritablement chaud sont un coût réel et mesurable dans un système à haut débit — cela vaut la peine de les éliminer par ingénierie. Mais ValueTask est un outil tranchant avec des règles attachées, et son gain n'apparaît que là où le rythme d'appel est élevé et où la complétion synchrone est courante. Utilisez-le exactement là — le chemin chaud mono-clé — et laissez les opérations flexibles et tolérantes aux allocations sur Task, là où la flexibilité vaut plus qu'une allocation économisée. Le bon outil, au seul endroit où c'est le bon outil.