3 puntos por GN⁺ 2025-10-05 | 1 comentarios | Compartir por WhatsApp
  • 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_join y 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 await o poll
  • Los futures en Rust son pasivos (inert) y no hacen ningún trabajo sin un poll/await explí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 drop o dejar de llamar a poll/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 sleep de Tokio es seguro ante cancelación
    • En cambio, send de MPSC en Tokio corre el riesgo de perder mensajes al hacer drop (no tiene seguridad de cancelación)
  • 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> a None) y luego se pasa por un await, 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 valor Result se recibe como _, puede no haber advertencia (se necesita el lint más reciente de Clippy)
  • Operaciones try como try_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 con select, 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_all de AsyncWrite: escribir todo el buffer con write_all es inestable ante cancelación, mientras que write_all_buf permite rastrear el progreso usando el cursor del buffer
    • Dentro de un bucle, con write_all_buf se puede reanudar de forma segura el progreso parcial

Operar futures evitando su cancelación

  • future pinning: en bucles con select y casos similares, se puede fijar un future con pin para esperarlo mediante referencia y poll sin cancelarlo
    • Ejemplo: si se reutiliza un future de reserve, se conserva el turno de espera de la reserva
  • Uso de tasks: si un future se ejecuta como task con tokio::spawn, aunque se haga drop del 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

 
GN⁺ 2025-10-05
Comentario de Hacker News
  • Me parece muy interesante el ejemplo de poner timeout en 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 en send, el mensaje puede enviarse incluso después del timeout, pero no se pierde, así que es seguro; en cambio, si pones timeout en recv, 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 con peek
    • Estoy pensando que justo ese es el núcleo de la cancellation-safety
    • Me parece una muy buena observación
  • Quiero compartir algunos materiales que escribí sobre este tema
    • En 2020 escribí una propuesta sobre que las funciones async necesariamente deberían ejecutarse hasta el final; incluye cancelación elegante, y sigo pensando que todavía no ha aparecido una idea mejor enlace a la propuesta
    • También hay una propuesta para una cancelación unificada en Rust sync y async en general ("A case for CancellationTokens") enlace al gist
    • También existe una implementación real de lo anterior min_cancel_token
  • No entiendo bien cuál es el problema con que se cancelen los futures. Los futures no son tasks, y el mismo artículo reconoce internamente ese punto. Entonces, ¿no es justamente normal que un future no llegue a ejecutarse hasta el final? Y no entiendo por qué eso sería un problema. En el ejemplo dicen que es un future "cancel unsafe", pero creo que la clave es una confusión entre expectativa y realidad
    • Ejemplo 1: uno de los try_join se cancela por error
    • Ejemplo 2: al cancelarse no se escriben los datos
      En 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
    • ¡Es cierto! De hecho, en Oxide esto causó muchos bugs. Si entiendes bien que los futures pueden cancelarse de forma pasiva en cualquier await, lo que queda son detalles técnicos
  • En RustConf de verdad disfruté mucho esta charla. La distinción conceptual entre cancel safety y cancel correctness es muy útil, y qué bueno que la charla también salió como post de blog; la charla está bien, pero tenerlo escrito en blog hace que sea mucho más fácil de compartir y consultar
    • Me gusta la expresión "cancel correctness" porque enmarca bien el contexto de la cancelación; en cambio, no me gusta mucho el término "cancel safety". No encaja del todo con la idea de safety en Rust y suena innecesariamente valorativo. safe/unsafe parece 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 con spawn suele llamarse "cancellation safe", pero si al hacer drop la task sigue ejecutándose, se puede acumular trabajo innecesario y además retener locks o ports, lo cual puede ser problemático. En cambio, un spawn handle que detiene la task al hacer drop dirían que es "cancellation unsafe", pero es un patrón muy importante para limpiar tasks dependientes
    • Coincido, el post de blog se lee mejor
  • Me pareció especialmente interesante el contenido de https://sunshowers.io/posts/cancelling-async-rust/#the-pain-of-tokio-mutexes. Siento que yo también cometería ese tipo de error fácilmente
    • Aunque soy desarrollador de Go, esto también me sirve. Rust te ayuda más estrictamente con las herramientas, pero en Go es igual de fácil caer en las mismas trampas con goroutines, canales, select y otros primitives de concurrencia
  • En el primer ejemplo no está claro cuál es el comportamiento deseado. Si la cola está llena, hay que elegir entre descartar, esperar o hacer panic. Poner timeout a algo bloqueante suele ser para detectar deadlocks. El código dice que "no todos los mensajes llegan al canal", pero claro, si faltan recursos eso va a pasar. ¿Cuál es el objetivo? ¿Un cierre limpio del programa? Eso ya es bastante difícil en un entorno con threads, y tampoco es sencillo en async. Un caso de uso real sería el intercambio de mensajes con un remoto, donde al desconectarse la otra parte tienes que limpiar tu estado local
    • Idealmente, querrías guardar los mensajes en un búfer hasta que vuelva a haber espacio en el canal; esto se trata en la parte final de la charla, "What can be done"
    • El ejemplo sí responde eso: el código que registra cuando no hay espacio durante 5 segundos es para diagnóstico, pero corre el riesgo de provocar pérdida de datos de forma sutil. Es un poco artificial, pero en la práctica es fácil terminar pegando este tipo de código por todo el sistema para depurar cosas como “¿por qué no funciona?”
    • Por cierto, la autora de este texto usa los pronombres they/she about
  • Siempre hay que tener presente que await es un punto potencial de retorno. Conviene evitar poner await entre dos acciones que necesariamente deban ejecutarse juntas de forma atómica
    • Me da curiosidad cómo exactamente esto causa problemas, por ejemplo:
      async fn a() {
        b().await
      }
      async fn b() {
        c().await
        d().await
      }
      async fn c() {}
      async fn d() {}
      
      ¿De qué forma en este código podría ocurrir que d no llegue a llamarse? ¿Porque se cancela en c? ¿O porque pasa algo arriba en a?
    • Entonces, ¿esto no es un poco peligroso? Claro, quizá sea inevitable, pero puede haber situaciones donde dentro de una "critical section" haya dos await y, 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”?
  • El Future de 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 en poll tienes que administrar el estado tú mismo dentro de un struct. 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 reddit
  • ¡Fue una charla realmente buena! Como principiante total, me habría gustado que en la SOP se enfatizara desde antes que no se puede cancelar un Future. Como .await toma posesión del future, no puedes hacer drop(), y como el future es lazy, después de .await no me quedaba claro cómo funcionaba la cancelación. Luego investigué select! y Abortable() y lo entendí, pero si vuelve a dar esta charla, creo que quedaría perfecta si hiciera esa aclaración desde el principio
    • Pregunta: me da curiosidad qué significa SOP aquí
  • Llegó en el momento justo: hoy mismo estaba agregando a la documentación de una función nueva la nota "this function is cancel safe", y me puse a pensar en esto. Ojalá pronto exista async drop
    • Me da curiosidad qué función es, ¿podrías contar un poco más?