.NET 11 : runtime async, la fin des state machines générées

Depuis C# 5, chaque méthode async est réécrite par le compilateur en une machine à états : une classe imbriquée, un champ d'état, une méthode MoveNext. Ce mécanisme fonctionne, mais il coûte des allocations et pollue toutes tes stack traces. .NET 11 déplace ce travail dans le runtime : le compilateur émet un simple appel, et c'est la VM qui gère la suspension. Ton code source, lui, ne change pas d'une ligne.

Sommaire

Ce que génère le compilateur aujourd'hui

Écris une méthode async de trois lignes et le compilateur produit une classe entière. Elle implémente IAsyncStateMachine, stocke l'état d'avancement dans un champ entier, et concentre tout le corps de ta méthode dans un MoveNext organisé en table de saut.

// What you write
public async Task<int> LoadAsync(HttpClient client)
{
    string body = await client.GetStringAsync("https://example.com");
    return body.Length;
}

// What the compiler emits today, simplified
[CompilerGenerated]
private sealed class LoadAsyncStateMachine : IAsyncStateMachine
{
    public int State;
    public AsyncTaskMethodBuilder<int> Builder;
    public HttpClient Client;
    private TaskAwaiter<string> awaiter;

    // The whole method body becomes a jump table over State
    public void MoveNext() { /* ... */ }

    public void SetStateMachine(IAsyncStateMachine stateMachine) { /* ... */ }
}
Ce que ça coûte
  • Une allocation sur le tas dès que la méthode suspend réellement (boxing de la structure d'état)
  • Des frames MoveNext parasites dans chaque pile d'appels
  • Un IL volumineux, plus difficile à inliner et à optimiser
  • Des noms générés illisibles dans les profileurs et les dumps mémoire

Ce que change runtime async

Avec runtime async, le compilateur n'émet plus de classe d'état. Il émet un appel direct vers le runtime, qui prend en charge la suspension et la reprise. L'IL généré tient en quelques instructions, et le JIT peut l'optimiser comme n'importe quel appel.

// Exact same source, compiled with runtime-async=on
public async Task<int> LoadAsync(HttpClient client)
{
    string body = await client.GetStringAsync("https://example.com");
    return body.Length;
}

// Emitted IL, simplified: no state class, a direct runtime call
// call !!0 System.Runtime.CompilerServices.AsyncHelpers::Await<string>(
//     class System.Threading.Tasks.Task<!!0>)
Ce que tu y gagnes
  • Moins d'allocations : la structure d'état n'est plus systématiquement promue sur le tas
  • Un IL nettement plus simple, donc plus de marge pour l'inlining
  • Depuis la Preview 4, l'inlining fonctionne aussi à la compilation ReadyToRun via crossgen2
  • Les bibliothèques du runtime .NET 11 sont désormais compilées avec runtime-async=on

Activer et désactiver

Les bibliothèques du runtime en bénéficient déjà. Pour tes propres assemblys, l'activation passe par une propriété MSBuild. Attention : la fonctionnalité a évolué à chaque preview, vérifie la note de version de la preview que tu utilises.

Activer sur ton projet

Cible net11.0 et ajoute la feature au compilateur. En .NET 11 Preview 3, l'attribut RequiresPreviewFeatures n'est plus nécessaire.

<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>

  <!-- Opt in for your own assemblies -->
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
Sortie de secours

Si un profileur, un réécrivain d'IL ou un outil de couverture n'est pas encore compatible, tu peux désactiver la fonctionnalité au niveau du projet. À noter : le commutateur d'environnement DOTNET_RuntimeAsync a été supprimé, il n'y a plus de désactivation à chaud.

<PropertyGroup>
  <!-- Escape hatch when a profiler or IL rewriter is not ready yet -->
  <UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>

Des stack traces lisibles

C'est le bénéfice le plus immédiat, et celui qui se voit dès le premier incident en production. Les frames MoveNext synthétiques disparaissent : la pile d'appels ressemble enfin aux méthodes que tu as écrites.

# Before: MoveNext frames and dispatch noise
   at Shop.Orders.OrderService+<LoadAsync>d__4.MoveNext()
--- End of stack trace from previous location ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at Shop.Api.OrdersController+<Get>d__2.MoveNext()

# After: the frames match the methods you wrote
   at Shop.Orders.OrderService.LoadAsync(HttpClient client)
   at Shop.Api.OrdersController.Get(Int32 id)

Concrètement, cela change aussi la lecture des logs et des outils d'APM : le regroupement d'erreurs devient plus fiable, puisqu'il ne s'appuie plus sur des noms de types générés.

Covariance Task vers Task<T>

Autre apport lié de .NET 11 : une méthode dérivée peut désormais retourner Task<T> là où la méthode de base retourne Task. Le runtime génère l'adaptateur nécessaire pour que la répartition virtuelle reste correcte. Cela supprime une bonne partie des surcharges d'adaptation écrites à la main.

public abstract class Repository
{
    public abstract Task SaveAsync();
}

// .NET 11 accepts an override returning Task<T> where the base returns Task
public sealed class SqlRepository : Repository
{
    public override Task<int> SaveAsync() => Task.FromResult(1);
}

Cas d'utilisation concrets

Runtime async ne demande aucune réécriture. Ce sont surtout les charges fortement asynchrones qui en profitent.

  • API ASP.NET Core à fort débit : chaque requête traverse des dizaines de méthodes async, l'allocation économisée se cumule
  • Workers et consommateurs de files : boucles de traitement longues où la pression sur le GC est le facteur limitant
  • Bibliothèques réutilisées largement : recompiler ta librairie avec runtime-async profite à tous ses appelants
  • Diagnostic d'incidents : piles d'appels exploitables sans décodage mental des noms générés
  • Applications sensibles à la latence de queue : moins de collectes gen0 déclenchées par les state machines

Avantages et inconvénients face au modèle async classique

Le point de comparaison, c'est le modèle actuel : la state machine générée par le compilateur, éprouvée depuis C# 5 et présente dans tous les runtimes .NET jusqu'à .NET 10 inclus.

Ce que runtime async apporte
  • Aucun changement de code source : tes async et await restent identiques
  • Moins d'allocations et une pression GC réduite sur les chemins fortement asynchrones
  • Stack traces et profils mémoire enfin lisibles
  • Inlining possible jusque dans la compilation ReadyToRun
  • Activé par défaut dans les bibliothèques du runtime .NET 11
Ce qui incite à la prudence
  • Nécessite .NET 11, qui n'est pas une version LTS, avec une GA au 10 novembre 2026
  • Les gains réels n'apparaissent que lorsque tout le graphe d'appels est recompilé
  • Les outils qui réécrivent l'IL (AOP, instrumentation, couverture de code) peuvent nécessiter une mise à jour
  • Certains profileurs et agents d'APM doivent être validés avant une bascule en production
  • Plus de commutateur d'environnement pour revenir en arrière à chaud : la décision se prend à la compilation
  • Tes benchmarks de référence sont à refaire : les chiffres publiés ne valent pas pour ta charge

En pratique : active runtime async d'abord sur un service non critique, compare allocations et latence de queue avant et après, et valide ta chaîne d'observabilité. Le gain est réel mais graduel, alors que le risque se concentre entièrement sur l'outillage.