4 puntos por GN⁺ 2024-03-26 | 1 comentarios | Compartir por WhatsApp
  • async/await de Rust no es un simple reemplazo de los threads, sino un modelo de programación que expresa código concurrente centrado en I/O como máquinas de estado componibles
  • El código que debe manejar muchas conexiones al mismo tiempo, como un servidor web, encuentra límites si solo usa ejecución lineal; los threads permiten procesamiento concurrente al separar el manejo de clientes con thread::spawn
  • async/await cede la ejecución en los puntos await y permite que el ejecutor (executor) continúe ejecutando otras tareas, intercalando muchas tareas dentro de un mismo runtime
  • Requisitos como un timeout de 3 segundos pueden agregarse en async combinando race y Timer, pero en código síncrono con threads se necesita un wrapper específico para TcpStream y configurar timeouts de lectura/escritura, lo que reduce la generalidad
  • Explicar async solo por su overhead de rendimiento genera contraejemplos en trabajos CPU bound; la fortaleza de Rust async está en su expresividad semántica y la composibilidad del ecosistema

El punto de partida del problema de concurrencia en Rust

  • El código normal de Rust tiene, por defecto, una estructura de ejecución lineal
    • Como foo(), bar(), baz(): una tarea termina y luego se ejecuta la siguiente
  • Cuando se necesita manejar muchas cosas al mismo tiempo, como en un servidor web, la estructura lineal llega rápidamente a sus límites
    • En una estructura que recibe clientes con TcpListener::accept() y ejecuta handle_client(), el segundo cliente debe esperar mientras se procesa el primero
    • Si handle_client() tarda unos milisegundos y hay 2 clientes concurrentes, se produce una espera breve
    • Si hay 2 millones de clientes concurrentes, el usuario al final de la cola podría esperar varios minutos

Cómo lo resuelven los threads

  • Los threads del sistema operativo pueden guardar en memoria los valores de registros y la pila del programa, ejecutar otra rutina y luego reanudar la rutina original
  • El código de un servidor web delega el manejo de clientes a un thread separado con una forma como thread::spawn(move || handle_client(client))
    • El thread principal sigue haciendo accept() de nuevas conexiones
    • Si el thread que atiende al cliente queda bloqueado, el OS puede volver al thread principal para recibir la siguiente conexión
    • Dos clientes pueden ejecutarse en paralelo después de una latencia del orden de microsegundos
  • Si un servidor web de producción tiene decenas de núcleos de CPU, el OS no solo hace que parezca que los threads se ejecutan al mismo tiempo, sino que realmente puede ejecutar varios threads simultáneamente

Cómo funciona async/await

  • En concurrencia en espacio de usuario hay varios modelos, como programación basada en eventos, actores y corrutinas; el enfoque elegido por Rust es async/await
  • Simplificando, el programa se compila como un conjunto de máquinas de estado que pueden ejecutarse de forma independiente entre sí
    • Una async fn no es una función tradicional, sino una función que devuelve una máquina de estados
    • await incluye otra máquina de estados como una etapa de la máquina de estados actual
    • Cuando una función interna cede la ejecución, por ejemplo al esperar una nueva conexión, toda la máquina de estados entrega el control al ejecutor (executor) superior
  • Un ejecutor como smol::Executor ejecuta otra máquina de estados creada con spawn en lugar de la máquina de estados actual
    • El bloque async move { handle_client(client).await } es una nueva máquina de estados independiente de main
    • Cuando main cede, se ejecuta una de las tareas de cliente; cuando esa tarea vuelve a ceder, se pasa a la siguiente tarea
  • Con esta estructura se pueden manejar millones de clientes al mismo tiempo, pero también aumenta la complejidad por conceptos adicionales como ejecutor, tareas y máquinas de estado

La composibilidad que se ve en el ejemplo de timeout

  • Una de las fortalezas de Rust es la composibilidad
    • Iterator permite encadenar varios combinadores y pasar el resultado de nuevo a funciones que reciben un Iterator
    • Como en recv.try_iter().filter(...).map(...) de mpsc::channel(), se pueden filtrar y transformar valores para agregarlos a una lista
  • async/await permite aplicar esta composibilidad también a funciones I/O bound
  • Si handle_client() es una función asíncrona que hace await sobre read_to_end, do_something_with_data y write_all, un timeout de 3 segundos puede implementarse combinando dos Future
    • race ejecuta dos Future al mismo tiempo
    • Timer devuelve después de que transcurre el tiempo indicado
    • Basta con envolver el código existente de manejo de conexión en un bloque async y hacerlo competir contra un Future que devuelve un error TimedOut después de 3 segundos
  • Este enfoque no está atado solo a TcpStream
    • El mismo patrón puede aplicarse a cualquier objetivo que implemente impl AsyncRead + AsyncWrite
    • También pueden sustituirse objetivos como un stream GZIP sobre un stream común, sockets Unix o archivos

Restricciones al implementar el mismo timeout en código síncrono con threads

  • En código bloqueante, por lo general es difícil interrumpir llamadas del sistema como read o write, y métodos como cerrar el descriptor de archivo no pueden usarse en Rust
  • TcpStream ofrece set_read_timeout y set_write_timeout
    • Se puede configurar un timeout por separado para lectura y escritura
    • Pero si el cliente envía 1 byte cada 2,9 segundos, un timeout simple puede seguir reiniciándose
  • Para defenderse de eso, habría que crear un tipo como DeadlineStream que envuelva TcpStream, calcular en cada ocasión el tiempo restante hasta la deadline total y configurarlo como timeout de lectura/escritura
  • Este enfoque puede funcionar, pero tiene muchas limitaciones
    • Está atado a TcpStream
    • Rust no tiene un trait que abstraiga el uso de set_read_timeout y set_write_timeout
    • Aplicarlo a un writer genérico requiere mucho trabajo adicional
    • Introduce llamadas al sistema adicionales para configurar timeouts
    • En la lógica real de un servidor web puede ser más engorroso de usar

Casos del ecosistema async de Rust

  • Que el ecosistema HTTP, incluyendo clientes, haya adoptado async/await como mecanismo principal de runtime se debe a la composibilidad de las funciones
    • Las funciones que hacen llamadas HTTP pueden encajarse en distintos huecos y casos de uso
  • tower es un caso representativo que muestra la composibilidad de async/await
    • Si se implementa un servicio como una función async, se le pueden agregar timeouts, rate limiting, balanceo de carga, hedging y manejo de backpressure
    • Sin importar qué runtime se use ni qué haga internamente el servicio, tower puede aplicarse para aumentar su robustez
  • macroquad es un pequeño motor de juegos para Rust y usa async/await en la función principal para ejecutar el motor
    • async/await es adecuado para expresar situaciones en las que hay que detener una función lineal mientras se espera alguna tarea en Rust
    • Permite configuraciones como sondear al mismo tiempo, en el mismo thread, una conexión de red de un servidor de juego y un framework GUI

Límites de explicar async solo por rendimiento

  • El Rust Async Book compara que los threads del OS facilitan expresar concurrencia sin cambiar el modelo de programación, pero sincronizar entre threads es difícil, su overhead de rendimiento es grande y, aun con thread pools, es complicado soportar suficientemente cargas de trabajo I/O bound a gran escala
  • En la comunidad async existe una tendencia a responder a la pregunta de por qué usar async en vez de threads del OS con algo como “tiene menor overhead y el resto es igual”
  • La razón por la que quienes escribían servidores web pasaron a async/await fue resolver el problema C10k, pero no todos los usuarios tienen que elegir async/await por motivos de rendimiento
  • La ventaja de rendimiento puede desaparecer según la situación
    • En trabajos CPU bound, un flujo de trabajo basado en threads puede ser más rápido que un flujo async equivalente
    • La ventaja temporal de rendimiento de Rust async se ha enfatizado demasiado, mientras que sus beneficios semánticos se han subestimado
  • async/await no es una herramienta para casos de nicho, sino un modelo de programación potente para manejar patrones que serían difíciles de expresar de forma concisa en Rust síncrono sin decenas de threads y canales

Aceptar las diferencias en vez de hacerlo como Rust sync

  • En el roadmap del proyecto Rust existe la dirección de que escribir async Rust debería ser tan fácil como escribir código síncrono, salvo por usar ocasionalmente las palabras clave async y await
  • Pero también existe la visión de que enmarcar async Rust como “exactamente igual a sync Rust” es fundamentalmente difícil
    • Aunque pueda hacerse parecido en un 99%, el usuario promedio inevitablemente notará la diferencia
  • El ecosistema async/await de Rust, en lugar de intentar volverse igual al Rust síncrono, debería mostrar con más claridad sus fortalezas: composibilidad y expresividad
  • Para que async/await se convierta en la opción predeterminada cuando se necesita concurrencia, este modelo debe explicarse por razones semánticas más que por motivos técnicos de rendimiento

1 comentarios

 
GN⁺ 2024-03-26
Opiniones de Hacker News
  • async/await de un solo hilo es un modelo simple y bien conocido, y JavaScript funciona así
    Los hilos permiten poner varias CPU a trabajar en la resolución de un problema, y Rust ayuda con la gestión de bloqueos. También se pueden tener hilos con distintas prioridades, así que pueden ser necesarios para tareas limitadas por cómputo
    En cambio, async/await multihilo se vuelve desordenado. Si entran en serio secciones limitadas por cómputo, es fácil que el modelo se rompa, porque en la práctica bloquean un hilo compartido con otras tareas
    El multihilo limitado por cómputo en Rust no funciona tan bien como se esperaría. En el asignador de almacenamiento, varios hilos pueden golpear el mismo bloqueo y provocar colapso por congestión de futex; en especial, si durante la expansión de un búfer se vuelve a copiar todo mientras se mantiene bloqueado el asignador completo, resulta muy costoso. El asignador de la biblioteca dentro de la .DLL de emulación de bibliotecas de Microsoft en Wine era vulnerable a este problema: todo el tiempo de CPU se iba en spinlocks y el rendimiento caía por factores de dos dígitos, mientras que la implementación de Microsoft no tenía ese problema
    Además, el Mutex estándar y los canales de crossbeam-channel pueden sufrir inanición por mutex injusto. Si varios hilos repiten el ciclo de bloquear un recurso, trabajar y liberarlo, un hilo puede seguir ganando y los demás quedar relegados. Si se necesita un mutex justo, existe parking-lot, pero no tiene la seguridad de poisoning ante pánico de hilos que ofrece el mutex estándar
    Si no es limitado por I/O, se vuelve mucho más complejo
    https://users.rust-lang.org/t/mutex-starvation/89080

    • Correcto. Se trataba principalmente de cómputo limitado por I/O, pero incluso ahí aparecen problemas de contención
      Si el throughput de I/O vuelve a ser el límite, uno se pregunta qué sentido tiene tener un millón de corrutinas. Si agotas de inmediato un pool de 10 conexiones a la DB, las corrutinas no te salvan; solo hacen que depurar y esquivar el problema sea más difícil y más complicado de razonar
    • Me da la impresión de que quizá este problema termine teniendo que repensarse a nivel de hardware
      Los problemas limitados por CPU parecen reducirse a interrupciones/reanudaciones sistemáticas, y si fuera posible hacer cambios de contexto basados en colas, justos y eficientes, para n hilos de ejecución en curso —por ejemplo, una CPU con n contextos en curso—, me pregunto si el problema no podría convertirse en un problema de asignación de recursos
    • No entiendo por qué se siguen redescubriendo las dificultades del multitasking cooperativo
      Incluso Go, que considero un lenguaje diseñado con responsabilidad, al principio fue por el camino cooperativo y al final no tuvo más remedio que cambiar a uno preventivo. No significa que el multitasking cooperativo sea inútil, pero debería llevar una etiqueta de advertencia; más aún, quizá convenga impedir estáticamente la ejecución de ciertos tipos de código
      Dejo como lectura relacionada “What color is your function”
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • Hay que repetirlo una y otra vez: esto es un detalle de implementación
      Un ejecutor multihilo de async/await puede manejar suficientemente bien la inanición, y la implementación de .NET aguanta incluso código muy malo que mezcla llamadas bloqueantes con código asíncrono
      https://news.ycombinator.com/item?id=39530435
      https://news.ycombinator.com/item?id=39786142
      https://news.ycombinator.com/item?id=39721626
    • Las funciones con color que genera async/await también agregan costos de desarrollo y mantenimiento de software
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
      Si no se trata de software que necesite alta escalabilidad, puede que las concesiones de async no valgan la pena
  • El núcleo del debate entre async/await e hilos no es cuál de los dos es más complejo, sino que parte el ecosistema en dos y deja a uno de los lados como ciudadano de segunda, de modo que si eliges mal en un proyecto aparece fricción.
    Se pueden mezclar, pero cuando hace falta resulta hacky e ineficiente. Hoy, el ecosistema de Rust está básicamente decidido de forma tal que, si hay I/O de por medio, todo queda atado al ecosistema async/await; y como casi todo lo que uno quiere hacer en Rust incluye I/O salvo raras excepciones, en general hay que ignorar las bibliotecas que no son asíncronas, quiera o no async el resto de la aplicación.
    Si Rust hubiera usado una abstracción más componible que async/await, y esa componibilidad no exigiera convertir también todo lo demás a async/await, creo que desaparecería la mayor parte del descontento.

    • Estoy de acuerdo con el diagnóstico. En mi artículo sobre async en Rust[0] llegué a la misma conclusión.
      Lo peor es que el ecosistema no solo se divide en dos: incluso dentro del código async normalmente quedas fuertemente atado a un ejecutor, por lo general Tokio. Si extendemos el problema de los colores de funciones, en vez de azul (sin I/O), verde (I/O bloqueante) y rojo (I/O async), en la práctica termina siendo azul, verde, rojo (Tokio), morado (async-std), naranja (smol), etc.
      Creo que la mejor solución a este problema es el patrón sans-I/O. Si separas todo el código azul y usas inversión de control para la I/O y el tiempo, puedes hacer que la lógica central del protocolo no sepa nada de la I/O y sea fácil envolverla con distintas formas de I/O.
      0: https://hugotunius.se/2024/03/08/on-async-rust.html
    • Solo deberías ignorar una biblioteca no asíncrona cuando haya dos bibliotecas disponibles y todo lo demás sea igual. Esos casos son raros.
      Usar código bloqueante en una aplicación async no es tan fluido como uno esperaría, pero tampoco es difícil. En vez de foo(), usas tokio::spawn_blocking(foo).await, y eso ejecuta el código nuevo en un hilo separado y devuelve un future que se completa cuando ese hilo termina.
    • C# es básicamente similar.
      Para I/O sí hay opciones no asíncronas, pero si usas la opción asíncrona, en la práctica te obliga a que todo sea async hasta Main(). También hay formas de llamar de manera segura a métodos async desde métodos síncronos, pero hacen que depurar sea extremadamente difícil.
  • Al artículo le faltan muchas partes.
    Como async/await se ejecuta en el contexto de un solo hilo, no necesita bloqueos ni sincronización; pero si ejecutas async/await en varios hilos para aprovechar los núcleos de CPU, los bloqueos y la sincronización vuelven a ser necesarios. Esta complejidad puede ocultarse en código externo. Por ejemplo, en vez de sincronizar el acceso a una única conexión de BD, es más fácil abrir una conexión de BD por cada tarea async, pero en SQLite o PostgreSQL eso puede afectar el rendimiento.
    En async/await, la propagación de errores no es clara, especialmente cuando intentas agrupar tareas async; Happy Eyeballs es el ejemplo típico.
    Si hablamos de I/O de red, también hay que tratar el backpressure. La implementación de async/await de CPython es famosa por carecer de suficiente backpressure de red, y eso trae problemas.

    • async/await tiene varios problemas, pero mi mayor queja es esta:
      “Design Patterns”, de Gang of Four, era en gran parte un recetario para rodear defectos de C++, pero la gente aplicó esos patrones incluso a lenguajes que no tenían esos defectos.
      Rust no es JavaScript y puede ejecutar bien varios hilos. Por eso no era imprescindible usar async/await; como lenguaje de sistemas, podría haber probado otras soluciones.
      Pero para empujar Rust hacia los programadores JavaScript hacía falta async/await. without.boats escribió que “impulsó async/await con la pasión honesta de quien asumía que la supervivencia de Rust dependía de esta funcionalidad”.
      https://without.boats/blog/why-async-rust/
      No importaba si async/await encajaba técnicamente bien con Rust; parece más bien que, como los programadores JavaScript estaban acostumbrados a async/await, Rust también tenía que tener async/await.
    • async/await también es un mecanismo de concurrencia, igual que los hilos, y cuando se accede a memoria compartida siempre se necesitan bloqueos. No sé de dónde salió eso de que no hacen falta bloqueos.
    • async puede dar más miedo en cuanto a bloqueos.
      Un bloque de código dependía de acceso exclusivo y eso estaba garantizado porque no había ningún await, pero si agregas un await en medio, el código se rompe. El threading al menos te obliga a expresar en el código qué necesita acceso exclusivo.
      async también significa que vas a gestionar tú mismo la planificación de tus hilos. Si hay mucha I/O y el código CPU-bound es corto, está bien; pero si tienes código CPU-bound, aunque sea ocasional, terminas jugando a ser scheduler.
    • Cuando me uní a un equipo que usaba Node.js, sufrí problemas de backpressure.
      Varios servicios simplemente morían con ABEND, y viniendo de Java me sorprendió esa omisión. También era difícil explicarle al equipo cómo corregirlo.
      Por la propagación de errores, no usaría async/await si tuviera opción. Si fuera un proyecto personal, maybe; pero si trabajo con otras personas, uso bibliotecas y tengo que alinear a todos con la misma comprensión, definitivamente no.
      Todavía no he usado en serio la concurrencia estructurada a nivel de lenguaje, pero tengo esperanzas en Project Loom de Java. Por lo que se ve, hará que este debate pierda sentido.
    • En Rust, async/await no se ejecuta únicamente en el contexto de un solo hilo.
  • El texto tiene problemas
    El único ejemplo es un servidor web, y la solución del lado de los threads está mal planteada. Además, la pregunta parece asumir que la gente quiere threads del SO en lugar de async/await
    Lo que los programadores quieren son threads conceptuales y semánticos. Escribir lógica secuencial y no usar anotaciones raras como async. Si async/await es tan bueno, ¿por qué no hacer que todas las funciones sean async de forma implícita y usar llamadas a funciones normales en lugar de await? Entonces, en la práctica, estaríamos programando con threads
    Los threads del SO son caros por sus stacks asignados estáticamente; lo que queremos son threads baratos que puedan ejecutar millones en una sola CPU. Pero sin las toscas palabras async/await. wait puede quedarse para la espera bloqueante en el sentido clásico, como esperar un evento o la finalización de otro thread, pero no lo quiero en llamadas a funciones
    Volviendo al ejemplo del servidor web, cuando se implementa un timeout con driver.race(timeout).await, ¿qué pasa con el socket del cliente después de que race informa el error de timeout? ¿No queda abierto, conectado y filtrándose?
    La versión con threads del timeout también podría hacerse casi igual que async/await, como threaded_race(client_thread, timeout).wait. threaded_race rastrea el timeout en paralelo con el thread usando un timer y, cuando se cumple el tiempo, llama a client_thread.interrupt() al estilo Java. Thread.interrupt() solo marca un flag si el thread no está bloqueado, y si está bloqueado en una llamada de I/O lanza InterruptedException. Como es una excepción verificada, el compilador obliga a envolver client.read_to_end(&mut data) en try/catch o a declarar la excepción en handle_client, así que el programador no se olvida de cerrar el socket del cliente

    • Los valores internos de race() se someten a Drop y driver en sí permanece
      Si usas el tipo tal cual, Rust se quejará de que no estás manejando el Result, y si dentro del future se creó un socket nuevo como variable local, se limpiará
      Lo bueno de los futures de Rust es que puedes definir todo ese comportamiento alrededor. A diferencia del modelo en el que todas las funciones son bloqueantes, Rust puede especificar en qué momento diferir la ejecución al siguiente trabajo de la cola de tareas, y sondear el trabajo a una velocidad arbitraria usando estado almacenado explícitamente (la estructura Future). Por eso no hace falta hacer sleep() para ceder como con threads, así que es rápido y fácil de razonar
      Thread.interrupt de Java también termina siendo cercano a un loop con sleep, y puede estar bien para la mayoría de las aplicaciones. Pero Rust es un lenguaje de sistemas, así que en sistemas embebidos no se puede usar ese enfoque, y tampoco es deseable para kernels o aplicaciones de baja latencia
    • Algunos programadores quieren lógica secuencial, pero muchos quieren exactamente lo contrario
      En la mayoría de los casos no me importa mucho si es una llamada de sistema bloqueante o no bloqueante del SO, pero sí quiero entender el flujo de control del programa que estoy leyendo y saber dónde espera y cómo puede ejecutarse en paralelo
      Más bien, cuando trabajo con funciones bloqueantes, me gustaría que existiera un par de palabras clave blocking/block. Las llamadas bloqueantes pueden hacer que todo se vuelva lento de forma sutil, y he visto demasiadas apps desesperantemente lentas porque metieron una llamada de sistema bloqueante en el thread de la UI
    • La idea de hacer que todas las funciones sean async implícitamente y usarlas como llamadas normales se ha intentado muchas veces durante las últimas décadas. Basta buscar “RPC”
      Todos los intentos de unificar lo síncrono y lo asíncrono fracasaron. Hay grandes diferencias semánticas entre el código que se ejecuta dentro de un thread, el que se ejecuta entre threads e incluso el que se ejecuta entre computadoras. Si intentas abstraer eso, al final se queda corto, así que es mejor aprenderlo bien desde el principio
    • Recuerdo que withoutboats dijo en algún artículo que la verdadera respuesta es la compatibilidad con C
    • También existe la forma de escribir código con poll() y select(), pero eso es otro enfoque aparte
  • Es interesante ver una campaña casi de marketing para salvarle la cara a async/await
    En mi experiencia, esto no solo fue un error técnico, sino que también le costó mucho a la comunidad. En vez de enfocarse en funciones del lenguaje que fueran realmente útiles, el esfuerzo de Rust se desvió hacia este lío
    Aun así, sigo teniendo grandes expectativas para el lenguaje, y creo que es lo mejor que tenemos hoy. Solo me preocupa que esta pelea continúe para siempre
    P. D.: el ejemplo de AsyncWrite/AsyncRead se ve convincente, pero en realidad, si te limitas a *nix, se puede hacer lo mismo con threads y descriptores de archivo

    • He usado async en firmware y fue un salvavidas
      Esa generalización tiene poca base y parece sesgada hacia una carga de trabajo específica
    • No conozco tan bien Rust, así que no sé si sea cierto, pero por experiencia, 9 de cada 10 discusiones sobre Rust que veo últimamente en HN/reddit parecen girar alrededor de async
      Como alguien a quien no le interesa en absoluto async y quiere leer sobre Rust, es bastante malo
    • Si crees que los threads son más rápidos que poll(), me gustaría saber para qué caso de uso. En toda mi vida nunca vi un caso así
    • No es un error técnico; cuando se necesita código async de latencia ultrabaja, es una solución excelente
      El error fue impulsarlo incluso en la gran mayoría de casos de uso que no lo necesitan
    • Me pregunto si hay fundamento para afirmar que el trabajo en async les quitó espacio a otras funciones útiles
      Muchos proyectos importantes de Rust dependen de async no solo por las grandes mejoras de rendimiento frente a alternativas basadas en threads, sino también por sus características de diseño. En cargas de trabajo importantes ligadas a I/O, estos beneficios se ven fácilmente. Que personas inteligentes resolviendo problemas reales hayan adoptado ampliamente async en crates importantes me parece una señal fuerte de que async sí es una función del lenguaje realmente útil
      La pelea ocurre sobre todo en Hacker News y reddit, en forma de gente que no necesita async enojándose porque los crates de I/O que usan ahora quieren async. Entiendo que es una situación desagradable, y también es cierto que async tiene problemas reales y que todavía se están resolviendo. No es perfecto. Pero tengo la impresión de que la división sobre async que se ve en los foros no es tan amplia ni dramática como en los proyectos reales
  • Lo que falta de forma importante es la cancelación
    Los future son muy fáciles de cancelar. En cambio, cancelar threads es desprolijo, como jugar al topo, y forzar la interrupción de un thread no es confiable por el riesgo de que queden locks bloqueados
    En el modelo async de Rust, a cualquier future se le puede agregar un timeout desde afuera. No hace falta que cada función de I/O en los extremos soporte una opción de timeout, ni propagar ese timeout por toda la pila de llamadas
    Si se combina con los guards Drop, la práctica recomendada de Rust para administrar estado en curso, incluso las tareas grandes y complejas se pueden cancelar de forma fácil y confiable

    • No es que todos los future sean fáciles de cancelar, sino que es fácil simular que se cancelaron todos los future
      Por ejemplo, si cancelas (drop) algo que usa spawn_blocking, seguirá ejecutándose en segundo plano, y el usuario quizá no se entere. Las operaciones async del sistema de archivos implementadas con un pool de threads también siguen ejecutándose aunque las canceles
      Esto puede llevar a bugs difíciles de entender, como “estoy seguro de que nada está escribiendo ese archivo, ¿por qué el servicio falla diciendo que el archivo está en uso?”
    • Si se pudo implementar un future async, también se podrían haber implementado threads cancelables en su lugar
      El problema es bastante isomórfico. Las llamadas al sistema son difíciles, pero ya sea que se hagan desde un thread o desde un future async, si se hace la misma llamada al sistema, aparece exactamente el mismo problema de cancelación
    • No entiendo por qué cancelar threads es difícil
      Basta con tener un estado, como un flag al que todos los threads puedan acceder, y hacer que el loop de trabajo revise ese flag. Si es false, retorna y haces join del thread, y listo
    • En mi experiencia, no ha valido mucho la pena preocuparse por la cancelación
      Si una tarea ya no es útil, basta con que esa información finalmente sea visible para la función que se llamó en nombre de esa tarea. No lo reviso a menos que esté justo por hacer algo muy costoso, como iniciar un RPC
  • Creo que una mejor pregunta sería: “¿por qué async/await y no fibers?”
    Sé que Rust tuvo green threads antes de la 1.0 y que las eliminó deliberadamente, pero hay varios enfoques para implementar concurrencia basada en fibers, por ejemplo formas que no requieren incorporar un runtime pesado en el lenguaje.
    Si entendí bien el artículo, parece elogiar sobre todo que se pueda descartar un future en cualquier momento. En threads no se puede hacer algo similar por razones obvias, y aunque técnicamente fuera posible, sería extremadamente inseguro. Pero esta capacidad tiene un costo enorme. No solo impide usar ejecutores basados en completion, como io-uring, junto con arreglos basados en stack, o ejecutar subtareas en otros threads del ejecutor; también introduce trampas sutiles y problemas de confiabilidad que, cuando uno viene de escribir Rust síncrono, resultan sorpresas muy desagradables.
    https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
    Creo que la cancelación de tareas, en el fondo, debe ser cancelación cooperativa, y que la cancelación no cooperativa es más bien una mala funcionalidad: parece cómoda en la superficie, pero tiene problemas profundos debajo.
    Además, también me parece extraño elogiar la composabilidad de async/await. En el Rust actual, sin un proper effect system, está lejos de ser componible por su naturaleza contagiosa. Por ejemplo, basta intentar usar el método map de la biblioteca estándar con un closure async, o usar los traits estándar io::Read/Write.

    • Para incorporar fibers en Rust como una abstracción de concurrencia cooperativa en espacio de usuario, habría que imponer varias decisiones de diseño.
      Hay que elegir, por ejemplo, si implementar los stacks como spaghetti stacks, exigir una biblioteca de mapeo de memoria a nivel de proceso, o limitarse a stacks de tamaño fijo.
      Las tres opciones tienen problemas al interactuar con código de lenguajes que tienen otro ABI. Por ejemplo, si desde una fiber se llama a código C y ese código C intenta reanudar otra fiber, la cosa puede volverse bastante complicada.
      Una de las ventajas de async/await es la propia palabra clave await. Gracias a los puntos de espera explícitos, se puede razonar realmente sobre las interacciones de un programa concurrente.
      Una fiber que hace yield se parece al goto del mundo de la concurrencia. Al llamar a un método, no sabes si como efecto secundario la ejecución se va a pausar, ni si cuando continúe el estado del mundo habrá cambiado. Como hay que escribir de forma defensiva al tocar el mundo exterior, las fibers encajan mejor en tareas que se ejecutan aisladas y se comunican al completarse.
      Green threads, fibers y corrutinas comparten aquí el mismo problema. La concurrencia cooperativa en espacio de usuario no tanto resuelve las partes difíciles de la concurrencia como que mueve papeles de un lado a otro del escritorio. Rust async/await es más explícito, así que no oculta los efectos secundarios que otros mecanismos sí esconden.
    • No veo cómo las fibers resuelven el problema de la cancelación. ¿No es casi equivalente?
      El código basado en fibers me parece difícil de seguir porque hay que ir rastreando mentalmente los threads en curso. Al menos para mí, es mucho más fácil rastrear valores que se completarán.
    • Las fibers con stack no son muy buenas para código de bajo nivel.
      Se puede ver la revisión de Gor Nishanov para el comité de C++: http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136.... También está enlazada desde https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10.... El resumen también es claro: DO NOT USE FIBERS!
    • Como es Ruby, la situación de threads/GVL es distinta de Rust, pero me pregunto si se refieren a este tipo de cosas:
      https://m.youtube.com/watch?v=qKQcUDEo-ZI
      Creo que muestra bastante bien la base para decir que async/await es contagioso y torpe, y que las fibers, al menos en la implementación de Ruby, son un paradigma mucho mejor.
    • En problemas matemáticos, la cancelación cooperativa puede ser bastante molesta.
      Un algoritmo de optimización puede llamar a un problema de búsqueda de raíces, que a su vez llama a un integrador de ODE, y así sucesivamente; cualquiera de esos niveles puede ejecutarse durante mucho tiempo. Habría que pasar tokens de cancelación por todas partes, pero los frameworks de cálculo numérico normalmente no lo soportan.
      Se puede, y se debería, poner un límite de iteraciones a todos los algoritmos, pero en algoritmos anidados eso solo garantiza algo como que se detendrán antes de que termine el año, no que se detendrán en 5 segundos.
      En este tipo de problemas puedo garantizar que lo único que hago es una gran cantidad de cálculos matemáticos, asignaciones y los page faults correspondientes, nada de I/O, y escribir strings de log en un objeto Queue de la biblioteca estándar que se procesará en el thread Main, que no voy a cancelar. Cualquier otra funcionalidad necesaria también se puede devolver al thread principal mediante una Queue.
      Siento que en el siglo XXI este problema debería poder resolverse sin obligar a pasar tokens de cancelación por todas partes y sin hacer que uno escriba defensivamente código que puede correr mucho tiempo sin revisar el token.
  • En otra discusión más sobre async/await, la gente no entiende async/await, no puede imaginar por qué se necesita un mecanismo de concurrencia en un solo thread y asume que nadie lo necesita.
    La programación de UI, la comunicación con la GPU y la comunicación entre runtimes son buenos ejemplos, y seguramente hay más.
    Los threads, sean green threads o no, no encajan en estos casos, pero async/await sí.

    • En GUI se pueden usar threads con facilidad, y en el pasado escribí varias apps GUI que aprovecharon threads con bastante efectividad.
    • Sin duda es importante poder administrar explícitamente varias tareas en un solo thread.
      Si es posible implementar otras funcionalidades del lenguaje que sean menos molestas para los usuarios del lenguaje y produzcan el mismo binario, también vale la pena hablar de eso.
  • Una de las principales ventajas de async/await en Rust es que puede funcionar incluso en situaciones sin hilos ni memoria dinámica.
    También es perfectamente útil para escribir código conciso en un microcontrolador que espere a que una interrupción lea datos I2C que llegan a algún búfer. Es una abstracción de más alto nivel que permite usar concurrencia sin exponer demasiado la interacción con el runtime subyacente.
    Todo software importante en el que he trabajado implementaba algo así de alguna forma. Incluso en código sin el concepto moderno de corrutinas de C++, usábamos Apple Grand Central Dispatch, Intel Threading Building Blocks, etc. De lo contrario, la lógica de negocio se queda bloqueada de forma muy ineficiente en I/O, o hay una cantidad enorme de hilos que vuelve infernal el desarrollo y la depuración, o queda cubierta de detalles de implementación del runtime subyacente, o una mezcla de las tres cosas.
    Si no usas la abstracción ya existente del lenguaje o de una biblioteca, al final terminas creando la tuya, y eso es difícil y probablemente sea inferior en general a algo ampliamente usado. Yo mismo hice una para C++ en el pasado: https://github.com/goto-opensource/asyncly

  • Parece que el autor confunde dos cosas.
    Una son los hilos en espacio de usuario / hilos verdes, y la otra es la concurrencia estructurada.
    La primera es una ventaja de async/await, pero no es exclusiva. Hay ejemplos como Go o Java Loom donde es posible sin el problema del color de las funciones.
    La segunda se puede implementar tanto con hilos del SO como con hilos verdes. Basta ver el JEP de Structured Concurrency de Java.
    https://openjdk.org/jeps/462