async closures y los pecados clasicos del runtime
La edicion 2024 estabilizo los closures async: async || y los traits AsyncFn, AsyncFnMut y AsyncFnOnce, que cierran un hueco que Fn -> impl Future no podia expresar. Con ellos como cierre del nivel, los dos pecados que castigan a todo principiante de async: bloquear el hilo del runtime, y sostener un lock a traves de un .await.
Cerramos el nivel con dos temas que parecen dispares y no lo son. El primero es una novedad que la edición 2024 por fin estabilizó: los closures async, async || { ... }, y su familia de traits AsyncFn, AsyncFnMut y AsyncFnOnce. Durante años se escribía un sucedáneo —un closure normal que devolvía un future— que funcionaba a medias y fallaba justo cuando querías que el future tomara prestado del entorno capturado. Los async closures cierran ese hueco. El segundo tema son los dos pecados capitales del async, los que compilan sin quejarse y luego arruinan el rendimiento o cuelgan el servicio: bloquear el hilo del runtime y sostener un lock a través de un .await. No son bugs exóticos; son la forma en que casi todo el mundo se estrella la primera vez. Terminar el nivel entendiéndolos es la diferencia entre escribir async que compila y escribir async que escala.
- Escribir closures
async ||y usar los traitsAsyncFn,AsyncFnMutyAsyncFnOnce. - Entender qué hueco cierran frente al viejo patrón
Fn() -> impl Future. - Evitar el primer pecado: nunca bloquear el hilo del runtime con trabajo síncrono o de CPU.
- Evitar el segundo pecado: nunca sostener un guard de lock a través de un
.await.
Closures async y los traits AsyncFn
Un closure async se escribe anteponiendo async a la lista de parámetros. Al llamarlo produce un future, que esperas con .await; y lo aceptas en una función con un bound AsyncFn, del mismo modo que aceptabas closures normales con Fn:
async fn para_cada<F: AsyncFn(u32)>(f: F) {
for i in 0..3 {
f(i).await; // llamar devuelve un future; se espera
}
}
let base = leer_config().await;
para_cada(async |i| {
let r = servicio(i).await; // el cuerpo puede tener sus propios .await
registrar(base, i, r).await;
}).await;
La jerarquía calca la de los closures síncronos que ya conoces. AsyncFnOnce se llama una vez y consume; AsyncFnMut se llama muchas con &mut self; AsyncFn se llama muchas con &self. Como antes, el compilador deduce cuál implementa tu closure según lo que haga con sus capturas, y al pedir uno conviene exigir el trait más permisivo que tu uso tolere.
La utilidad de aceptar un AsyncFn es la misma que la de aceptar un Fn, trasladada a async: escribir combinadores que reciben una operación asíncrona y la orquestan. Un reintentar que llama al closure y, si su future falla, vuelve a llamarlo; un medir que cronometra cada invocación; un map sobre los elementos de un Stream que aplica una acción async a cada uno. En todos, el cuerpo hace f(arg).await y decide qué envolver alrededor. Sin AsyncFn, cada uno de esos combinadores tendría que declarar a mano el Fn(...) -> impl Future con sus bounds de por vida, y se atascaría en cuanto el future necesitara prestar del entorno del closure.
Por qué AsyncFn y no Fn que devuelve impl Future
Antes de la edición 2024 no había sintaxis async ||. El apaño era un closure corriente cuyo cuerpo era un bloque async: |x| async move { ... }, con tipo impl Fn(X) -> impl Future. Funcionaba para casos simples, pero chocaba contra un muro en cuanto el future devuelto necesitaba tomar prestado del propio closure:
// El viejo apaño: un Fn que devuelve un future.
fn hazlo<F, Fut>(f: F) where F: Fn(u32) -> Fut, Fut: Future<Output = ()> {
// ...
}
El problema es de expresividad de tipos. En Fn(u32) -> Fut, el tipo Fut del future es fijo e independiente del préstamo de &self del closure; no hay forma de decir “el future que devuelvo vive prestado de lo que el closure capturó”. Por eso, un closure que capturaba un Vec y quería que su future lo leyera por referencia no compilaba: el future no podía atarse a la vida del &self. AsyncFn resuelve esto porque su future asociado está parametrizado por el préstamo de la llamada —conceptualmente, un tipo asociado genérico sobre la vida de &self—, de modo que el future sí puede sostener referencias al entorno capturado.
Fn() -> impl Future no está muerto: sigue siendo válido y a veces preferible, sobre todo cuando el future devuelto es 'static —no toma prestado nada del closure— y quieres almacenarlo, o cuando trabajas con objetos de trait y necesitas nombrar el future. La ganancia de AsyncFn es específica: habilita el préstamo del entorno capturado por el future, el caso que el patrón viejo no podía expresar. Regla: para closures async que prestan de sus capturas, AsyncFn; para futures independientes y 'static, cualquiera de los dos.
Pecado uno: bloquear el hilo del runtime
Ahora los dos errores que definen a quien todavía no ha interiorizado el modelo. El primero: meter trabajo bloqueante o de CPU largo dentro de una tarea async. Un .await cede el hilo; una llamada bloqueante, no. Mientras ese trabajo corre, el hilo trabajador del runtime queda congelado y ninguna otra tarea que dependa de él avanza, aunque su E/S ya esté lista.
async fn manejador_malo() {
std::thread::sleep(Duration::from_secs(1)); // CONGELA el hilo del runtime
let datos = std::fs::read("archivo").unwrap(); // E/S sincrona: bloquea igual
let h = hash_lentisimo(&datos); // bucle de CPU: bloquea igual
}
Cada una de esas tres líneas es un pecado: thread::sleep en vez de tokio::time::sleep, lectura síncrona en vez de tokio::fs, y un cálculo largo sin ceder. El remedio depende del tipo de trabajo: para esperas, usa la versión async; para cómputo pesado inevitable, delégalo a un pool aparte con spawn_blocking, que lo saca del hilo del executor.
El síntoma de este pecado es escurridizo porque no hay error de compilación: el servicio simplemente se vuelve a ratos irresponsivo, con latencias que se disparan bajo carga sin causa aparente. La pista reveladora es que las tareas vecinas —las que comparten hilo trabajador con la que bloquea— se atascan a la vez, en ráfagas. Herramientas como tokio-console hacen visible el problema mostrando tareas que monopolizan el hilo sin ceder; sin ellas, el diagnóstico se apoya en la disciplina de sospechar de toda llamada síncrona dentro de una async fn.
async fn manejador_bien() {
tokio::time::sleep(Duration::from_secs(1)).await; // cede el hilo
let datos = tokio::fs::read("archivo").await.unwrap(); // E/S async
let h = tokio::task::spawn_blocking(move || hash_lentisimo(&datos)).await.unwrap();
}
Pecado dos: sostener un lock a través de un await
El segundo pecado es más insidioso porque a veces compila y a veces no, y en ambos casos hace daño. Consiste en adquirir el guard de un Mutex y mantenerlo vivo cruzando un .await:
async fn incrementa_mal(m: &std::sync::Mutex<u64>) {
let mut g = m.lock().unwrap(); // adquiere el lock
*g += 1;
guardar_en_db(*g).await; // g SIGUE VIVO durante toda la espera
} // g se suelta aqui, demasiado tarde
Esto tiene dos consecuencias, ambas malas. Primera, de corrección concurrente: mientras la tarea está cedida en guardar_en_db().await —que puede tardar milisegundos—, nadie más puede tomar el lock; has serializado a todas las tareas a través de la espera más lenta, y si el propio código de dentro intenta reentrar, tienes un deadlock. Segunda, de tipos: el guard de std::sync::Mutex es !Send, así que un future que lo mantiene vivo a través de un .await se vuelve !Send, y tokio::spawn —que exige Send— lo rechaza en compilación con el célebre error “future cannot be sent between threads safely”. El arreglo es acotar la sección crítica: toma el lock, haz lo mínimo, suéltalo antes del .await.
async fn incrementa_bien(m: &std::sync::Mutex<u64>) {
let valor = {
let mut g = m.lock().unwrap();
*g += 1;
*g
}; // g se suelta AQUI, antes de cualquier espera
guardar_en_db(valor).await; // ya no hay lock vivo
}
El bloque interno no es cosmético: delimita con precisión la vida del guard, que se dropea al cerrar la llave, antes del .await. Extraes de la sección crítica solo el valor que necesitas llevarte, y la espera ocurre ya sin lock. El deadlock más traicionero aparece incluso en un runtime de un solo hilo: si mantienes el guard vivo y el guardar_en_db().await cede a otra tarea que intenta tomar el mismo lock, esa tarea se bloquea, el hilo no puede volver a la primera para que lo suelte, y el programa se congela sin un solo hilo extra de por medio. Acotar la sección crítica cierra esa puerta por construcción.
Existe tokio::sync::Mutex, cuyo lock().await es async y cuyo guard sí es Send, pensado para los raros casos en que de verdad debes sostener el lock a través de un .await. Pero no es la solución por defecto: es más lento que el de std y sigue serializando la sección crítica a través de la espera. La recomendación de la propia Tokio es preferir std::sync::Mutex acotando la sección crítica —soltar antes del .await—, y reservar el mutex async solo cuando el diseño exija mantener el lock mientras se espera. Casi siempre, si crees necesitar eso, lo que necesitas es reestructurar (pasar mensajes, mover el estado a una tarea dueña).
async || y AsyncFn
Closures async de la edición 2024. Tres traits (Once/Mut/Fn) como en los síncronos. Llamar da un future que se .await.
Prestan del entorno
Su ventaja sobre Fn() -> impl Future: el future devuelto puede tomar prestado de lo capturado, lo que el patrón viejo no expresaba.
No bloquees el runtime
Trabajo síncrono o de CPU congela el hilo trabajador. Usa la versión async, o delega con spawn_blocking.
No cruces await con un lock
Sostener un guard a través de un .await serializa, arriesga deadlock y vuelve el future !Send. Suéltalo antes.
flowchart TB L[Lock adquirido guard vivo] --> AW[await en medio de la seccion critica] AW --> P1[La tarea cede con el lock tomado] P1 --> P2[Otras tareas no pueden entrar serializa o deadlock] P1 --> P3[Si el guard no es Send el future no es Send no spawnea] AW --> FIX[Arreglo suelta el guard antes del await] FIX --> OK[Seccion critica corta el future vuelve a ser Send] style AW fill:#f38ba8,color:#11111b style P2 fill:#f38ba8,color:#11111b style P3 fill:#fab387,color:#11111b style OK fill:#a6e3a1,color:#11111b
Los dos errores que cierran este nivel parecen no tener relación —uno es de cómputo, otro de sincronización—, pero brotan de la misma raíz, y verla convierte dos reglas memorizadas en un solo principio. El modelo async de Rust descansa en un pacto cooperativo: unos pocos hilos trabajadores se reparten miles de tareas, y el sistema entero funciona solo si cada tarea cede con frecuencia, soltando el hilo en cada .await para que las demás avancen. Ese hilo trabajador es un bien común, escaso y compartido; toda la escalabilidad del nivel de fundamentos —diez mil tareas sobre ocho hilos— depende de que nadie lo acapare. Los dos pecados son, exactamente, dos maneras de romper el pacto acaparando algo que no era tuyo. Bloquear el hilo lo acapara en el sentido literal: un thread::sleep o un bucle de CPU se queda el hilo sin cederlo, y mientras tanto las otras tareas que ese hilo debía atender se mueren de hambre, no porque su trabajo no esté listo, sino porque no hay quien lo recoja. Sostener un lock a través de un .await acapara de forma más sutil: la tarea sí cede el hilo, pero se va con la llave en el bolsillo, y todas las que necesitan esa llave quedan bloqueadas durante toda la espera, convirtiendo una sección crítica de microsegundos en una de milisegundos y estrangulando la concurrencia justo donde creías tenerla. En ambos casos el compilador te avisa solo a medias —te rechaza el !Send, pero te deja bloquear el hilo sin una palabra— porque estos no son errores de seguridad de memoria, que Rust caza siempre, sino errores de buena ciudadanía en un sistema cooperativo, que Rust no puede detectar por ti porque dependen de cuánto tarda el mundo real. Por eso la regla última del async no está en el sistema de tipos sino en el juicio del programador, y se resume en una sola frase que engloba a las dos: no acapares el hilo, ni con trabajo que no cede ni con llaves que no sueltas. Ceder a tiempo y soltar a tiempo son la misma virtud, y en ella se apoya todo lo demás.
La edición 2024 estabilizó los closures async: async || { ... } y los traits AsyncFn, AsyncFnMut y AsyncFnOnce, análogos a los síncronos. Su ventaja sobre Fn() -> impl Future es que el future devuelto puede tomar prestado del entorno capturado, lo que el patrón viejo no podía expresar. Los dos pecados del runtime: bloquear el hilo (trabajo síncrono o de CPU congela al executor; usa la versión async o spawn_blocking) y sostener un lock a través de un .await (serializa, arriesga deadlock y vuelve el future !Send; suelta el guard antes de esperar). Ambos rompen el pacto cooperativo: ceder a tiempo y soltar a tiempo.
- Escribe una
fngenérica con boundAsyncFn(u32) -> u32, llámala con un closureasync |x| { ... }que tenga su propio.awaitdentro, y espera cada resultado. - Intenta capturar un
Vecpor referencia en un closure async y usarlo dentro del future devuelto; comprueba queAsyncFnlo permite y razona por qué el patrónFn() -> impl Futuredaba problemas. - Mete un
std::thread::sleepde dos segundos en una tarea sobre un runtimecurrent_thready observa cómo se congelan las demás; cámbialo portokio::time::sleepy luego porspawn_blockingpara un cálculo de CPU. - Escribe la versión “mala” que sostiene un guard de
std::sync::Mutexa través de un.awaitdentro de untokio::spawny lee el error deSend; arréglalo acotando la sección crítica. - Explica, en una frase que englobe ambos pecados, por qué “no bloquear el hilo” y “no cruzar un
.awaitcon un lock” son la misma virtud del pacto cooperativo.