Manejo de cancelación en Rust asíncrono
(sunshowers.io)- El manejo de cancelación en entornos de Rust asíncrono es conveniente, pero si se maneja mal puede provocar bugs inesperados y dificultades
- En Rust síncrono se necesitan cosas como verificar flags explícitos o terminar el proceso, pero en Rust asíncrono la cancelación es muy fácil: basta con hacer drop del future
- La seguridad de cancelación (cancel safety) y la corrección de cancelación (cancel correctness) son conceptos distintos, y la cancelación de un future puede causar problemas en todo el sistema
- Entre los principales patrones problemáticos relacionados con la cancelación están Tokio mutex, la macro
select,try_joiny errores al usar futures - No existe una solución perfecta, pero se pueden reducir los problemas causados por la cancelación usando APIs seguras ante cancelación, fijando futures con pin y separando tasks
Introducción
- Esta publicación se basa en una charla de RustConf 2025 sobre cancelación en Rust asíncrono
- En ejemplos comunes de código asíncrono en Rust, al agregar un timeout a un bucle de recepción o envío de mensajes, a menudo se descubre el problema de pérdida de mensajes
- Se abordan problemas de cancelación y casos reales de bugs surgidos al usar Rust async en sistemas grandes del mundo real, como en Oxide Computer Company
- El texto está dividido en tres partes: 1) el concepto de cancelación, 2) el análisis de la cancelación y 3) soluciones prácticas
- El autor ha experimentado tanto las ventajas como las dificultades de Rust asíncrono a través de trabajos como el manejo de señales en Rust y el desarrollo de cargo-nextest
1. ¿Qué es la cancelación?
Significado de la cancelación
- La cancelación (cancellation) es la situación en la que se inicia una operación asíncrona y luego se interrumpe a mitad de camino
- Ejemplos: descargas grandes, solicitudes de red, lectura parcial de archivos, etc., que pueden cancelarse en medio del proceso
Cómo se cancela en Rust síncrono
- En general existen métodos como verificar periódicamente si hubo cancelación mediante un flag atómico, usar excepciones especiales (
panic) o forzar la terminación del proceso completo - Algunos frameworks (como Salsa) usan el payload de
panic, pero eso no funciona en todas las plataformas de Rust, especialmente en entornos como Wasm - Forzar la terminación de solo un hilo no está permitido por las garantías de seguridad de Rust ni por la estructura de los mutex
- En resumen, en Rust síncrono no existe un protocolo de cancelación general y seguro
Rust asíncrono: ¿qué es un Future?
- Un Future es una máquina de estados (state machine) generada por el compilador de Rust y no es más que datos en memoria
- No se ejecuta solo por crearse, y solo avanza cuando se llama a
awaitopoll - Los futures en Rust son pasivos (inert) y no hacen ningún trabajo sin un
poll/awaitexplícito - Esto contrasta con Go/JavaScript/C#, donde al crear un future o equivalente normalmente la ejecución comienza de inmediato
El protocolo de cancelación en Rust asíncrono
- Cancelar un Future consiste simplemente en hacerle
dropo dejar de llamar apoll/await - Como es una máquina de estados, el Future puede descartarse en cualquier momento
- En Rust asíncrono, la cancelación es muy poderosa y al mismo tiempo muy fácil de aplicar
- Sin embargo, es demasiado fácil que un future se descarte silenciosamente, cancelando en cadena a sus futures hijos según el modelo de ownership
- Por esta característica, la cancelación se convierte en un fenómeno no local (non-local) que afecta toda la cadena de llamadas
2. Análisis de la cancelación
Seguridad de cancelación y corrección de cancelación
- Seguridad de cancelación (cancel safety): propiedad de que un future individual pueda cancelarse de forma segura y sin efectos secundarios
- Ejemplo: el future
sleepde Tokio es seguro ante cancelación - En cambio,
sendde MPSC en Tokio corre el riesgo de perder mensajes al hacerdrop(no tiene seguridad de cancelación)
- Ejemplo: el future
- Corrección de cancelación (cancel correctness): propiedad global de que el sistema completo mantenga sus propiedades esenciales en situaciones de cancelación
- Si un future no seguro ante cancelación no existe dentro del sistema, no hay problema de corrección
- Los problemas solo aparecen cuando un future no seguro ante cancelación realmente necesita ser cancelado
- Si la cancelación provoca pérdida de datos, violación de invariantes o limpieza omitida, entonces se viola la corrección de cancelación
Las dificultades de Tokio mutex
- Tokio mutex funciona tomando el lock, corrigiendo datos y luego liberándolo
- Problema: si dentro del lock se viola temporalmente el estado (por ejemplo, cambiando
Option<T>aNone) y luego se pasa por unawait, si el future se cancela el dato puede quedar fijado en un estado incorrecto - En trabajo real (por ejemplo, gestión de estado de sled en Oxide), se han producido estados inestables por cancelación en puntos de
await - Así, la cancelación en el manejo de estado de código asíncrono puede convertirse en una fuente muy peligrosa de fallas
Patrones y ejemplos de cuándo ocurre cancelación
- Llamar un future sin
.await: Rust advierte sobre futures no usados, pero si el valorResultse recibe como_, puede no haber advertencia (se necesita el lint más reciente de Clippy) - Operaciones
trycomotry_join: si un future falla, los demás se cancelan (esto ha llevado a bugs en lógica real de detención de servicios) - Macro
select: procesa varios futures en paralelo y luego cancela todos los futures salvo el que terminó primero (en bucles conselect, esto aumenta el riesgo de pérdida de datos) - Estos patrones se mencionan en la documentación, pero en la práctica la cancelación asíncrona puede ocurrir implícitamente en muchos lugares
3. ¿Qué se puede hacer?
- Todavía no existe una solución fundamental y completa para los problemas relacionados con la corrección de cancelación
- Aun así, en la práctica se puede reducir la probabilidad de fallas por cancelación con métodos como los siguientes
Reestructurar con futures seguros ante cancelación
- Ejemplo de
MPSC send: separar la reserva (reserve) del envío real (send) permite obtener seguridad parcial ante cancelación- Aunque se cancele la operación de reserva, el mensaje correspondiente no se pierde
- Una vez obtenido el permit, se puede enviar sin preocuparse por la cancelación
write_alldeAsyncWrite: escribir todo el buffer conwrite_alles inestable ante cancelación, mientras quewrite_all_bufpermite rastrear el progreso usando el cursor del buffer- Dentro de un bucle, con
write_all_bufse puede reanudar de forma segura el progreso parcial
- Dentro de un bucle, con
Operar futures evitando su cancelación
future pinning: en bucles conselecty casos similares, se puede fijar un future con pin para esperarlo mediante referencia ypollsin cancelarlo- Ejemplo: si se reutiliza un future de
reserve, se conserva el turno de espera de la reserva
- Ejemplo: si se reutiliza un future de
- Uso de tasks: si un future se ejecuta como task con
tokio::spawn, aunque se hagadropdel handle, la task en sí sigue administrada por el runtime y no se cancela a la fuerza- En el servidor HTTP Dropshot de Oxide, por ejemplo, cada request se ejecuta en una task separada, de modo que incluso si el cliente se desconecta se garantiza que el procesamiento de la request termine
¿Una solución sistemática?
- Hoy en día, a nivel de safe Rust, las opciones son limitadas, pero hay enfoques en discusión
Async drop: permitir ejecutar código de limpieza asíncrono cuando se cancela un future- Tipos lineales (linear types): forzar que se ejecute cierto código al hacer
drop, o marcar ciertos futures como no cancelables
- Todos estos enfoques presentan dificultades de implementación
Conclusión y recomendaciones
- Es fundamental entender que los futures son pasivos (passive)
- Es necesario conocer los conceptos de seguridad de cancelación (cancel safety) y corrección de cancelación (cancel correctness)
- Conviene identificar los principales casos de bugs y patrones de código relacionados con cancelación para preparar estrategias de respuesta con anticipación
- Algunas recomendaciones prácticas
- Evitar usar Tokio mutex y considerar alternativas
- Diseñar o aprovechar APIs parcialmente completas o seguras ante cancelación
- Para futures no seguros ante cancelación, adoptar estructuras de código que garanticen su finalización
- Además, se recomienda revisar temas más avanzados como cooperative cancellation, modelo de actores, structured concurrency, panic safety y mutex poisoning
- El material relacionado puede consultarse en sunshowers/cancelling-async-rust
Gracias por leer. El autor agradece a sus colegas de Oxide por revisar la charla y los materiales de referencia, y por brindar comentarios
1 comentarios
Comentario de Hacker News
send/recv; aprendí que en lenguajes donde un future se ejecuta sin polling inmediato aun cuando no se haya ejecutado, incluso puede darse la situación opuesta. Si pones timeout ensend, el mensaje puede enviarse incluso después del timeout, pero no se pierde, así que es seguro; en cambio, si pones timeout enrecv, puede ocurrir que se lea el mensaje del canal y luego se elija el timeout, tirando simplemente el mensaje, así que puede no ser seguro. La solución es seleccionar entre el timeout o que “haya algo disponible” en el canal y, en este último caso, mirar los datos de forma segura conpeektry_joinse cancela por errorEn todos esos casos, es natural que el contexto se cancele y que el trabajo no se complete. Si el trabajo necesariamente debe terminar, basta con separarlo en una task independiente. Me pregunto si me estoy perdiendo algún matiz importante; yo entendía que el diseño de los futures justamente pretende que el trabajo pueda desaparecer por cancelación. Si alguien puede volver a explicar cuál es el problema, se agradecería
await, lo que queda son detalles técnicossafe/unsafeparece insinuar que algo es mejor o peor, pero lo deseable de un comportamiento de cancelación depende del caso. Por ejemplo, un future que espera una task lanzada conspawnsuele llamarse "cancellation safe", pero si al hacerdropla task sigue ejecutándose, se puede acumular trabajo innecesario y además retener locks o ports, lo cual puede ser problemático. En cambio, unspawn handleque detiene la task al hacerdropdirían que es "cancellation unsafe", pero es un patrón muy importante para limpiar tasks dependientesselecty otros primitives de concurrenciaawaites un punto potencial de retorno. Conviene evitar ponerawaitentre dos acciones que necesariamente deban ejecutarse juntas de forma atómicadno llegue a llamarse? ¿Porque se cancela enc? ¿O porque pasa algo arriba ena?awaity, aunque se pause entre ellos, de todos modos ambos deban ejecutarse al final. Por ejemplo, si cambias la DB y luego escribes un audit log, y ambas cosas tienen que ejecutarse sí o sí, ¿la única salida es poner un comentario de “do not cancel”?Futurede Rust, como la semántica de movimiento en C++, puede quedar en un estado inválido después de terminar. Como Rust tiene un diseño de coroutines sin stack, al implementar directamente una estructura async basada enpolltienes que administrar el estado tú mismo dentro de unstruct. Todo esto son trampas bastante comunes. Y recientemente, la cancelación en async Rust también añade una nueva variable al manejo de estado. Cuando desarrollaba la librería mea (Make Easy Async), si la cancel safety no era trivial, siempre lo documentaba, y recuerdo un caso donde una cancelación async descuidada causó problemas en el stack de I/O mea caso en redditFuture. Como.awaittoma posesión del future, no puedes hacerdrop(), y como el future es lazy, después de.awaitno me quedaba claro cómo funcionaba la cancelación. Luego investiguéselect!yAbortable()y lo entendí, pero si vuelve a dar esta charla, creo que quedaría perfecta si hiciera esa aclaración desde el principioasync drop