- 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
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
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 pendientesRealmente no recomendaría
FuturesUnordereda menos que se prueben absolutamente todos los casos límiteSuena 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 hacerselect!repetidamente sin perder la posición en la colaJunto 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 futureenselect!, pero eso también bloquearía mucho código legítimoAl 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 hacerlesdrop, sería posible no perder el estadoAun 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
dropse retrasa y el guard sigue retenido, lo que provoca efectos secundariosMe 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/awaitse compila a máquinas de estado y tiene poco overheadGo 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
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 lockSegú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
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