Allokationsfreie Hot Paths: Wie der Zaris-.NET-Client ValueTask nutzt
Meistens ist Task<T> genau der richtige Rückgabetyp für eine asynchrone Methode, und man sollte nicht zweimal darüber nachdenken. Es gibt aber einen bestimmten Ort, an dem er unbemerkt Kosten verursacht: ein Hot Path, der synchron abschließt und millionenfach aufgerufen wird.
Der Mechanismus: Task<T> ist ein Referenztyp. Jeder Aufruf, der einen zurückgibt, allokiert ein Task-Objekt auf dem Heap — selbst wenn die Operation synchron abschließt. Ein Cache-Treffer direkt aus einem lokalen Puffer, ein Awaitable, das bereits fertig ist: Nichts davon erfordert echte Asynchronität, dennoch zahlen Sie für ein Heap-Objekt, das das Ergebnis trägt. Eine Allokation ist nichts. Bei der Aufrufrate eines Clients mit hohem Durchsatz — einer engen Schleife mit Millionen kleiner Gets und Puts — ist das ein stetiger Strom kleiner Heap-Allokationen, und dieser Strom füttert den Garbage Collector. Mehr Gen-0-Collections, mehr CPU für GC statt für Ihre Arbeit, mehr Jitter bei den Tail-Latenzen.
Der Zaris-.NET-Client setzt genau dort an, wo es zählt, und nirgendwo sonst. Dieser Beitrag handelt von dieser Entscheidung: welche Methoden sich geändert haben, warum, welche Regeln Sie für den korrekten Einsatz befolgen müssen und — bewusst — welche Methoden wir unangetastet gelassen haben.
Was sich geändert hat
Die Single-Key-Operationen auf dem Hot Path des Clients geben nun ValueTask / ValueTask<T> statt Task / Task<T> zurück. Das sind Get, Put, Delete, Expire, Persist, TTL und der Fluent-Chain-Builder.
ValueTask<T> ist ein Struct. Schließt die Operation synchron ab, verpackt es das bereits bekannte Ergebnis inline — keine Heap-Allokation auf diesem schnellen Pfad. Ist die Operation tatsächlich asynchron, greift es intern auf ein echtes Task zurück, sodass Sie im asynchronen Fall nichts verlieren. Den allokationsfreien Pfad gibt es kostenlos, wenn die Arbeit schon erledigt ist, und korrektes asynchrones Verhalten, wenn nicht.
So sieht die Änderung aus:
// 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);
Im weitaus häufigsten Fall ändert sich Ihr aufrufender Code überhaupt nicht — Sie führen einfach await aus:
// 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));
Die Regeln, die Sie befolgen müssen
ValueTask erkauft seine Performance, indem es einen Teil der Flexibilität von Task aufgibt, und es setzt diesen Tausch mit Regeln durch. Verstoßen Sie dagegen, erhalten Sie undefiniertes Verhalten, keine freundliche Exception. Drei Dinge sollten Sie verinnerlichen:
- Höchstens einmal awaiten. Ein
ValueTaskkann ein gepooltes oder wiederverwendetes Objekt umhüllen. Es zweimal zu awaiten — oder zu awaiten, nachdem Sie das Ergebnis bereits verbraucht haben — ist nicht erlaubt und kann Müll liefern oder eine Exception auslösen. .Resultnicht vor Abschluss anfassen. Anders als beiTaskist das Lesen des Ergebnisses einesValueTaskvor dessen Abschluss kein blockierendes Warten, sondern schlicht ungültig.- Mehrfach benötigt? Zuerst in ein
Taskumwandeln. Müssen Sie das Ergebnis speichern, an mehreren Stellen awaiten oder mehrere gleichzeitig ausführen, rufen SieAsTask()auf und arbeiten ab da mit demTask.
Letzteres ist der praktische Notausgang. Sobald Sie die ausstehende Operation festhalten wollen, gehen Sie so vor:
// 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);
Die einfachste Gewohnheit, die Sie schützt: Führen Sie await auf den ValueTask sofort aus, genau dort, wo Sie ihn aufrufen. Dann verstoßen Sie nie gegen eine der Regeln. Nur wenn Sie die Operation bewusst speichern oder wiederverwenden wollen, greifen Sie zu AsTask() — und sind wieder im vertrauten Task-Terrain.
Ein einmaliger, bewusster Bruch
Den Rückgabetyp einer Methode von Task<T> in ValueTask<T> zu ändern, ändert ihre Signatur und ist damit eine binärkompatibilitätsbrechende API-Änderung. Wir haben sie absichtlich und nur einmal vorgenommen, denn genau eine solche Änderung will man nicht zweimal machen. Bauen Sie gegen den neuen Client neu, kompiliert Ihr Quellcode höchstwahrscheinlich unverändert — die await-Stellen sind identisch; neu gebaut werden muss die kompilierte Assembly-Referenz.
Entscheidend: Das Wire-Protokoll hat sich nicht geändert. Es handelt sich ausschließlich um eine clientseitige Optimierung im Prozess, die betrifft, wie Ergebnisse durch Ihren eigenen Code zurückfließen. Server sind nicht betroffen — für diese Änderung ist kein Cluster-Redeploy nötig, und ein gegen die neuen Signaturen gebauter Client spricht mit denselben Nodes über dasselbe Protokoll wie zuvor.
Was wir bei Task belassen haben — und warum
Die andere Hälfte der Entscheidung ist die Hälfte, die wir nicht geändert haben. Bulk-Operationen, Transaktionen und Scans bleiben bewusst bei Task. Das sind keine Hot-Path-Primitive pro Aufruf — es sind größere, seltenere, gröber granulierte Operationen. Ein einzelner Bulk-Schreibvorgang oder eine Transaktion leistet bereits erhebliche Arbeit, sodass eine darauf verteilte Task-Allokation tatsächlich vernachlässigbar ist. Und genau bei diesen Operationen zahlt sich die Flexibilität von Task aus: Oft möchte man sie speichern, an mehr als einer Stelle awaiten oder mehrere mit Task.WhenAll kombinieren. Ihnen die Single-Await-Disziplin von ValueTask aufzuzwingen, würde echten ergonomischen Wert gegen eine Allokation tauschen, die Sie nie bemerken würden.
Das ist die ganze Lehre, und sie geht über diesen einen Client hinaus. Mikro-Allokationen auf einem wirklich heißen Pfad sind in einem System mit hohem Durchsatz ein reeller, messbarer Kostenfaktor — es lohnt sich, sie wegzuengineeren. Aber ValueTask ist ein scharfes Werkzeug mit Regeln, und sein Nutzen zeigt sich nur dort, wo die Aufrufrate hoch ist und synchroner Abschluss häufig vorkommt. Setzen Sie es genau dort ein — auf dem Single-Key-Hot-Path — und belassen Sie die flexiblen, allokationstoleranten Operationen bei Task, wo Flexibilität mehr wert ist als eine gesparte Allokation. Das richtige Werkzeug an der einen Stelle, an der es das richtige ist.