Runtime-native async in .NET 11

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.