Wilson Vargas

Runtime async en .NET 11 a fondo: qué cambia en tu código async/await (y en tu app .NET MAUI)

Runtime async reemplaza las máquinas de estados de async/await. Cómo funciona, stack traces, ValueTask y qué revisar en tu app .NET MAUI con .NET 11.

Portada: runtime async en .NET 11, async/await sin máquinas de estados

¿Alguna vez abriste un stack trace de tu app y te encontraste con <CargarProductosAsync>d__12.MoveNext() repetido diez veces entre líneas de AsyncMethodBuilderCore.Start? Si llevas años con async/await, seguro que sí. Yo empecé a escribir sobre Xamarin.Forms en 2017 y desde entonces esos nombres raros son parte del paisaje 😅.

En el artículo sobre CoreCLR en .NET 11 te conté runtime async como un bonus al final. Pero el tema da para mucho más, así que hoy vamos a verlo a fondo: cómo funciona, qué pasa con los stack traces, qué onda con ValueTask, cómo activarlo y qué deberías revisar en tu código antes de hacerlo.

¿Qué es runtime async?

Desde C# 5, async/await es un truco del compilador. Cada método async se transforma en una máquina de estados: un struct con un campo <>1__state, un builder y un método MoveNext() que retoma la ejecución en el punto correcto después de cada await. El runtime no sabe nada de "métodos asíncronos"; solo ve clases y métodos normales.

Con .NET 11 eso cambia. En palabras de la documentación oficial, runtime async es "un paso importante para reemplazar las máquinas de estados generadas por el compilador con suspensión y reanudación administradas por el runtime". Ahora el compilador marca el método como asíncrono y es el runtime (el JIT, ReadyToRun o NativeAOT) el que se encarga de suspenderlo y reanudarlo.

Mismo async/await, otro motor

Por dentro, el IL que genera el compilador queda muy parecido a tu código original. Los métodos se marcan con MethodImplOptions.Async (el flag 0x2000 en metadata) y cada await se convierte en una llamada a System.Runtime.CompilerServices.AsyncHelpers.Await(...). Tú no escribes nada de eso: el compilador lo hace por ti.

Máquina de estados (hasta .NET 10) Runtime async (.NET 11)
¿Quién implementa la suspensión? El compilador de C# El runtime (JIT / R2R / NativeAOT)
Código generado Struct <Método>d__N + MoveNext() El mismo método, marcado async en IL
Stack trace en vivo Lleno de AsyncMethodBuilderCore.Start Solo tus métodos
Locales al suspender Todos van a campos del struct Solo se guardan los que se necesitan
Atributo AsyncStateMachine Sí No
Ojo: tu código C# no cambia. Sigues escribiendo async, await, Task y ValueTask exactamente igual. Lo que cambia es lo que el compilador emite y quién hace el trabajo en tiempo de ejecución.

¿En qué estado está a la fecha?

Esto ha ido cambiando bastante durante los previews de .NET 11, así que vale la pena ordenarlo:

Línea de tiempo de runtime async en .NET 11
Versión Qué pasó con runtime async
Preview 1 (feb 2026) El soporte en CoreCLR viene activo por defecto. NativeAOT ya puede compilar código runtime-async. Las librerías base todavía no lo usan.
Preview 3 Se elimina el requisito [RequiresPreviewFeatures].
Preview 4 Las librerías de .NET se compilan con runtime-async=on. Se eliminan las variables DOTNET_RuntimeAsync y UNSUPPORTED_RuntimeAsync.
RC 2 (oct 2026) Un proyecto net11.0 ya no necesita EnablePreviewFeatures. Tu código se activa con <Features>runtime-async=on</Features>.
GA (nov 2026) Versión estable planeada.

Aquí hay un detalle importante que a mi parecer genera mucha confusión: hay dos piezas distintas.

  1. El soporte del runtime: viene activo y ya no se puede apagar a nivel de runtime. Y como las librerías de .NET (HttpClient, Stream, System.Text.Json…) ya vienen compiladas con runtime async, tu app ya está usando runtime async dentro del framework aunque no toques nada.
  2. Tu propio código: según la documentación de RC 2, compilar tus métodos con runtime async sigue siendo una característica en preview y la activas tú, proyecto por proyecto.
Importante: la documentación oficial todavía lo llama preview feature. Microsoft pide explícitamente reportes, buenos o malos, sobre rendimiento y tamaño de librerías. Tómalo como algo para probar en serio, no como algo para activar a ciegas en producción el día uno.

Cómo activarlo en tu proyecto

Paso 1: activa la característica

En tu .csproj (o mejor, en un Directory.Build.props si tienes varios proyectos):

<PropertyGroup Condition="'$(TargetFramework)' == 'net11.0' Or $(TargetFramework.StartsWith('net11.0-'))">
  <!-- Compila tus métodos async con runtime async -->
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

Uso $(Features); para no pisar otras características que ya tengas, y la condición con StartsWith porque en .NET MAUI tus TFM son net11.0-android, net11.0-ios, etc.

Paso 2: si aparecen avisos de preview

Durante los previews, algunos desarrolladores reportaron que los analizadores marcaban SYSLIB5007 y CA2252 como errores, porque los await se traducen a APIs en preview. Si te pasa, puedes silenciarlos mientras pruebas:

<PropertyGroup>
  <!-- Solo mientras runtime async siga marcado como preview -->
  <NoWarn>$(NoWarn);SYSLIB5007;CA2252</NoWarn>
</PropertyGroup>

Paso 3: haz un Rebuild completo

Esto me pareció un buen consejo del artículo de fluxzy: cambiar Features no invalida la comprobación de "está actualizado" de MSBuild, así que puedes terminar midiendo un binario viejo sin darte cuenta. Limpia antes de compilar:

dotnet clean
dotnet build -f net11.0-android -c Release

Paso 4: comprueba que de verdad está activo

La forma más sencilla: los métodos compilados con runtime async ya no llevan el atributo AsyncStateMachineAttribute. Puedes verificarlo en tu app con unas líneas:

using System.Reflection;
using System.Runtime.CompilerServices;

public static class RuntimeAsyncCheck
{
    // Devuelve true si el método se compiló como runtime async
    public static bool EsRuntimeAsync(Type tipo, string metodo)
    {
        var info = tipo.GetMethod(metodo,
            BindingFlags.Instance | BindingFlags.Static |
            BindingFlags.Public | BindingFlags.NonPublic);

        return info is not null
            && info.GetCustomAttribute<AsyncStateMachineAttribute>() is null;
    }
}

// En tu MauiProgram o en una página de diagnóstico:
// Debug.WriteLine($"Runtime async: {RuntimeAsyncCheck.EsRuntimeAsync(typeof(ProductosViewModel), "CargarProductosAsync")}");

¿Y para desactivarlo?

Si tu proyecto lo tenía activo y necesitas volver atrás, quita runtime-async=on de Features. La documentación también menciona la propiedad <UseRuntimeAsync>false</UseRuntimeAsync> como forma de excluir un proyecto, en reemplazo de las variables de entorno que ya no existen. Si dependías de DOTNET_RuntimeAsync=0, eso ya no hace nada.

Stack traces: la mejora que más vas a notar

Para mí, esta es la parte más agradecida. La documentación oficial trae este ejemplo con tres métodos encadenados:

await OuterAsync();

static async Task OuterAsync()
{
    await Task.CompletedTask;
    await MiddleAsync();
}

static async Task MiddleAsync()
{
    await Task.CompletedTask;
    await InnerAsync();
}

static async Task InnerAsync()
{
    await Task.CompletedTask;
    Console.WriteLine(new StackTrace(fNeedFileInfo: true));
}

Sin runtime async, ese StackTrace tiene 13 frames, la mitad de ellos de infraestructura (AsyncMethodBuilderCore.Start y compañía). Con runtime async quedan 5 frames: exactamente la cadena de llamadas que escribiste.

Stack trace con y sin runtime async

Ahora, ojo con un matiz que la documentación aclara muy bien: esto aplica a los stack traces en vivo, o sea lo que ven el depurador, los profilers y new StackTrace() mientras el código se ejecuta. Los stack traces de excepciones (lo que obtienes en un catch (Exception ex)) ya se veían limpios antes, gracias a ExceptionDispatchInfo, así que ahí no vas a notar mucha diferencia.

¿Dónde lo vas a notar en una app MAUI?

Rendimiento: lo que dice Microsoft y lo que midieron otros

Microsoft no da una cifra única de mejora, pero sí describe de dónde sale:

Como dato de referencia (no de móvil), el equipo de fluxzy midió su proxy en .NET 11 Preview 6 contra .NET 10 en Linux. En un microbenchmark sintético, una cadena de 12 await que completan de forma síncrona pasó de 52,5 ns a 18,3 ns, y las asignaciones al suspender bajaron un 53 %. En su app real, la mejora fue mucho más modesta: entre 8 % y 13 % de throughput en los escenarios con cuerpo de petición, y plano en el más simple. A mi parecer, esa es la expectativa correcta: el microbenchmark es el techo, no lo que vas a ver en tu app.

Si vas a medir en tu app MAUI, te dejo una tabla para que anotes tus resultados (mide siempre en Release y en dispositivo físico):

Escenario Sin runtime async Con runtime async
Tiempo de arranque (Android, Release) completar completar
Tiempo de arranque (iOS, Release) completar completar
Carga de lista desde API (ms) completar completar
Asignaciones en la carga (MB, dotnet-counters) completar completar
Tamaño del APK / IPA completar completar

¿Qué pasa con ValueTask?

ValueTask y ValueTask<T> son ciudadanos de primera clase en runtime async. La especificación define overloads de AsyncHelpers.Await para Task, Task<T>, ValueTask, ValueTask<T> y sus versiones con ConfigureAwait, así que un método que devuelve ValueTask<T> se transforma igual que uno que devuelve Task<T>.

Lo que no cambia son las reglas de siempre de ValueTask:

public async ValueTask<Producto?> ObtenerAsync(int id)
{
    // Camino rápido: si está en caché, no hay suspensión ni asignación
    if (_cache.TryGetValue(id, out var producto))
        return producto;

    producto = await _api.GetProductoAsync(id);
    _cache[id] = producto;
    return producto;
}

// ✅ Correcto: se espera una sola vez
var p = await servicio.ObtenerAsync(5);

// ❌ Incorrecto (antes y ahora): esperar dos veces el mismo ValueTask
var vt = servicio.ObtenerAsync(5);
var a = await vt;
var b = await vt; // comportamiento indefinido

Lo interesante es que runtime async también trabaja sobre el camino síncrono de los Task normales: el JIT reconoce Task.FromResult y Task.CompletedTask, y crossgen2 puede hacer inlining de las llamadas async que terminan sin await. Mi recomendación: no cambies tus Task a ValueTask "por si acaso". Usa ValueTask donde ya tenía sentido (APIs con un camino síncrono muy frecuente, como una caché) y mide.

Qué se transforma y qué no

No todo método async se convierte en runtime async. Según el diseño del compilador, solo se transforman los que devuelven uno de estos cuatro tipos: Task, Task<T>, ValueTask y ValueTask<T>. Todo lo demás sigue con la máquina de estados de siempre.

Qué se transforma con runtime async
Tipo de método ¿Runtime async?
async Task / async Task<T> ✅ Sí
async ValueTask / async ValueTask<T> ✅ Sí
async void (event handlers, OnAppearing con async) ❌ No, sigue con máquina de estados
IAsyncEnumerable<T> (async + yield return) ❌ Pendiente en el diseño
Tipos task-like con [AsyncMethodBuilder] propio ❌ No
Métodos con builder de pooling (PoolingAsyncValueTaskMethodBuilder) ❌ No, se excluyen a propósito

Esto es relevante en MAUI porque usamos muchísimo async void en event handlers (Clicked, OnAppearing). La buena noticia es que no se rompe nada: simplemente esos métodos siguen funcionando como siempre. Y si el handler llama a un async Task (que es lo recomendable), de ahí para abajo sí se beneficia.

// async void: sigue con máquina de estados
private async void OnGuardarClicked(object sender, EventArgs e)
{
    // ...pero todo lo que llama hacia abajo sí puede ser runtime async
    await _viewModel.GuardarAsync();
}

Lo que deberías revisar en tu código

La gran mayoría de las apps no van a notar nada raro. Pero hay algunos casos donde vale la pena revisar.

1. Código que depende de la máquina de estados

Si tienes (o usas una librería que tiene) código que:

...ese código puede comportarse distinto. Herramientas de AOP como PostSharp ya tienen issues abiertos analizando el impacto. Si usas alguna librería de este estilo, revisa su compatibilidad con .NET 11 antes de activar runtime-async=on.

2. Herramientas de crash reporting y logging

Los stack traces de excepciones se mantienen prácticamente iguales, así que la agrupación de errores no debería cambiar. Pero si tu herramienta captura stack traces en vivo (por ejemplo, para breadcrumbs o para medir tiempos), verás menos frames. Es una mejora, pero puede cambiar cómo se agrupan eventos que antes tenías clasificados.

3. El hilo de UI

En MAUI, todo depende de que después de un await volvamos al hilo principal para tocar la UI. El modelo de await (incluido ConfigureAwait(false)) está pensado para mantenerse igual, pero yo no lo daría por sentado sin probar. Haz una pasada por las pantallas que actualizan la UI después de un await, sobre todo en iOS:

public async Task CargarAsync()
{
    Cargando = true;

    // Trabajo de red: aquí está bien liberar el contexto
    var json = await _http.GetStringAsync(url).ConfigureAwait(false);
    var items = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListProducto);

    // Volvemos explícitamente al hilo principal para tocar la UI
    await MainThread.InvokeOnMainThreadAsync(() =>
    {
        Productos.Clear();
        foreach (var item in items!)
            Productos.Add(item);
        Cargando = false;
    });
}
Ojo: esto no es nuevo de runtime async. Si después de un ConfigureAwait(false) tocas una propiedad enlazada a la UI, ya tenías un bug latente. Simplemente es buen momento para revisarlo.

4. Restricciones que ya conocías

La especificación del runtime confirma que las variables por referencia (ref, Span<T> y otros ref struct) no sobreviven a una suspensión. C# ya te impedía usarlas a través de un await, así que en la práctica no cambia nada para ti.

✅ Cuándo activarlo / ❌ cuándo esperar

✅ Actívalo ya (en una rama) si:

❌ Espera si:

Checklist

✅ Actualiza a .NET 11 y confirma que tus TFM son net11.0-*. ✅ Activa <Features>$(Features);runtime-async=on</Features> en una rama. ✅ Haz dotnet clean antes de compilar para no medir binarios viejos. ✅ Verifica con reflexión que tus métodos ya no tienen AsyncStateMachineAttribute. ✅ Elimina cualquier uso de DOTNET_RuntimeAsync en scripts o pipelines. ✅ Revisa librerías que dependan de la máquina de estados o de nombres en stack traces. ✅ Prueba las pantallas que actualizan la UI después de un await, en Android y en iOS. ✅ Mide arranque, asignaciones y tamaño en Release, en dispositivo físico.

Conclusión

Runtime async es uno de esos cambios que casi no se ven en tu código, pero que mueven bastante por debajo. Después de más de una década de máquinas de estados generadas por el compilador, el runtime por fin entiende qué es un método asíncrono.

Ya viste:

Mi recomendación personal: actívalo en una rama apenas migres a .NET 11, corre tus pruebas y mide. Lo que más me gusta es que, aunque no ganes mucho rendimiento, la experiencia de depurar código async mejora muchísimo, y eso solo ya vale la pena.

¿Ya lo probaste en tu app? ¿Notaste diferencia en arranque o en memoria? Escríbeme en X (Twitter), me encantaría saber tus números.

Nos leemos pronto 😊


Referencias:

Compartir
Wilson Vargas
Escrito por

Wilson Vargas

Programador .NET con 8 años de experiencia, apasionado por la tecnología. Con habilidades en el desarrollo de aplicaciones utilizando tecnologías de la plataforma .NET.

Posts relacionados

Comentarios