Rutas calientes sin asignaciones: cómo el cliente .NET de Zaris usa ValueTask
La mayor parte del tiempo, Task<T> es exactamente el tipo de retorno correcto para un método asíncrono, y no deberías pensártelo dos veces. Pero hay un lugar específico donde te cuesta en silencio: una ruta caliente que se completa de forma síncrona, llamada millones de veces.
Aquí está el mecanismo. Task<T> es un tipo de referencia. Cada llamada que devuelve uno asigna un objeto Task en el heap — incluso cuando la operación se completa de forma síncrona. Un hit de caché servido directamente desde un búfer local, un awaitable que ya está completado: nada de eso requiere asincronía real, pero aún pagas por un objeto en el heap para transportar el resultado. Una asignación no es nada. A la tasa de llamadas de un cliente de alto rendimiento — un bucle apretado haciendo millones de pequeños Gets y Puts — eso es un flujo constante de pequeñas asignaciones en el heap, y ese flujo alimenta al recolector de basura. Más recolecciones de gen-0, más CPU gastada en GC en lugar de tu trabajo, más jitter en tus latencias de cola.
El cliente .NET de Zaris ataca esto exactamente donde importa y en ningún otro sitio. Este post trata de esa decisión: qué métodos cambiaron, por qué, las reglas que tienes que seguir para usarlos correctamente, y — deliberadamente — qué métodos dejamos intactos.
Qué cambió
Las operaciones de clave única de ruta caliente del cliente ahora devuelven ValueTask / ValueTask<T> en lugar de Task / Task<T>. Eso es Get, Put, Delete, Expire, Persist, TTL, y el fluent chain builder.
ValueTask<T> es un struct. Cuando la operación se completa de forma síncrona, envuelve el resultado ya conocido en línea — cero asignación en heap en ese camino rápido. Cuando la operación es genuinamente asíncrona, recurre a envolver un Task real por debajo, así que no pierdes nada en el caso asíncrono. Obtienes el camino libre de asignaciones gratis cuando el trabajo ya está hecho, y el comportamiento asíncrono correcto cuando no lo está.
Aquí está la forma del cambio:
// 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);
Para el caso abrumadoramente común, tu código que llama no cambia en absoluto — simplemente lo haces 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));
Las reglas que tienes que seguir
ValueTask compra su rendimiento renunciando a parte de la flexibilidad de Task, y hace cumplir ese intercambio con reglas. Rómpelas y obtienes comportamiento indefinido, no una excepción amable. Tres cosas a interiorizar:
- Haz
awaitcomo mucho una vez. UnValueTaskpuede envolver un objeto agrupado o reutilizado. Hacerawaitdos veces — o hacerawaitdespués de que ya hayas consumido el resultado — no está permitido y puede devolver basura o lanzar una excepción. - No toques
.Resultantes de que esté completo. A diferencia deTask, leer el resultado de unValueTaskantes de que haya terminado no es una espera bloqueante — es simplemente inválido. - ¿Lo necesitas más de una vez? Convierte a un
Taskprimero. Si tienes que almacenar el resultado, hacerleawaiten varios lugares, o ejecutar varios de ellos de forma concurrente, llama aAsTask()y trabaja con elTaska partir de ahí.
Ese último es la vía de escape práctica. En el momento en que quieras aferrarte a la operación pendiente, haz esto:
// 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);
El hábito más simple que te mantiene a salvo: haz await del ValueTask inmediatamente, justo donde lo llamas. Haz eso y nunca tropiezas con ninguna de las reglas. Solo cuando quieras deliberadamente almacenar o reutilizar la operación recurres a AsTask() — y entonces estás de vuelta en el territorio familiar de Task.
Un cambio incompatible, único y deliberado
Cambiar el tipo de retorno de un método de Task<T> a ValueTask<T> cambia su firma, lo que hace de esto un cambio de API incompatible a nivel binario. Lo hicimos a propósito, y lo hicimos una sola vez, porque es precisamente la clase de cambio que no quieres hacer dos veces. Recompila contra el nuevo cliente y tu código fuente casi con seguridad compila sin cambios — los sitios de await son idénticos; es la referencia del ensamblado compilado la que hay que recompilar.
Crucialmente, el protocolo de cable no cambió. Esto es puramente una optimización del lado del cliente, en proceso, sobre cómo fluyen los resultados de vuelta a través de tu propio código. Los servidores no se ven afectados — no se requiere redespliegue del clúster para este cambio, y un cliente compilado contra las nuevas firmas habla con los mismos nodos por el mismo protocolo que antes.
Qué dejamos en Task — y por qué
La otra mitad de la decisión es la mitad que no cambiamos. Las operaciones de bulk, las transacciones y los scans permanecen deliberadamente en Task. Estas no son primitivas de ruta caliente, por llamada — son operaciones más grandes, menos frecuentes, de grano más grueso. Una única escritura de bulk o una transacción ya hace trabajo sustancial, así que una asignación de Task amortizada a lo largo de ella es genuinamente despreciable. Y estas son exactamente las operaciones donde la flexibilidad de Task se gana su sitio: a menudo quieres almacenarlas, hacerles await en más de un lugar, o combinar varias con Task.WhenAll. Forzarles la disciplina de un solo await de ValueTask intercambiaría valor ergonómico real por una asignación que nunca notarías.
Esa es toda la lección, y se generaliza más allá de este único cliente. Las microasignaciones en una ruta genuinamente caliente son un costo real y medible en un sistema de alto rendimiento — que vale la pena eliminar por ingeniería. Pero ValueTask es una herramienta afilada con reglas adjuntas, y su recompensa solo aparece donde la tasa de llamadas es alta y la finalización síncrona es común. Úsala exactamente ahí — la ruta caliente de clave única — y deja las operaciones flexibles y tolerantes a asignaciones en Task, donde la flexibilidad vale más que una asignación ahorrada. La herramienta correcta, en el único lugar donde es la herramienta correcta.