2 puntos por GN⁺ 2025-11-01 | 1 comentarios | Compartir por WhatsApp
  • Futurelock es un fenómeno de interbloqueo (deadlock) que ocurre cuando una tarea administra varios Future al mismo tiempo, pero uno de ellos necesita recursos de otro Future y ya no vuelve a ser sondeado
  • Puede ocurrir fácilmente en tokio::select! cuando se usan juntos un Future referenciado (&mut future) y una rama que incluye await
  • Este problema surge por una falla al separar responsabilidades entre la tarea y el Future: la misma tarea espera a ambos Future, pero solo sigue sondeando uno de ellos, lo que lleva a un estado de bloqueo
  • También puede aparecer de forma similar con FuturesUnordered, bounded channel, Stream, etc.
  • La clave para un diseño asíncrono seguro es separar el Future en una tarea distinta con tokio::spawn o evitar usar await dentro de select

Concepto y ejemplo de Futurelock

  • Futurelock ocurre cuando el Future A tiene un recurso que el Future B necesita, pero la tarea que se encarga de ambos Future deja de sondear A
  • En el código de ejemplo, dentro de tokio::select! se espera simultáneamente por &mut future1 y sleep; si sleep termina primero, future1 permanece esperando el bloqueo
  • Después, future3 solicita el mismo lock, pero el lock fue asignado a future1 y como future1 ya no es sondeado, el programa queda detenido para siempre

Interacción entre tokio::select! y Mutex

  • tokio::sync::Mutex es un lock justo (fair), que concede el lock en orden de espera
  • El lock se entrega a future1, pero la tarea ya solo está sondeando future3, así que future1 no se ejecuta
  • El Mutex solo se encarga de despertar a la siguiente tarea en espera, y no puede saber qué Future está siendo sondeado realmente

Causas generales de Futurelock

  • Una estructura de dependencia circular donde la tarea T espera al Future F1, F1 depende de F2, y F2 a su vez necesita que T lo sondee nuevamente
  • Suele ocurrir sobre todo en estas situaciones
    • usar &mut future en tokio::select! y luego ejecutar await en otra rama
    • realizar otro trabajo asíncrono después de que algunos Future terminen en FuturesOrdered o FuturesUnordered
    • comportamientos similares en Future implementados manualmente

Casos en Streams y otras estructuras

  • En FuturesOrdered o FuturesUnordered, puede producirse Futurelock al extraer un Future y luego esperar otro Future que usa recursos relacionados con él
  • join_all no produce Futurelock porque sigue sondeando todos los Future continuamente

Casos reales y depuración

  • En el caso de Omicron#9259, todos los Future de acceso a base de datos quedaron atrapados en Futurelock y las solicitudes HTTP esperaban indefinidamente
  • El envío por canal mpsc estaba bloqueado, pero del lado receptor parecía estar vacío, lo que dificultó identificar la causa
  • Herramientas como tokio-console pueden ayudar al depurar, pero en la mayoría de los casos seguir la causa raíz es muy difícil

Guía para evitar Futurelock

  • Cuando una tarea sondea varios Future, hay que tener cuidado de no dejar de sondear un Future que ya fue iniciado
  • Siempre que sea posible, ejecutar el Future en una tarea nueva con spawn para que corra de forma independiente
    • Si se pasa un JoinHandle a tokio::select!, se elimina el riesgo de Futurelock
  • Puntos a cuidar al usar tokio::select!
    • no usar &mut future y await al mismo tiempo
    • si ambas condiciones están presentes, el riesgo de Futurelock es alto
  • Al usar Stream, emplear JoinSet para ejecutar cada Future en una tarea separada
  • Aumentar la capacidad de un bounded channel no es una solución de fondo
    • en su lugar, se puede usar try_send() para evitar el bloqueo

Patrones de evasión incorrectos

  • Aumentar indefinidamente la capacidad del canal es poco realista y provoca efectos secundarios como latencia y mayor uso de memoria
  • Intentar eliminar dependencias entre Future es frágil, porque durante el mantenimiento pueden aparecer nuevas dependencias
  • El único método realmente seguro es la separación de tareas mediante tokio::spawn

Mejoras futuras y consideraciones de seguridad

  • Se plantea la posibilidad de que un lint de Clippy advierta sobre el uso de &mut future dentro de tokio::select! o sobre la presencia de await
  • Futurelock puede explotarse como una forma de denegación de servicio (DoS), pero como en esencia es un comportamiento incorrecto, debe prevenirse

1 comentarios

 
GN⁺ 2025-11-01
Comentarios de Hacker News
  • Al revisar el documento por encima, me pareció un informe bastante transparente y exhaustivo
    En especial, la sección de notas al pie me resultó interesante
    Me impresionó que mucha gente no conociera el problema de cancellation safety en Rust, y que sea muy probable que este tipo de problema esté extendido en todo Omicron
    Es irónico que se eligiera Rust para evitar los problemas de seguridad de memoria de C, y que esta vez aparezcan bugs de cancelación difíciles de detectar en tiempo de ejecución
    Resultó especialmente frustrante que el programador tenga que garantizar por sí mismo propiedades dinámicas en las que el compilador no puede ayudar

    • Me hace pensar si no hará falta una capa de abstracción superior para evitar este tipo de problemas
      Parece que incluso en el modelo de concurrencia de Rust sigue existiendo la posibilidad de deadlock
      Uno pensaría que la gestión de recursos estilo RAII debería impedir algo así, pero en la práctica no es así, y eso confunde
      Me pregunto si esto es solo una casualidad de implementación o una limitación estructural del modelo Rust/Tokio
  • Esto parece una variación sutil del deadlock explicado en el post de withoutboats sobre FuturesUnordered
    Cuando se usa concurrencia “intra-task”, hay que cuidar que ningún future caiga en inanición
    En general, hacer spawn de tasks es lo más seguro, y conviene manejar los timeouts con tokio::select!, pero administrando dentro de ahí todos los futures pendientes
    Realmente no recomendaría FuturesUnordered a menos que se prueben absolutamente todos los casos límite

  • Suena parecido a un problema de inversión de prioridad (priority inversion)
    En los sistemas operativos, si un hilo de baja prioridad tiene un lock y uno de alta prioridad queda esperando, el de baja hereda la prioridad para poder ejecutarse
    Me pregunto si algo similar podría aplicarse en Tokio; por ejemplo, si un future no ejecutable está reteniendo un Mutex, hacer poll de ese future en su lugar
    Pero detectar el estado de “no ejecutable” probablemente tendría un costo considerable

    • Este enfoque quizá podría funcionar a nivel de task en Tokio
      Pero no puede aplicarse a los futures dentro de una task
      Eso se debe al diseño básico de async Rust: futures are inert — un future es solo una estructura, y el runtime no conoce su interior
      Lo único que conoce el runtime es la task como unidad; no rastrea en absoluto el estado de los futures internos

    • El async de Rust usa un modelo de stackless coroutine, así que no es seguro reanudar arbitrariamente la ejecución de una función async que ya está corriendo
      El modelo stackless guarda el estado local en una pila compartida, así que solo puede ejecutarse de forma segura en orden LIFO
      Por eso hace falta coloring, y no se puede hacer yield libremente como con las stackful coroutines

    • El código se siente demasiado complejo
      Se ve mucho más verboso que en Erlang, Elixir, Go e incluso C

    • Creo que esto se parece a un deadlock básico de dos locks
      La cola de espera del Mutex de Tokio y la planificación de tasks se entrelazan y terminan creando el bloqueo
      Con un Mutex del sistema operativo, probablemente se habría resuelto despertando a otro hilo en espera, pero en async Rust eso parece difícil por la estructura de máquina de estados de los futures
      Tal vez podría resolverse haciendo poll secuencialmente de los futures en la cola de espera, pero eso también podría traer efectos secundarios inesperados

  • He vivido problemas de este tipo en el ecosistema async de Rust
    Si select! no permitiera usar referencias, se podría evitar este problema, pero entonces sería imposible el patrón de hacer select! repetidamente sin perder la posición en la cola
    Junto con los problemas de cancelación, esto puede convertirse en una trampa inesperada incluso para expertos en Rust
    Aun así, hay muchas menos sorpresas que con código basado en callbacks

    • Sí, después de analizar este deadlock, nuestro equipo también discutió “¿cómo podríamos haber prevenido esto?”, pero terminamos concluyendo que no fue culpa de nadie
      Todos los primitivos de Tokio funcionaron como se esperaba, y el código estaba correctamente escrito, pero la interacción entre ellos produjo un deadlock inesperado
      Se podría impedir prohibiendo &mut future en select!, pero eso también bloquearía mucho código legítimo
      Al final llegamos a la amarga conclusión de que es una de esas cosas con las que “simplemente hay que tener cuidado”
      La discusión relacionada continúa también en este comentario

    • Si select! devolviera los futures no seleccionados sin hacerles drop, sería posible no perder el estado
      Aun así, eso es incómodo y no es una solución de fondo
      La causa real está en la imperfección del manejo de cancelación, tal como se explica en este hilo

  • Me pareció interesante la pregunta del FAQ: “¿future1 no se cancela?”
    En la cancelación hay dos etapas: dejar de hacer poll y drop
    En este ejemplo, el drop se retrasa y el guard sigue retenido, lo que provoca efectos secundarios
    Me pregunto si sería posible garantizar que ambas acciones siempre ocurran al mismo tiempo

  • Me gustaría preguntarles a los diseñadores de Rust por qué eligieron el patrón async en lugar del modelo de actores
    Después de usar Erlang, el modelo de actores se siente mucho más limpio y seguro
    En JS no quedaba otra por la estructura del lenguaje, pero Rust era un lenguaje nuevo, así que me intriga por qué tomó ese camino

    • Una razón importante del diseño async de Rust fue el soporte para entornos embebidos
      Como tenía que funcionar sin malloc ni threads, el modelo de actores no era viable
      Se puede escribir código estilo actor con Tokio, pero no resulta natural

    • Otra razón es el rendimiento
      El modelo de actores tiene un costo alto por copia de mensajes, y como Rust es un lenguaje de sistemas donde el rendimiento importa, se buscó una zero-cost abstraction con máquinas de estado async
      Erlang y Go son lenguajes que eligieron otros trade-offs

    • Como Rust no quería aceptar sobrecarga en llamadas C FFI, se descartó un modelo basado en green threads
      async/await se compila a máquinas de estado y tiene poco overhead
      Go también tuvo al principio problemas similares de inanición porque no tenía preemption, y más adelante su scheduler lo resolvió
      Al final, cada lenguaje tenía objetivos y restricciones distintas

    • También me sorprendió que Oxide adoptara async
      Es algo familiar en entornos embebidos o servidores HTTP, pero no esperaba que una empresa de sistemas como Oxide lo usara tan a fondo

  • La parte que no entendí al leer el documento fue por qué se despierta el hilo principal y no el future que tiene el lock
    Si el lock es justo, lo lógico sería que se despertara future1, así que me queda la duda de por qué el runtime eligió otro hilo

  • El artículo fue realmente interesante
    El código de ejemplo también estaba claro, y aunque encontrar un bug así debe ser una pesadilla, cuando por fin aparece da esa sensación satisfactoria de que las piezas del rompecabezas encajan

    • En nuestra empresa grabamos todas las reuniones y sesiones de depuración, y justo ese “momento en que encaja el rompecabezas” quedó registrado en video
      Fue impresionante ver a Eliza, Sean, John y Dave haciendo lluvia de ideas juntos hasta encontrar la causa
      El lunes vamos a publicar un episodio de podcast sobre esto
      El video relacionado puede verse en RFD 537 y en este enlace del evento
  • Me parece difícil de entender y propenso a errores que Rust no haga que todas las tasks activas avancen al mismo tiempo
    Algo como la structured concurrency de Trio en Python parecería mucho más intuitivo
    Me pregunto si Rust podría adoptar un modelo así

    • En Rust también se puede hacer structured concurrency, pero solo a nivel de task
      Un future no es más que una estructura que solo avanza si se le hace poll, así que no existe realmente el concepto de “future activo”
      Parece que hacer spawn de todo como task lo resolvería, pero incluso eso bloquearía algunos patrones útiles

    • La distinción entre task y future es importante
      Si no se hace poll de un future, no hace absolutamente nada
      Si se define cancelación como “un estado en el que no se hace poll hasta que ocurra drop”, entonces aparecen casos como este, donde un future queda detenido mientras sigue sosteniendo el lock
      Según la filosofía RAII de Rust, uno espera la limpieza al hacer drop, pero si el poll se detiene, ni siquiera eso ocurre

  • Últimamente me da la impresión de que el async de Rust tal vez se lanzó demasiado pronto

    • Yo también creo que hay mucho por mejorar, pero veo el diseño base como una fundación excelente
      Tal vez Pin o partes de la sintaxis puedan pulirse, pero no hace falta cambiar la estructura fundamental
      Todavía estamos en la etapa de “cimientos de una casa que aún no se termina”, no ante el resultado de algo apresurado
      Aun así, creo que hace falta una capa inferior adicional, como coroutines generalizadas