Por qué elegir async/await en lugar de threads
(notgull.net)async/awaitde 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/awaitcede la ejecución en los puntosawaity 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
asynccombinandoraceyTimer, pero en código síncrono con threads se necesita un wrapper específico paraTcpStreamy configurar timeouts de lectura/escritura, lo que reduce la generalidad - Explicar
asyncsolo por su overhead de rendimiento genera contraejemplos en trabajos CPU bound; la fortaleza de Rustasyncestá 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
- Como
- 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 ejecutahandle_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
- En una estructura que recibe clientes con
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
- El thread principal sigue haciendo
- 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 fnno es una función tradicional, sino una función que devuelve una máquina de estados awaitincluye 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
- Una
- Un ejecutor como
smol::Executorejecuta otra máquina de estados creada conspawnen lugar de la máquina de estados actual- El bloque
async move { handle_client(client).await }es una nueva máquina de estados independiente demain - Cuando
maincede, se ejecuta una de las tareas de cliente; cuando esa tarea vuelve a ceder, se pasa a la siguiente tarea
- El bloque
- 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
Iteratorpermite encadenar varios combinadores y pasar el resultado de nuevo a funciones que reciben unIterator- Como en
recv.try_iter().filter(...).map(...)dempsc::channel(), se pueden filtrar y transformar valores para agregarlos a una lista
async/awaitpermite aplicar esta composibilidad también a funciones I/O bound- Si
handle_client()es una función asíncrona que haceawaitsobreread_to_end,do_something_with_dataywrite_all, un timeout de 3 segundos puede implementarse combinando dos Future - 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
- El mismo patrón puede aplicarse a cualquier objetivo que implemente
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
readowrite, y métodos como cerrar el descriptor de archivo no pueden usarse en Rust TcpStreamofreceset_read_timeoutyset_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
DeadlineStreamque envuelvaTcpStream, 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_timeoutyset_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
- Está atado a
Casos del ecosistema async de Rust
- Que el ecosistema HTTP, incluyendo clientes, haya adoptado
async/awaitcomo 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
toweres un caso representativo que muestra la composibilidad deasync/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,
towerpuede aplicarse para aumentar su robustez
- Si se implementa un servicio como una función
macroquades un pequeño motor de juegos para Rust y usaasync/awaiten la función principal para ejecutar el motorasync/awaites 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
asyncexiste una tendencia a responder a la pregunta de por qué usarasyncen 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/awaitfue resolver el problema C10k, pero no todos los usuarios tienen que elegirasync/awaitpor 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
asyncequivalente - La ventaja temporal de rendimiento de Rust
asyncse ha enfatizado demasiado, mientras que sus beneficios semánticos se han subestimado
- En trabajos CPU bound, un flujo de trabajo basado en threads puede ser más rápido que un flujo
async/awaitno 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 Rustdebería ser tan fácil como escribir código síncrono, salvo por usar ocasionalmente las palabras claveasyncyawait - Pero también existe la visión de que enmarcar
async Rustcomo “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/awaitde 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/awaitse 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
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
Mutexestándar y los canales decrossbeam-channelpueden 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, existeparking-lot, pero no tiene la seguridad de poisoning ante pánico de hilos que ofrece el mutex estándarSi no es limitado por I/O, se vuelve mucho más complejo
https://users.rust-lang.org/t/mutex-starvation/89080
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
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
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-...
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
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.
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
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(), usastokio::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.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.
“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.
Un bloque de código dependía de acceso exclusivo y eso estaba garantizado porque no había ningún
await, pero si agregas unawaiten 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.
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.
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 deawait? Entonces, en la práctica, estaríamos programando con threadsLos 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.waitpuede 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 funcionesVolviendo 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 queraceinforma 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_racerastrea el timeout en paralelo con el thread usando un timer y, cuando se cumple el tiempo, llama aclient_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 lanzaInterruptedException. Como es una excepción verificada, el compilador obliga a envolverclient.read_to_end(&mut data)en try/catch o a declarar la excepción enhandle_client, así que el programador no se olvida de cerrar el socket del clienterace()se someten aDropydriveren sí permaneceSi 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 hacersleep()para ceder como con threads, así que es rápido y fácil de razonarThread.interruptde 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 latenciaEn 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 UITodos 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
poll()yselect(), pero eso es otro enfoque aparteEs 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/AsyncReadse ve convincente, pero en realidad, si te limitas a *nix, se puede hacer lo mismo con threads y descriptores de archivoEsa generalización tiene poca base y parece sesgada hacia una carga de trabajo específica
Como alguien a quien no le interesa en absoluto async y quiere leer sobre Rust, es bastante malo
poll(), me gustaría saber para qué caso de uso. En toda mi vida nunca vi un caso asíEl error fue impulsarlo incluso en la gran mayoría de casos de uso que no lo necesitan
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 confiablePor ejemplo, si cancelas (
drop) algo que usaspawn_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 cancelesEsto 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?”
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
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
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
mapde la biblioteca estándar con un closure async, o usar los traits estándario::Read/Write.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
gotodel 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.
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.
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!
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.
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í.
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