Runtime-native async in .NET 11
- Matt
Wenn eine Aufgabe irgendwo in einer Kette von await-Aufrufen festhängt und ich
in den Debugger schaue, sehe ich seit Jahren dasselbe: nicht meine Methoden,
sondern MoveNext und AsyncMethodBuilderCore.Start in dreizehnfacher
Ausführung. Man lernt, das zu überlesen. Schön ist es nicht.
Der Grund dafür ist alt. Seit C# 5 schreibt der Compiler jede async-Methode in
eine Zustandsmaschine um: eine generierte Struct mit MoveNext, ein Builder, ein
Awaiter. Das funktioniert, aber die Laufzeit weiß nichts davon. Sie sieht nur
gewöhnliche Methoden, und deshalb sieht der Stack aus, wie er aussieht.
Was sich ändert
.NET 11 führt runtime-native async ein, im Team “Runtime Async V2” genannt. Die Idee: nicht der Compiler baut die Zustandsmaschine, sondern die Laufzeit verwaltet das Anhalten und Fortsetzen selbst. Der Compiler emittiert keine generierten Structs mehr, die Laufzeit kennt Suspendierung als eigenes Konzept.
Einschalten muss man es pro Projekt über eine Eigenschaft in der csproj:
<PropertyGroup>
<Features>runtime-async=on</Features>
</PropertyGroup>
Ein net11.0-Projekt braucht dafür kein <EnablePreviewFeatures> mehr. Die
Laufzeitbibliotheken selbst sind bereits mit runtime-async=on übersetzt und
enthalten keine compilergenerierten Zustandsmaschinen mehr. Man kann eine ganze
Anwendung umstellen, sofern sie nur von Bibliotheken abhängt.
Der sichtbare Gewinn
Am deutlichsten wird es bei den Live-Stacktraces, also dem, was Profiler,
Debugger und new StackTrace() während der Ausführung sehen. Drei
ineinander verschachtelte async-Methoden ergeben ohne das Feature dreizehn
Frames, mit dem Feature fünf. Was übrig bleibt, ist die echte Aufrufkette, ohne
das Infrastruktur-Rauschen dazwischen. Breakpoints binden korrekt innerhalb der
Methoden, und der Debugger steppt über await-Grenzen, ohne in generierten Code
zu springen.
Dazu kommen Nebeneffekte, die man nicht sieht, aber misst: Continuations werden
aggressiver wiederverwendet, unveränderte lokale Variablen nicht mehr gesichert,
und Fortsetzungen können den ExecutionContext überspringen, wenn ohnehin kein
AsyncLocal<T>-Zustand wiederherzustellen ist. Auch Native AOT und ReadyToRun
tragen das Modell mit, was vorher der wunde Punkt jeder Umstellung war. Wer
Native AOT für kleine Werkzeuge
schon nutzt, bekommt async ohne die alten Einschränkungen dazu.
Wo der Haken sitzt
Es ist ein Preview-Feature. Die Signaturen im Code ändern sich nicht, dein
async/await bleibt Zeile für Zeile gleich, aber der erzeugte Code ist neu und
noch nicht in Stein. Man muss neu übersetzen, und man verlässt sich auf einen
Pfad, den das Ökosystem gerade erst validiert. Bibliotheken von Dritten, die
selbst noch klassisch übersetzt sind, mischt man in genau diesem Zustand.
Und der eine Fall, den ich anfangs erwartet hatte, verbessert sich gar nicht:
Exception-Stacktraces aus catch (Exception ex) sehen mit und ohne Runtime Async
gleich aus, weil die bestehende ExceptionDispatchInfo-Bereinigung das schon
löst. Der Gewinn liegt bei der laufenden Ausführung, nicht beim Nachhinein.
Ausschalten geht übrigens pro Projekt mit <UseRuntimeAsync>false</UseRuntimeAsync>.
Die früheren Umgebungsvariablen DOTNET_RuntimeAsync und UNSUPPORTED_RuntimeAsync
sind entfernt, wer sie in Skripten hat, sucht sonst lange.
Ich lasse es vorerst in einem Nebenprojekt laufen und schaue auf die Stacktraces. Für Produktion warte ich, bis das Preview-Etikett weg ist.