2 puntos por GN⁺ 2023-09-09 | 1 comentarios | Compartir por WhatsApp
  • El async/await de Rust apunta a la concurrencia a gran escala para manejar decenas de miles de conexiones, pero choca con los objetivos de Rust de control de bajo nivel y verificación estática de lifetimes, lo que crea una experiencia de desarrollo distinta al Rust habitual
  • Los hilos y canales son suficientes para mucho software, pero en escalas como C10K el costo del enfoque de una conexión por hilo se vuelve alto, por lo que hacen falta tareas en espacio de usuario y planificación desde el runtime
  • En Rust async, los datos deben moverse como Send o tratarse como referencias 'static, y por la naturaleza contagiosa de async estas restricciones se repiten en todo el código
  • Arc puede resolver problemas de compilación, pero también vuelve más difusa la vida útil de objetos y recursos, y siguen apareciendo trampas como async recursivo, la diferencia entre future y task, y llamadas bloqueantes que atascan hilos del runtime
  • En Haskell o Go, el “código async” funciona como código normal y el runtime junto con el GC ocultan esas diferencias, por lo que en este tipo de programación el control explícito de Rust puede no traducirse solo en ventajas

Por qué hacen falta concurrencia y paralelismo

  • Los programas rápidos tienen dos exigencias al mismo tiempo
    • Deben usar varios núcleos de CPU para aprovechar toda la computadora
    • Deben seguir haciendo otras cosas mientras esperan operaciones lentas, como transmitir mensajes por internet o abrir archivos
  • El paralelismo es el problema de ejecutar código al mismo tiempo en varias CPU
  • La concurrencia es una forma de dividir un problema en partes independientes
  • No son lo mismo, pero cuando un programa se divide en piezas concurrentes, esas piezas pueden ejecutarse en paralelo y mantener ocupados los núcleos

Procesos, hilos y canales

  • Una forma simple de construir un sistema concurrente es dividir el código en varios procesos
    • El scheduler del sistema operativo ejecuta porciones de tiempo de los procesos listos sobre los núcleos de CPU disponibles
    • Este modelo también se usa cuando se conectan comandos de shell con pipes
  • El enfoque basado en procesos tiene un costo alto de comunicación entre procesos
    • En muchas implementaciones, los datos deben copiarse a memoria del SO y luego recuperarse
    • Se puede reducir ese costo con memoria compartida, pero eso debilita la ventaja de que el SO aísle los procesos entre sí
  • Los hilos evitan ese overhead porque comparten la misma memoria, pero si se usan mal herramientas de sincronización como mutex, condition variable o semaphore, aparecen carreras de datos y deadlocks
  • El modelo de Communicating Sequential Processes de Tony Hoare conecta hilos mediante colas o canales
    • Los hilos no comparten memoria, así que se obtiene un aislamiento parecido al de los procesos
    • Las entradas y salidas de cada hilo quedan expuestas a través de canales, lo que facilita razonar y depurar
    • El propio canal cumple el papel de sincronización: si está vacío, el receptor espera; si está lleno, el emisor espera
  • La biblioteca estándar de Rust incluye std::sync::mpsc::sync_channel
  • Para mucho software basta con una combinación de hilos y canales, junto con herramientas como Rayon para paralelizar loops intensivos en CPU

Concurrencia en espacio de usuario y Rust async

  • En problemas C10K, como un servidor web con decenas de miles de usuarios conectados al mismo tiempo, el enfoque de asignar un hilo por conexión llega a su límite
    • En Linux, cada hilo tiene un control block de 4 kB, y cambiar de hilo requiere un context switch que entra al scheduler del sistema operativo
  • Para la concurrencia a gran escala, algunos lenguajes crean y administran tareas en espacio de usuario
    • El runtime agenda esas tareas sobre un pool de hilos del SO
    • Normalmente el pool se configura para tener un hilo por núcleo de CPU y así maximizar el paralelismo
    • A este enfoque también se le llama green thread, lightweight thread, lightweight process, fiber o coroutine
  • Rust usa el modelo async/await que se ve en C# o Node.js
    • Una async fn no devuelve el valor directamente, sino un future o promise cuyo resultado se obtiene con .await
  • Los future de Rust son muy pequeños y rápidos gracias a la planificación cooperativa y a su diseño stackless
  • Rust intenta ofrecer la abstracción de future y al mismo tiempo prometer control de bajo nivel al programador
    • Intenta verificar estáticamente en tiempo de compilación la vida útil de todos los objetos y referencias
    • Un future divide el código y los datos a los que hace referencia en miles de fragmentos, y permite que se ejecuten en cualquier momento y en cualquier hilo según condiciones que solo se conocen después de empezar la ejecución
    • Un future que lee datos de un cliente debería ejecutarse solo cuando haya datos disponibles en ese socket, pero las lifetime annotations no indican ese momento
  • Rust no incorpora un runtime de future dentro del lenguaje y lo deja en manos de bibliotecas como Tokio
    • El usuario gana libertad para elegir alternativas según su entorno
    • Pero incluso si imagináramos un mundo en el que Tokio estuviera integrado en el lenguaje, las mismas reglas seguirían aplicando, así que en este punto es un detalle secundario

La presión que generan Send, 'static y Arc

  • Para convencer al compilador, los datos deben moverse marcados como Send o pasar mediante referencias con lifetime 'static
  • En código async es común que varias tareas compartan estado, así que mover datos sin copiarlos muchas veces no encaja bien
  • Las referencias también son complicadas, y no existe un equivalente de thread::scope que permita limitar la vida de un future a algo menor que “para siempre”
  • async es contagioso, así que una función que llama a una función async también debe ser async
    • Por eso estos problemas de lifetime y movilidad no se resuelven en unas cuantas funciones, sino una y otra vez a lo largo del código
    • Se puede cortar la cadena esperando en tiempo de ejecución a que un future termine con block_on, pero eso no es componible y, si se anida, el runtime puede hacer panic
  • Arc es una herramienta para manejar lifetimes dinámicos entre varios hilos, y permite pasar el borrow check y hacer que el código compile
  • Pero usar Arc de forma extensa también vuelve más difusa la vida útil de objetos y recursos
    • Ya no queda claro cuándo se liberarán recursos como memoria, archivos o sockets
    • Se sufren pérdidas parecidas a las de un GC, sin obtener ventajas reales de un GC como mayor allocation throughput, menor fragmentación o evitar cycle leaks

Trampas adicionales de async Rust

  • Las coroutines de Rust son stackless, así que el compilador convierte cada coroutine en una máquina de estados que avanza hasta el siguiente punto .await
    • Una función async recursiva se convierte en un tipo definido de forma recursiva
    • Quien solo quiere llamarse a sí mismo tiene que hacer boxing manualmente o usar un crate como async-recursion
  • Un future no hace nada hasta que se le aplica await
  • Una task comienza a ejecutarse en el pool de hilos del runtime y devuelve un future que indica su finalización
  • No hay ningún mecanismo que impida llamar código bloqueante dentro de un future
    • Tampoco hay nada que impida que esa llamada bloquee el hilo del runtime en el que cayó
    • Eso entra en conflicto con el propósito central de usar async

Diferencias con Rust normal, Haskell y Go

  • async Rust tiene un sabor muy distinto al Rust “normal”
    • Tiene más trampas
    • Es más difícil de entender y de enseñar
  • El usuario queda frente a dos opciones
    • Entender a fondo cómo funciona realmente la abstracción y escribir código complejo
    • Esparcir elementos como Arc, Pin y 'static por todo el código y esperar que funcione
  • Incluso un equipo de desarrolladores con experiencia puede intentar usar Rust en un proyecto nuevo y quedar frenado por estos detalles
  • En Haskell o Go, el “código async” es código normal
    • Ambos lenguajes esconden la diferencia entre código bloqueante y no bloqueante detrás de un runtime pesado
    • Los problemas de lifetime se delegan al garbage collection
  • En este tipo de software concurrente a gran escala en espacio de usuario, la forma en que el runtime y el GC ocultan esas diferencias funciona como una ventaja pura
  • Puede que Rust no sea una buena herramienta para software concurrente a gran escala en espacio de usuario, y quizá convenga usarlo en proyectos que no tengan ese tipo de exigencia

1 comentarios

 
GN⁺ 2023-09-09
Opiniones de Hacker News
  • Estoy escribiendo un cliente de metaverso de alto rendimiento en Rust, y actualmente tiene unas 40 mil líneas
    El video demo está en https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz
    Un metaverso bien hecho tiene que procesar contenido creado por usuarios casi en tiempo real, así que necesita 2 a 3 veces más VRAM que juegos similares, cientos de Mbps de ancho de banda para cargar assets desde el servidor, varios CPU y Vulkan para hacer renderizado y subidas a la GPU en paralelo
    Esto no es una estructura de concurrencia “a escala web”, con pequeños servidores corriendo por separado en el mismo espacio de direcciones, sino una en la que se mueven juntos un hilo de render de alta prioridad, un hilo de actualización de eventos de red, hilos de carga y descompresión de assets, y varios hilos encargados de objetos móviles, LOD, limpieza de caché, etc.
    En Rust uso bastante bloqueo sin estado global aparte de constantes; los canales los uso donde encajan, y el árbol principal de objetos lo maneja sobre todo el hilo de actualización con propiedad única. Las conexiones de objetos gráficos se administran con conteo de referencias Arc, y subo mallas y texturas a la GPU mediante Rend3/WGPU/Vulkan
    Si lo hubiera hecho en C++, habría estado peleando con crashes todo el tiempo, pero en Rust tengo crashes relacionados con memoria más o menos una vez al año, y por lo general fueron por código unsafe de otros. En mi código prohibí unsafe; es difícil lograr que compile, pero una vez que lo hace, suele “simplemente funcionar”, lo cual me parece mucho mejor que depurar concurrencia
    También tengo quejas. Rust es fuerte contra las carreras de datos, pero no puede impedir los interbloqueos, así que hace falta un analizador estático que rastree el orden de los bloqueos a lo largo de las rutas de llamada. async no encaja con trabajos centrados en cómputo ni con varios hilos de distintas prioridades, pero aun así se va colando como dependencia. Las estructuras comunes con propiedad única y referencias inversas son demasiado difíciles sin Rc y Weak, y el sistema de traits también es complejo, por lo que en partes de procesamiento de assets donde la orientación a objetos sería natural aparece código duplicado
    Las crates centrales de gráficos tampoco están todavía muy maduras. Eso de que “Rust tiene 5 juegos y 50 motores de juegos” no es un problema del lenguaje, sino del ecosistema, y aun comparándolo con https://gamedev.rs/, parece que todavía falta desarrollo serio de juegos en Rust. Para desarrollo profesional de juegos con fechas de entrega, el ecosistema de juegos en Rust todavía no está listo; lo veo como algo que necesitaría aproximadamente 5 personas trabajando un año más

    • En los últimos 3 años hice un simulador de robots en Rust y mi experiencia fue casi la misma. En 3 años hubo unos 5 bugs reales en tiempo de ejecución, y aunque Rust y async tienen problemas, en general las ventajas pesan mucho más
    • Buscar posibles interbloqueos siguiendo el orden de los bloqueos parece una buena idea
      Como lockdep de Linux: analizar qué bloqueos se toman mientras ya se tiene otro, y avisar sobre combinaciones peligrosas incluso antes de que el programa se detenga de verdad. Con bloqueos complejos harían falta anotaciones como “esta clase de bloqueo siempre se toma en orden de direcciones”, pero parece implementable
    • Estoy haciendo casi lo mismo en un MMO con Java, y el JDK lo vuelve muy fácil. Basta con crear modelos desde la red y mover objetos al hilo de UI mediante una cola concurrente; es bastante simple, casi aburrido, y aun así rápido
    • Rust no elimina las condiciones de carrera; elimina las carreras de datos
      Fuera del acceso a datos todavía pueden aparecer condiciones de carrera: https://news.ycombinator.com/item?id=23599598
    • El problema de prioridades se puede resolver de forma relativamente sencilla
      Se pueden crear varios pools de hilos y enrutar los futures al lugar adecuado, o escribir un loop de eventos propio que tome trabajo de varias colas de eventos con distintas prioridades. El segundo enfoque, si el tiempo de ejecución de las tareas está acotado, puede dar garantías soft real-time a las tareas de alta prioridad mientras permite que avancen las de baja prioridad incluso con la CPU al 100%
  • Con async Rust estoy en un punto raro
    Si usas un montón de Arc, RwLock y estado compartido, todo se vuelve sucio, y es cierto eso de que, sobre todo cuando 'static empieza a propagarse por todas partes, infecta todo como las funciones de colores. Antes intenté poner Arc y manejar préstamos con lifetime de forma inteligente, y terminó siendo un desastre
    Pero Rust también tiene canales. El código que estoy escribiendo ahora consiste en su mayoría en unas cuantas tareas que atienden canales, miran los mensajes entrantes y, si hace falta, colocan mensajes para otras tareas en los canales correspondientes. No comparto objetos. Si varias tareas necesitan un objeto grande, lo pongo dentro de una tarea que envía por mensaje los resultados de las consultas relacionadas, o hago que cada tarea cree su propia copia dentro del flujo de mensajes
    Aun así hay demasiados artículos sobre cómo usar Arc y cómo manejar lifetimes. Si estás implementando un runtime async, será necesario, pero no entiendo bien por qué el usuario promedio de una librería debería concentrarse tanto en eso

    • La crítica me parece un poco extraña. async no significa necesariamente multihilo, y si es async dentro del mismo hilo, no hay compartición, así que tampoco hace falta poner palabras clave mágicas a todo lo compartido
      Cuando se cruza entre hilos, se envían señales por canales en vez de tener mucho estado compartido. Si hay algún estado global realmente necesario, se crea una pequeña estructura que envuelva mecanismos de acceso exclusivo como Arc/RwLock, y para quien la llama se ve como una simple llamada a función
      Tampoco entiendo bien la preocupación por Send+Sync. En mi experiencia, la mayoría de las cosas son fácilmente Send+Sync, y las que no lo son eran cosas que no deberían o no podían serlo. A veces uno quiere escribir código sin pensar en los detalles, pero si necesitas concurrencia y paralelismo eficientes, los microsegundos y el throughput importan, y entonces hay que escribir código para computadoras reales de forma correcta
    • El paradigma de paso de mensajes es realmente bueno, y lenguajes como Erlang demostraron que es una gran opción para sistemas distribuidos
      Pero codificar de esta forma es muy distinto de JavaScript async, que se siente como código síncrono con green threads encima. La gente intenta escribir código de la forma a la que está acostumbrada, y por eso parece terminar yéndose por el camino de Arc y RwLock en Rust
    • El sueño de Smalltalk y de la verdadera orientación a objetos sigue vivo
    • En la universidad aprendí este consejo de un profesor, y me ayudó muchísimo
      Estructurar el problema como datos que fluyen entre tareas, conectadas por colas y evitando estado compartido, es una mejor manera de manejar multihilo sin importar el lenguaje que uses
    • Como dijo un programador sabio: “No te comuniques compartiendo memoria; comparte memoria comunicándote”
  • async es, en la práctica, Rust mucho más difícil, y es una pena que se haya impuesto casi a todos cuando probablemente solo un 1% de los proyectos realmente lo necesita.
    Dicho eso, en ese 1% es realmente excelente. Para servicios que procesan como parte central una enorme cantidad de llamadas de red, como linkerd o nginx; para juegos que ejecutan cantidades enormes de tareas livianas; o para casos de embebidos donde se necesita concurrencia cooperativa, async Rust se vuelve un arma poderosa.
    La mayoría del código a nivel de sistemas y aplicaciones no necesita E/S asíncrona. Para una app REST alcanza con un pool de hilos y, aun cuando se necesita async, lo habitual es que lo correcto sea un modelo mixto: limitarlo a partes pequeñas como la red y conectar el resto con hilos y canales.
    La comunidad de Rust ha usado async en todos lados de forma demasiado indiscriminada, al punto de que el Rust con E/S bloqueante, que ofrece una mejor experiencia de usuario, quedó como ciudadano de segunda en el ecosistema. También en frameworks web hay varios frameworks asíncronos bien diseñados como Axum y Warp, mientras que del lado bloqueante las opciones son mucho más limitadas, como tiny_http, rouille y astra.

    • El punto central es que Rust implementó mal las corrutinas.
      Al elegir corrutinas sin stack aparecieron async/await y el problema de las funciones de colores, y surgió la fricción que menciona el artículo. Go no tiene este problema porque usa corrutinas con stack.
      Rust también consideró al principio las corrutinas con stack, pero concluyó que requerían un runtime preventivo de corrutinas y que el costo era alto, así que fue por el modelo sin stack. Pero la mayoría no usa async Rust sin runtime, sino Tokio, y Tokio hace en la práctica casi todo lo que hacía el runtime que se quería evitar.
      Por eso muchos usuarios de async Rust terminan con lo peor de ambos mundos. En embebidos algunos usan async Rust con un runtime muy liviano, pero son pocos, e incluso ellos no están completamente convencidos.
    • Vi que Tokio volvió a entrar como dependencia en mi programa. Ni siquiera lo uso directamente: una función que no uso de algún crate trae reqwest, eso trae h2, y eso a su vez trae tokio.
    • Me pregunto si tiene sentido usar async cuando la plataforma soporta hilos virtuales.
      Como usuario de Java, estoy intentando abandonar por completo el paradigma asíncrono y reescribir el código con un modelo bloqueante sobre hilos virtuales donde bloquear está bien.
  • async se ha extendido a demasiados crates, así que todo el programa termina siendo async o, como mínimo, dependiendo de Tokio para muchas cosas.
    Si quieres un servidor web, la actitud parece ser “async + tokio o vete”, y con los conectores SQL el ambiente es que, si no quieres asincronía, tienes que escribirlos tú. Cada quien resuelve los problemas que trae async de una manera distinta, y cosas como los closures async se sienten como abrirle al compilador las puertas del infierno.
    Está bien que Rust como lenguaje y el compilador ayuden a resolver problemas, pero no alcanza con que el ecosistema esté cerca de “si no es async, hazlo tú mismo”.

    • Si la biblioteca estándar o el crate futures tuvieran mejores primitivas asíncronas, se podría haber reducido mucho el dolor.
      Harían falta cosas como traits que deban implementar los ejecutores, o un ejecutor bloqueante básico para ejecutar código asíncrono desde código síncrono. Hoy, solo crear una biblioteca que soporte varios runtimes asíncronos ya es un calvario, así que al final se termina soportando solo Tokio o, con suerte, agregando async-std.
  • No soy especialista en Rust async, pero este mes, tras escribir varios miles de líneas de Rust síncrono, lo que sentí es que cuando rustc vuelve difícil cierto enfoque, normalmente hay una buena razón para eso y existe una forma mejor de obtener un resultado parecido.
    Si estás aprendiendo el lenguaje, recomendaría primero acostumbrarte al código síncrono común, a los bucles y condicionales, y a las reglas de préstamo. async todavía está evolucionando bastante, no solo en la implementación, sino también en un plano filosófico: “qué es la asincronía y cómo debería verse para el usuario”.
    El compilador depende mucho de los traits, pero la funcionalidad de los traits para manejar async no está estabilizada. Por ejemplo, existe trabajo como https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i....
    Si las capacidades asíncronas de los traits no están estabilizadas, atacar que el código asíncrono de Rust todavía no se vea bonito es parecido a criticar un borrador inicial de un libro que eventualmente estará terminado.

    • Me pregunto qué sería un “buen diseño de API asíncrona”. Si diseñáramos un servidor completamente centrado en lo asíncrono, que sea escalable, mantenible y fácil de entender, ¿cómo debería verse?
      También me preocupa cómo evitar que lo asíncrono se propague por toda la base de código.
      La idea actual es una arquitectura en la que un hilo de E/S divide los eventos del sistema de liburing o epoll en dos etapas, “submit” y “handle”, y los envía a otros componentes. Por ejemplo, si creas un tcp-connection, puedes suscribirte a eventos asíncronos como “listo para escribir” o “listo para leer”, y el evento de listo para escribir toma datos de un búfer llenado mediante un mutex normal y los envía con EPOLLOUT/io_uring_prep_writev.
      Para transmitir eventos entre hilos se puede usar un búfer circular de múltiples productores y múltiples consumidores con el patrón LMAX Disruptor. Los hilos de aplicación o el pool de hilos tienen cada uno su propio event loop y procesan ese búfer circular.
      También estoy trabajando en una sintaxis para expresar el orden de disparo de eventos asíncronos; se parece a un pipeline de Bash y la llamo statelines: initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3
    • Si no está estabilizado, tampoco debería usarse en producción.
    • Son graciosos los comentarios que asumen que el autor es principiante en Rust. Quizá incluso tenga más experiencia que ellos.
  • La vida útil de Arc no es algo desconocido, sino que queda determinada por dónde y cómo se lo conserva
    La desconexión de este artículo parece venir de que el autor intenta forzar sobre Rust un modelo mental previo, como el de la recolección de basura, en lugar de aprender Rust y trabajar según el lenguaje. Es una trampa común al aprender un lenguaje nuevo, pero Rust hace que uno tropiece con ella especialmente seguido

    • En ese sentido, la vida útil de los objetos en un sistema con recolección de basura también tiene un límite inferior: “mientras estén referenciados”
      Pero eso es casi lo opuesto al objetivo del verificador de préstamos, que busca restringir estáticamente la vida útil de los objetos en tiempo de compilación
      En la práctica, fue casi lo contrario. Después de hacer programación de sistemas con C, C++ y Rust durante unos 10 años, en mi trabajo actual empecé a usar mucho Haskell, y me abrió bastante los ojos ver que un runtime grande del lenguaje y la recolección de basura no son monstruos en ciertos dominios de problemas
    • Buena parte de la crítica se siente así. Pensé que sería un artículo sobre cómo la transformación async obstaculiza optimizaciones que el compilador puede hacer en código no asíncrono
      La parte sobre lidiar con Weak parece apuntar a construir una estructura de ownership compleja, algo que no es fácil en Rust en general. Yo uso punteros inteligentes débiles muy rara vez
      Casi no se mencionan los canales, que son la herramienta principal para hacer que distintas partes de un programa se comuniquen, ya sea en código asíncrono o al conectar código asíncrono y síncrono. También existen abstracciones de señalización como Notify y semáforos
      Los mutex son lentos y tienden a convertirse en cuellos de botella, y el estado compartido se vuelve complejo rápidamente. Esto se sabe desde hace mucho. El problema podría estar, para empezar, en una estructura como BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORROR
      La observación de que no se puede impedir llamar código bloqueante desde un contexto asíncrono es válida, pero si hace falta se puede manejar de forma relativamente razonable con algo como tokio::spawn_blocking
    • El conteo de referencias también es un tipo de recolección de basura https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      Es muy probable que el autor sepa qué es Arc y cómo funciona; el punto se acerca más a que en Rust async se termina usando Arc mucho más seguido que RAII normal, en comparación con el código síncrono
      Si el 90% de los objetos del programa se gestionan con conteo de referencias, quizá convenga usar recolección de basura por trazado en lugar de pagar el costo de muchas asignaciones y liberaciones pequeñas en el heap y de operaciones atómicas. El ejemplo del tutorial de Tokio también apunta en una dirección similar: https://tokio.rs/tokio/tutorial/shared-state
      Me da curiosidad si una recolección de basura por trazado real en Rust podría hacer significativamente más rápidas las aplicaciones asíncronas típicas, como servidores HTTP: https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
    • La vida útil de Arc no es aleatoria, sino estáticamente desconocida
    • El Arc de Rust se puede mover o tomar prestado, y también se puede usar sin tocar el conteo de referencias
      En muchos casos es mucho más barato que los objetos de lenguajes con conteo de referencias implícito
  • Me gusta Rust, pero async es un desastre, y no se puede escribir código asíncrono como si fuera código síncrono
    Cada vez estoy más convencido de que mezclar ambos es una mala idea, y quizá el enfoque de Go —dejar todo síncrono y ofrecer solo una primitiva de canales async— sea el correcto
    Ahora mismo estoy cableando lógica para llamar métodos síncronos desde una estructura que implementa Future, y es un desafío bastante interesante. Se puede hacer que las abstracciones asíncronas de costo cero sean algo fáciles para el usuario, pero el dolor lo termina cargando el desarrollador de bibliotecas

    • No estoy de acuerdo con lo último. async sin duda también es doloroso para el usuario final, y se siente como usar un lenguaje aparte al que le faltan funciones centrales de Rust, como las vidas útiles y los tipos explícitos, con Pin espolvoreado por todos lados
      Como no se pueden ejecutar fibras con alcance, uno termina poniendo Arc por todas partes; Pin es difícil de usar sin unsafe, y un cambio minúsculo en una función asíncrona puede convertir todos los futures de una base de código en !Send
    • Los desarrolladores de bibliotecas tienen más margen que los usuarios para asumir complejidad. Encargar ese trabajo a desarrolladores experimentados que construyen la infraestructura base va en la dirección correcta
    • Vi un caso de una VM wasm para Rust que ofrece algo parecido a scheduling M:N transparente, y con ese enfoque parecería que se pueden resolver la mayoría de las dificultades de async. Habrá que ver cómo evoluciona
  • Async Everything es un mal lenguaje
    async/await fue una idea terrible para solucionar el problema de que JavaScript no tenía hilos bloqueantes de verdad, y ahora se está agregando a todos los lenguajes. Va a partir en dos el lenguaje y el ecosistema de bibliotecas, y seguirá causando dolor durante mucho tiempo
    Cualquiera que haya hecho multithreading fuera de JavaScript sabe que los actores o los procesos secuenciales comunicantes son la mejor forma de abordar el multithreading
    En la tesis de Joe Armstrong también se explica que la única forma de entender programas multihilo es escribir código estrictamente secuencial para cada hilo y no mezclar en un solo lugar el código de varios hilos. Para minimizar la brecha conceptual, una actividad concurrente real del problema debe corresponder exactamente a un proceso concurrente del lenguaje de programación: https://erlang.org/download/armstrong_thesis_2003.pdf
    También es buena la crítica a async/await de Ron Pressler, quien implementó Project Loom de Java: https://www.youtube.com/watch?v=oNnITaBseYQ

    • Odiar JavaScript es divertido, pero es interesante volver a ver la charla en la que Ryan Dahl presentó Node.js por primera vez: https://www.youtube.com/watch?v=EeYvFl7li9E
      Él era bastante ambivalente respecto de JavaScript en sí; su objetivo principal era encontrar una abstracción para manejar el bucle de eventos de E/S de epoll() sin querer sacarse los ojos. Antes había probado muchas otras formas
    • async/await en realidad no empezó en JavaScript, sino en C#
      Anders Hejlsberg, de C#, también creó TypeScript, y funcionalidades de TypeScript como las clases, las funciones flecha y async/await terminaron entrando en ES6+
      Creo que fue una gran solución para JS/TS, con su event loop de un solo hilo. Pero cuanto más de bajo nivel es un lenguaje, peor funciona como abstracción, así que la mayoría de las críticas a async Rust que salen de ahí son válidas
  • El artículo explica bien la complejidad y las dificultades de async Rust, pero también es importante que una de las filosofías centrales de Rust es la seguridad de memoria sin sacrificar rendimiento
    Los patrones asíncronos de Rust, en particular la forma en que hacen que el compilador garantice la seguridad de los datos, muestran bien esta filosofía. Hay complejidad, pero tiene valor como un modelo de concurrencia más seguro que obliga a los desarrolladores a pensar a fondo en los datos y en el flujo de ejecución
    Puede que Rust no sea la respuesta para todas las aplicaciones grandes de espacio de usuario con mucha concurrencia, pero en sistemas donde la robustez y la seguridad son la máxima prioridad, las concesiones pueden estar justificadas. A medida que evolucione el ecosistema, es probable que aparezcan más abstracciones y bibliotecas que reduzcan ese dolor

  • Escribo mucho Rust lock-free basado en async. El problema principal es que los futures de Tokio son 'static, y eso viene de un error de diseño profundamente incrustado en el ecosistema de Rust: la decisión de que las fugas de memoria son seguras
    Por eso no se puede garantizar estáticamente que un future se limpie correctamente. Cuando se crea una tarea asíncrona, si alguien se olvida del future con std::mem::forget, el borrow checker no puede saber que las referencias que ese future pasó transitivamente siguen vivas
    En vez de repartir Arc por todos lados, uso este crate unsafe: https://docs.rs/async-scoped/latest/async_scoped/
    Con esto se atrapa el 99% de los bugs que habría creado en C++, así que es una concesión razonable. También hay trabajo en curso para implementar futures no-'static de forma segura, y espero que tenga éxito
    Otro gran problema es que async trait actualmente exige futures boxeados, lo que agrega malloc/free en cada frontera de llamada de función, pero está en la hoja de ruta para corregirse este año
    El consejo de “simplemente usa canales” también dispersa el flujo de control por todos lados en bases de código grandes. Los canales se sienten como un GOTO moderno; yo también los uso, pero no suelo usarlos cuando solo quiero ejecutar varias cosas en paralelo y esperar a que terminen

    • La distinción importante es que no es que los futures de Tokio en sí sean 'static, sino que los únicos futures que se pueden spawn para aprovechar la concurrencia del runtime son los 'static
      Para que un future pueda ser poll(), debe estar Pin, y un T: !Unpin que está Pin finalmente debe llamar a Drop: https://doc.rust-lang.org/std/pin/#drop-guarantee
      Los futures generados por la funcionalidad async del compilador tienen esta propiedad, y también se puede poner PhantomPinned en futures manuales. Gracias a esto, después de que fueron poll(), las travesuras con mem::forget pueden asumirse como comportamiento indefinido, y se vuelven posibles bibliotecas de futures intrusivos y autorreferenciales: https://docs.rs/futures-intrusive/latest/futures_intrusive/
      Un future puede seguir vivo y fugarse por culpa de un Arc/Rc, pero desde el punto de vista de quien desarrolla una biblioteca, eso no se puede distinguir razonablemente del uso normal, o no hace falta preocuparse demasiado por ello
    • Si ves como un error de diseño que las fugas de memoria sean seguras, me da curiosidad si preferirías eliminar la mutabilidad interna, eliminar Rc, o bien poner límites de traits unsafe infecciosos