Async Rust nunca salió del estado MVP
(tweedegolf.nl)- Async Rust permite ejecutar código independiente del executor tanto en servidores como en microcontroladores, pero la máquina de estados que genera el compilador hace que el aumento del tamaño del binario se note especialmente en embebidos
- Incluso un ejemplo simple como
bar(), con 2 puntos de await, crea 360 líneas de MIR y los estadosUnresumed,Returned,Panicked,Suspend0,Suspend1; la versión síncrona solo necesita 23 líneas - Si al volver a hacer poll de un future ya completado se devuelve
Poll::Pendingen vez depanic, se puede cumplir el contrato sin comportamiento unsafe, y en pruebas el tamaño del binario se reduce entre 2% y 5% en firmware embebido - Incluso
async { 5 }, sin await, hoy genera una máquina de estados con 3 estados base, pero si se optimiza para devolver siemprePoll::Ready(5), el tamaño del binario embebido baja 0.2% - El Project Goal propuesto busca que el compilador impulse en modo release la eliminación del panic tras completarse, la eliminación de la máquina de estados en bloques async sin await, el inline de futures con un solo await y el plegado de estados idénticos
El problema del bloat a nivel de compilador en Async Rust
- Async Rust permite ejecutar código independiente del executor tanto en servidores como en microcontroladores, pero en microcontroladores pequeños el aumento del tamaño del binario se nota especialmente
- El blog de Rust presentó async/await como una abstracción de costo cero, pero en la práctica async sí genera bastante bloat; el mismo problema existe en desktop y servidores, aunque allí se nota menos por tener más memoria y capacidad de cómputo
- Tras un método alternativo para evitar el bloat al escribir código async, se presentó un Project Goal para resolver el problema desde el compilador
- Queda fuera del alcance el problema de futures más grandes de lo necesario y con demasiadas copias
- Este problema ya es conocido y hay un PR abierto que aborda una parte: https://github.com/rust-lang/rust/pull/135527
Estructura del future generado
- El código de ejemplo hace que
foo()devuelvaasync { 5 }, y quebar()ejecutefoo().await + foo().await- Ejemplo en Godbolt: godbolt
bartiene 2 puntos de await, así que la máquina de estados necesita al menos 2 estados, pero en la práctica se generan más- El compilador de Rust puede volcar MIR en varias pasadas, y la pasada
coroutine_resumees la última pasada de MIR específica de async- async aún existe en MIR, pero ya no en LLVM IR, así que la conversión de async a máquina de estados ocurre en las pasadas de MIR
- La función
bargenera 360 líneas de MIR, mientras que la versión síncrona solo usa 23 líneas - El
CoroutineLayoutque emite el compilador es, en la práctica, un conjunto de estados con forma de enumUnresumed: estado inicialReturned: estado completadoPanicked: estado posterior a un pánicoSuspend0: primer punto de await, guarda el future defooSuspend1: segundo punto de await, guarda el primer resultado y el segundo future defoo
Future::polles una función segura, así que no debe provocar UB aunque se vuelva a llamar después de que el future ya terminó- Hoy, después de
Suspend1, devuelveReadyy cambia el future al estadoReturned - Si se vuelve a hacer poll en ese estado, ocurre un panic
- Hoy, después de
- El estado
Panickedparece existir para impedir que se vuelva a hacer poll de ese future cuando una función async hizo panic y eso fue capturado concatch_unwind- Tras un panic, el future puede quedar en un estado incompleto, así que volver a hacer poll podría llevar a UB
- Este mecanismo es muy similar al poisoning de un mutex
- Esta interpretación del estado
Panickedno está totalmente documentada; la certeza es de alrededor del 90%
¿De verdad tiene que hacer panic al hacer poll después de completarse?
- Hoy un future en estado
Returnedhace panic, pero no es estrictamente necesario que sea así- La condición necesaria es solo que no cause UB
- Un panic es relativamente costoso y añade una ruta con efectos secundarios que cuesta eliminar por optimización
- Si al volver a hacer poll de un future ya completado se devuelve
Poll::Pending, se puede cumplir el contrato del tipoFuturesin comportamiento unsafe - Al modificar el compilador para probar este enfoque, se observó una reducción de 2% a 5% del tamaño del binario en firmware embebido async
- Se propone ofrecer este comportamiento como un switch, de forma similar a
overflow-checks = falsepara overflow de enteros- En builds de debug seguiría habiendo panic para exponer de inmediato el comportamiento incorrecto
- En builds release se podrían obtener futures más pequeños
- Con
panic=abort, podría ser posible eliminar por completo el estadoPanicked, aunque su impacto requiere más revisión
Siempre se genera una máquina de estados, incluso sin await
foo()solo devuelveasync { 5 }, así que la forma óptima manual sería un future sin estado que siempre devuelvaPoll::Ready(5)- Sin embargo, en el MIR que genera el compilador siguen existiendo los 3 estados base:
Unresumed,Returned,Panicked- Al hacer poll, se revisa el discriminante del estado actual y se bifurca en consecuencia
- Si se vuelve a hacer poll tras completarse, hay un panic con el assert
`async fn` resumed after completion
- En este caso se puede optimizar para no crear ninguna máquina de estados y devolver siempre
Poll::Ready(5) - Al aplicar esto experimentalmente en el compilador, el tamaño del binario embebido se redujo 0.2%
- El ahorro no es grande, pero la optimización es simple y podría valer la pena
- Esta optimización cambia un poco el comportamiento, pero solo afecta a executors que no respetan el contrato
- Hoy el compilador hace panic en polls posteriores
- Tras la optimización, el future siempre devolvería
Ready
Solo con LLVM no alcanza
- Aunque la salida MIR sea ineficiente, a veces LLVM puede limpiar todo, pero bajo condiciones limitadas
- El future debe ser lo suficientemente simple
- Hay que usar
opt-level=3
- Si el future se vuelve más complejo, LLVM ya no logra eliminarlo, y en código async idiomático de Rust los futures tienden a anidarse profundamente, así que la complejidad crece rápido
- En entornos donde a menudo se optimiza por tamaño, como embebidos o wasm, LLVM no logra optimizar todo esto por sí solo
- Ejemplo en Godbolt: https://godbolt.org/z/58ahb3nne
- En el ensamblador generado, LLVM sabe que
foodevuelve 5, pero no logra optimizar la respuesta debara 10 - La llamada a la función
polldefoosigue presente - La razón son rutas potenciales de panic que el compilador no puede descartar completamente
- LLVM no sabe que
fooen realidad solo se llama una vez y que no va a hacer panic
- En el ensamblador generado, LLVM sabe que
- Si se comenta la rama de panic en el IR, la optimización mejora: https://godbolt.org/z/38KqjsY8E
- En vez de esperar una optimización posterior de LLVM, el compilador debería darle una mejor entrada a LLVM
El inline de futures no está funcionando bien
- El inline es importante porque habilita pasadas de optimización posteriores, pero los futures generados por Rust hoy no se inlinean en etapas tempranas
- Una vez que cada future obtiene su implementación, LLVM y el linker sí tienen oportunidades de inline, pero por los problemas anteriores ese momento ya llega demasiado tarde
- La oportunidad de inline más directa es una forma donde
bar()solo hacefoo(blah).await- Es un patrón que aparece seguido al construir abstracciones con traits
- Hoy el compilador crea una máquina de estados para
bary dentro de ella llama a la máquina de estados defoo - De manera más eficiente,
barpodría ser directamente el future defoo
- Cuando hay preamble y postamble, la cosa es más compleja
- Ejemplo:
bar(input)creablahconinput > 10, luego hacefoo(blah).awaity finalmente aplica* 2al resultado - Es algo común al transformar funciones async a otras firmas, especialmente en implementaciones de traits
- Ejemplo:
- Incluso en esta forma,
barno necesita tener estado async propio- No hay datos preservados más allá del único punto de await excepto el valor capturado por
foo - Aun así,
barno puede convertirse simplemente enfoo, sino que la mayor parte del estado puede delegarse afoo
- No hay datos preservados más allá del único punto de await excepto el valor capturado por
- En una implementación manual,
BarFutpodría tener los estadosUnresumed { input }eInlined { foo: FooFut }- En el primer poll ejecutaría el preamble para crear
foo(blah)y cambiar al estadoInlined - Después aplicaría el postamble al resultado de
foo.poll(cx)
- En el primer poll ejecutaría el preamble para crear
- Si el código pudiera ejecutarse por adelantado hasta el primer punto de await, también podría eliminarse el estado
Unresumed, pero no puede hacerse porque está garantizado que un future no hace nada antes de recibir poll - Si se pudieran consultar propiedades del future mientras está siendo polleado, sería posible aplicar optimizaciones de inline adicionales
- Por ejemplo, si se supiera que un future siempre devuelve ready en su primer poll, el future llamador no necesitaría crear un estado para ese punto de await
- Si este tipo de optimización se aplicara recursivamente, muchos futures podrían plegarse en máquinas de estados mucho más simples
- En la estructura actual de
rustc, cada bloque async parece transformarse por separado y luego no se conserva la información relacionada, así que este tipo de consulta no parece posible - El inline de futures aún no se ha probado, pero se espera que ayude bastante tanto en tamaño de binario como en rendimiento
Plegado de estados idénticos
- Cada punto de await dentro de un bloque async añade un estado extra a la máquina de estados
- Un código como el siguiente es natural, pero como ambas ramas hacen await de la misma función async, se generan 2 estados idénticos
CommandId::A => send_response(123).awaitCommandId::B => send_response(456).await
- En este caso, el
CoroutineLayoutcrea_s0y_s1, ambos almacenando el mismo tipo de coroutine desend_response, además de los estadosSuspend0ySuspend1 - El MIR de esta función tiene 456 líneas, y muchos bloques básicos son en la práctica duplicados
- Si antes se refactoriza manualmente el código para calcular primero solo el valor de respuesta y luego hacer una sola vez
send_response(response).await, desaparecen los estados duplicadosCommandId::Aproduce123CommandId::Bproduce456- Después se hace
send_response(response).await
- Tras el refactor, en
CoroutineLayoutqueda almacenado un solo future y solo queda el estadoSuspend0 - La longitud total del MIR baja a 302 líneas y desaparece la duplicación
- Por eso parece útil una pasada de optimización que detecte rutas de código y estados equivalentes para plegarlos en uno solo
- Esta optimización probablemente combine bien con una pasada de inline de futures
Enlaces de experimentos y benchmarks adicionales
- Al aplicar juntos los 2 experimentos, se obtiene alrededor de 3% de mejora de rendimiento en un benchmark sintético x86 usando el executor
smol - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
Solicitud de apoyo para el Project Goal
- Este trabajo fue presentado como Project Goal para poder llevarse adelante en el compilador: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
- Sin financiamiento es difícil avanzar mucho, así que se necesita apoyo parcial o total de empresas u organizaciones que se beneficien de este trabajo
- El contacto es
dion@tweedegolf.com - El alcance del trabajo y el monto de financiamiento necesario son flexibles, pero se estima que €30k permitirían completar todo o una parte importante
1 comentarios
Comentarios de Lobste.rs
Fue un texto mucho más constructivo de lo que esperaba viendo solo el título
Ojalá quien quiera trabajar en esto reciba el apoyo que necesita
Me alegra ver que se esté abordando este problema. Ya había leído varias veces que ahora mismo rustc le está pasando demasiado código a LLVM y espera que el optimizador resuelva todo, y este texto en particular además está pidiendo financiamiento para ese trabajo
Dios mío, yo era un idiota
Siempre pensé que async era intrínsecamente “inflado”, porque de una forma u otra necesita un runtime, seguimiento de tareas y polling para verificar la finalización. Ese overhead no es cero, al fin y al cabo
Lo que se quería decir aquí con “abstracción de costo cero” era sobre la funcionalidad del lenguaje, y yo lo veía como algo aparte del runtime agregado
Ni siquiera se me había ocurrido mirar qué está emitiendo rustc antes de pasárselo a LLVM
Para quienes no están familiarizados con async Rust:
Esto es totalmente cierto. Incluso un árbol anidado de llamadas async, después de una optimización máxima, se solidifica internamente en una sola estructura con una máquina de estados dentro. Es una forma realmente ingeniosa de hacerlo
¿En un build de release, llegar a este caso genera una especie de deadlock? ¿O también podría producir fugas porque hay tareas esperando trabajo que siempre queda en
Pending?No puedes hacer polling incorrecto con
.awaitSe me ocurren algunas cosas:
panic=unwind. Fuera de algunos test harnesses, casi nunca he visto ventajas frente apanic=abortque compensen su costo. Incluso en un test harness, en Linux parece que podría aplicarse una elección parecida usandoclonede una forma rebuscada para hacerwaital hilo de ejecución en vez depthread_join. Puede que yo esté equivocado en esto¿El enlace también se le cayó a alguien más hace un momento?
Edit: la entrada del blog se ve como medio segundo y luego te manda a una página 404
Edit 2: entré a la lista de posts del blog y estuve picándole a varias cosas, y hasta abriendo ese post desde la lista igual me manda a una página 404. ¿Cómo se puede arruinar así un blog que es una página estática, o al menos debería serlo?
Como referencia, creo que seguí los mismos pasos de reproducción y a mí no me salió ningún 404. Lo probé en celular y escritorio, con JavaScript activado y desactivado. Así que da la impresión de que lo que te pasó pudo haber sido más complejo de lo que parecía