- La biblioteca de conexión de Rust de Firezone,
connlib, administra conexiones de red y túneles de WireGuard, y obtiene pruebas rápidas y alta confiabilidad operativa gracias a un diseño sans-IO - Los protocolos se implementan como máquinas de estado puras sin manipular sockets directamente, y el bucle de eventos llama APIs como
handle_input,poll_transmit,handle_timeoutypoll_timeout - Al empujar la elección de IO fuera de la máquina de estado, se reduce la carga de function colouring de async en Rust, y se deja a la aplicación la elección entre blocking IO, non-blocking IO o un runtime async específico
- Al abstraer sockets y tiempo, es posible validar paso del tiempo, pérdida de paquetes y respuestas anómalas solo con
InstantyTransmit, sin puertos reales ni tiempos de espera - A cambio, hay que administrar el bucle de eventos manualmente, lo que puede introducir bugs sutiles; los flujos secuenciales aumentan el código de la máquina de estado; y el ecosistema de Rust de bibliotecas sans-IO sigue siendo limitado
La estructura sans-IO elegida por Firezone connlib
- Firezone usa Rust para crear acceso remoto seguro y escalable en Android, macOS y Linux
- En el centro de cada app está
connlib, y esta biblioteca administra conexiones de red y túneles WireGuard para proteger el tráfico - El stack de Rust de Firezone usa
tokio,tungstenite,boringtun,rustls, etc., pero su estructura interna difiere del código async típico en Rust- Casi no hay llamadas a
tokio::spawn - Toda la comunicación se multiplexa sobre un solo socket UDP
- En varias capas se repiten APIs como
handle_timeout,poll_transmityhandle_input
- Casi no hay llamadas a
- Estas características son señal de un diseño sans-IO, donde la lógica del protocolo no realiza IO directamente sino que expresa estado e intenciones de entrada y salida
- En el ecosistema de Python existe un sitio de documentación dedicado a sans-IO, y en Rust las siguientes bibliotecas usan este patrón
Async Rust y la carga de function colouring
- Las funciones async de Rust solo pueden llamarse desde otras funciones async, lo que crea la restricción de function colouring, donde toda la cadena de llamadas termina volviéndose async
- Esta restricción fuerza en tiempo de compilación que el contrato API de una función incluya el hecho de que su ejecución puede suspenderse y reanudarse después
- Por una sola función async en una parte profunda del stack, otras funciones de la ruta de llamada también pueden volverse async para poder usar
.await - El trabajo realmente async suele ocurrir al fondo del stack de llamadas
- escritura en sockets
- lectura de archivos
- esperar a que pase el tiempo
- Muchas funciones async no hacen trabajo asíncrono por sí mismas, sino que son async porque dependen de otras funciones async
- El
connlibde Firezone usa ICE para NAT traversal y usa STUN para encontrar un server-reflexive candidate, es decir, una dirección pública - Un binding STUN es un protocolo simple en el que se envía un paquete UDP al servidor y se recibe una respuesta UDP con la IP y el puerto que el servidor observó
- El mismo ejemplo de STUN puede escribirse casi igual tanto con el
UdpSocketasync detokiocomo con el blocking IO de la biblioteca estándar - Si una biblioteca quiere ofrecer funcionalidad STUN, surge la duplicación de tener que elegir entre una versión async y una blocking, o incluir ambas
- El código de ejemplo está en firezone/sans-io-blog-example
El núcleo de sans-IO: separar la política de la implementación de IO
- El núcleo de sans-IO es parecido al principio de inversión de dependencias de la programación orientada a objetos
- El código de política que decide “qué hacer” no debería depender de los detalles de implementación que realizan “cómo hacerlo”
- Si el código que decide enviar un mensaje de red depende directamente del código real de envío por socket, el código superior también queda atado a la elección de async o blocking IO
- En el ejemplo de STUN, el código de política es el mismo, pero si se monta sobre
tokio::UdpSocketse vuelve async, y si se monta sobrestd::net::UdpSocketse vuelve blocking IO - En vez de llamar directamente a
UdpSocket::send, sans-IO crea una abstracción que representa la intención de transmisión - En el ejemplo,
Transmitcontiene la siguiente información- destino
SocketAddr payloada transmitir
- destino
- El código del protocolo no escribe directamente en el socket, sino que emite
Transmit - La llamada real a
UdpSocket::sendosend_toqueda a cargo del bucle de eventos - El código sans-IO debe ser impulsado por el bucle de eventos, de forma parecida a como un
Futurede Rust necesita ser polleado por el runtime para avanzar
Convertir el binding STUN en una máquina de estado
- Una solicitud de binding STUN puede modelarse como una máquina de estado con estados
SentyReceived - En el ejemplo, el estado se expresa con el siguiente enum
SentReceived { address: SocketAddr }
StunBindingmantiene el estado actual y una cola deTransmitpendientes de envío- Las APIs principales tienen funciones claras
handle_input: entrega a la máquina de estado un paquete que llegó como resultado deUdpSocket::recvpoll_transmit: permite que el bucle de eventos tome elTransmitque la máquina de estado quiere emitirpublic_address: consulta la dirección pública recibida
- En esta estructura, la lógica del protocolo modela solo el comportamiento del programa, sin IO
- El bucle de eventos envía por socket cualquier paquete pendiente obtenido con
poll_transmit, y si no hay ninguno, pasa ahandle_inputlos datos leídos del socket - El bucle de eventos funciona sin conocer el detalle de que STUN es un protocolo request-response
- UDP es un protocolo no confiable, por lo que pueden perderse paquetes, y STUN requiere un temporizador de retransmisión para mitigar eso
Abstraer también el tiempo
- En protocolos de red, cuando hace falta el tiempo actual, normalmente es para comprobar cuánto tiempo ha pasado desde cierto punto de referencia
- si ya pasaron 5 segundos desde que se envió una solicitud
- si ya pasaron 30 segundos desde el último keep-alive
- En esos casos no hace falta la hora real de reloj, sino solo la
Durationrespecto de un momento anterior Instantde Rust no expone la hora actual, sino que permite medir laDurationentre dos valoresInstant- Una máquina de estado sans-IO puede tener dos APIs para el comportamiento basado en tiempo
poll_timeout: devuelve cuándo el bucle de eventos debe programar el próximo temporizador de wake-uphandle_timeout: informa a la máquina de estado que el temporizador expiró
- El ejemplo extiende la máquina de estado para enviar una nueva solicitud de binding si pasan 5 segundos desde la última respuesta recibida
handle_inputrecibe el paquete junto con elInstantactual y lo guarda comoState::Received { address, at }- El bucle de eventos procesa tanto la recepción de socket como la expiración del temporizador, y luego vuelve a configurar el temporizador según el resultado de
poll_timeout
Composición y flexibilidad de API
- Las APIs principales de
StunBinding—handle_timeout,handle_input,poll_transmit,poll_timeout— no están especializadas solo para STUN - La mayoría de los protocolos de red pueden implementarse con esta forma o alguna variante, por lo que la composición de máquinas de estado es sencilla
- Si se quiere consultar a 5 servidores STUN para encontrar la IP pública, se pueden crear 5 instancias de
StunBindingy llamarlas en secuencia- En este caso, la multiplexación de mensajes STUN debe implementarse adecuadamente, y pueden usarse
TransactionIdo la dirección del servidor
- En este caso, la multiplexación de mensajes STUN debe implementarse adecuadamente, y pueden usarse
snownetde Firezone combina ICE y WireGuard para ofrecer a las aplicaciones un túnel IP que funciona en varios entornos de redsnownetestá construido sobre la biblioteca WebRTC sans-IOstr0my la implementación de WireGuard casi sans-IOboringtun- Firezone no necesita todo el stack de WebRTC, sino solo
IceAgent, que implementa RFC 8445 - Como
str0musa el enfoque sans-IO, es fácil tomar soloIceAgenty componerlo dentro de la máquina de estado del código existente - La conexión de
snownetcontieneIceAgenty un túnel WireGuard, y entrega los mensajes entrantes a uno u otro
Ventajas de escribir el bucle de eventos directamente
- El código sans-IO solo expresa el estado del sistema y no provoca efectos secundarios, por lo que el bucle de eventos debe consultar el estado, ejecutar acciones y entregar nuevas entradas
- Esta estructura puede parecer boilerplate, pero permite escribir el bucle de eventos directamente y lograr control fino
- La aplicación puede elegir directamente formas de ejecución como las siguientes
- reducir la cantidad de system calls al enviar paquetes con
sendmmsg - multiplexar varios protocolos sobre un solo socket
- reducir la cantidad de system calls al enviar paquetes con
- El autor de la biblioteca puede concentrarse en implementar funciones del protocolo en lugar de debatir runtimes async o exponer APIs de opciones de socket
str0mconsidera la enumeración de interfaces de red como una preocupación de IO y la deja en manos de la aplicación- En cambio, solo ofrece una API para agregar direcciones de socket al estado actual como ICE candidates
- Firezone usa esta estructura para implementar una optimización que recolecta TURN candidates por adelantado antes de que se cree la conexión y así reduce la latencia de establecimiento
- En ICE, ambos lados recolectan candidates, es decir, sockets, y luego prueban la conectividad entre ellos
Pruebas rápidas y validación de edge cases
- El código sans-IO, por su propia naturaleza, no tiene efectos secundarios y encaja bien con las pruebas unitarias
- Como sockets y tiempo están abstraídos, las pruebas no necesitan abrir puertos reales ni esperar tiempo real
- Para probar el comportamiento 5 minutos después, basta con pasar un
Instantmodificado a la función y verificar el cambio de estado - Firezone ofrece un ejemplo real de prueba para verificar si
snownetcierra una conexión idle después de 5 minutos - La transmisión de datos tampoco necesita sockets reales: se puede sacar un
Transmitde un lado y entregarlo alhandle_inputde la máquina de estado del otro lado - Firezone implementó una máquina de estado de referencia que representa cómo debe comportarse
connlib - Esta máquina de estado de referencia se usa como base en las pruebas
- Con state machine testing de
proptest, se muestrean y ejecutan de manera determinista miles de escenarios en cada CI, y se compara la máquina de estado de referencia con el estado real deconnlib - Sin IO, también es fácil probar fallos y comportamientos anómalos como los siguientes
- pérdida de paquetes que impide recibir una respuesta
- recepción de una respuesta inválida
- RTT muy alto hacia el servidor
- ausencia de una interfaz IPv6 funcional
- presencia solo de interfaces IPv6
- Cuando se separa la implementación del protocolo de los efectos secundarios reales de IO, la detección y el manejo de errores pasan a formar parte del procesamiento de entrada de la máquina de estado
Por qué Rust y sans-IO encajan bien
- Rust obliga a especificar qué componente o función posee un valor
- Al leer desde
UdpSocket, hay que proporcionar un&mut [u8]como espacio donde recibir los bytes reales - Solo quien posee el valor puede volverlo mutable o pasar una referencia mutable temporal a otra función
- Este modelo explícito de ownership y mutabilidad es la base de funciones de Rust como el borrow checker
- Las APIs de máquinas de estado en un diseño sans-IO son todas funciones sincrónicas y no hacen block esperando IO o tiempo
- Como la máquina de estado es solo una estructura de datos, resulta fácil expresar cambios de estado con
&mut selfy aprovechar el borrow checker para garantizar la soundness del código - En cambio, en async Rust
&mutpuede sentirse más difícil de manejar - Las funciones async de Rust se compilan como estructuras de datos que implementan
Future - Para hacer spawn de un
Futureen un runtime comotokio, esa estructura de datos debe ser'static, por lo que no puede contener referencias como&mut - Para cambiar estado fuera del
Future, normalmente se usa una de estas opciones- punteros con conteo de referencias y mutex, como
Arc<Mutex<T>> - un enfoque actor, haciendo spawn de varias tasks y conectándolas con channels
- punteros con conteo de referencias y mutex, como
- Ambos enfoques tienen overhead en runtime
- los locks pueden generar contention
- el envío de mensajes por channel requiere copias
- Si varias tasks se ejecutan en orden no determinista dentro del runtime, eso puede llevar a race conditions y deadlocks
- Como el código de protocolo sans-IO no hace spawn de tasks, para cambiar estado solo necesita
&mut self - Si no hay tasks ni threads, no hacen falta primitivas de sincronización como
Mutex; y si no hay channels, también se reduce la necesidad de copiar datos - Firezone considera que, tras pasar a sans-IO, disminuyó el trabajo de rastrear el otro extremo de un channel, channels cerrados o qué código estaba haciendo lock de un
Mutex, lo que facilitó entender el código
Desventajas y alcance de aplicación
- sans-IO no es una solución universal
- Escribir manualmente el bucle de eventos da un control fuerte, pero al principio puede introducir bugs sutiles difíciles de encontrar
- Por ejemplo, si el valor devuelto por
poll_timeoutde la máquina de estado no avanza hacia adelante, el bucle de eventos puede caer en un busy loop - Los flujos secuenciales requieren más código
- Las funciones async de Rust se compilan como máquinas de estado donde cada punto
.awaites una transición a otro estado, por lo que permiten escribir con facilidad código secuencial y non-blocking IO al mismo tiempo - En sans-IO, esas etapas deben modelarse manualmente como máquina de estado
- Un protocolo request-response como
StunBindingno es difícil, pero representar flujos secuenciales más grandes puede volverse tedioso - En la comunidad de Rust, el diseño sans-IO todavía no está muy extendido
- La mayoría de las bibliotecas implementan blocking IO o non-blocking IO en lugar de sans-IO
boringtunllama internamente aInstant::now, por lo que tiene algunas partes no totalmente puras, y hay un issue relacionado en cloudflare/boringtun#391
Cierre
- El código sans-IO puede parecer extraño al principio, pero una vez que uno se acostumbra encaja bien con las herramientas de Rust para modelar máquinas de estado
- Como su estructura obliga a tratar los errores como si fueran otra entrada más, también combina bien con la forma de escribir código de networking
- Existen otras maneras de escribir async Rust, y structured concurrency se ubica entre sans-IO y el enfoque de async Rust tratado aquí
- Sobre structured concurrency, se puede consultar Let futures be futures de withoutboats
1 comentarios
Opiniones en Hacker News
Aunque esto se presenta como innovación y avance, en realidad antes de que llegara el soporte del lenguaje para async/await, así era como se manejaba la asincronía en
$lang, incluido RustEn el desarrollo de firmware embebido con Rust, el mayor aumento de productividad llegó cuando pudimos dejar de implementar a mano una máquina de estados entre cada operación de I/O y de mover variables locales a estados personalizados, para hacer que Rust lo hiciera por nosotros con la sintaxis async/await
En Rust, async al final se descompone en una máquina de estados automática que guarda los valores entre puntos de I/O (
await)Pero no siempre es así. En casos de uso centrados en paquetes, como QUIC, WebRTC o IP, la I/O real en sí es sencilla. Basta con enviar y recibir paquetes/datagramas individuales
Como no hay muchos puntos
.await, tampoco hay gran cosa para que el compilador genere. Al mismo tiempo, varios aspectos tienen que ejecutarse en paralelo y cada uno debe ir en su propio future/task, así que la gestión de estado a lo largo de esos futures puede convertirse fácilmente en código espaguetiAl hacer menos suposiciones sobre el entorno de ejecución, se vuelve más fácil de probar y de componer
En teoría se podría hacer lo mismo con async/await generando una máquina de estados, pero en la práctica es bastante doloroso y la mayoría del código async/await no es puro
Lenguajes experimentales como Eff, Koka y Frank tienen muy buen soporte para este estilo de programación. En el fondo de las discusiones sobre I/O en Haskell también hay una inversión profunda en técnicas como free monads y sus variantes
Últimamente Unison es un lenguaje interesante: explora varios conceptos nuevos y, a la vez, pone en el centro un sistema de efectos extensible que da buen soporte a este tipo de código a nivel del lenguaje
.awaitpor todas partes. Estaría bueno poder invertirlo: que.awaitse ejecute por defecto y que solo con una sintaxis especial no sea asíAun así, en la práctica veo con demasiada frecuencia que una biblioteca de protocolo hace I/O directamente :-(
Llevaba tiempo dándole vueltas a este espacio de problemas, y este enfoque encaja muy bien con la dirección que tenía en mente. Dicho eso, como menciona la nota al pie 3 del artículo, todavía hay cosas que pulir
Lo que me llevó a pensar en esto fue la discusión sobre los colores de funciones y un hallazgo accidental. Estaba creando una biblioteca VT100 y las pruebas unitarias eran muy difíciles, porque en la práctica estaba haciendo
parser::new(stdin()). En la tercera o cuarta reescritura, casi sin pensarlo, cambié el parser aparser::push(data), y entonces me di cuenta de que Rust estaba castigando un antipatrón de OOP empresarial que empecé a llamar “obsesión por la encapsulación”Ahora veo este patrón y sus daños en todas partes, no solo en la I/O
Irónicamente, esta solución es algo que se enseña tanto antes de la universidad como al inicio de la universidad. La explicación más simple de una computadora es que es una máquina que recibe entrada, procesa/transforma datos y produce salida. La razón por la que esto se relaciona con la discusión sobre los colores de funciones es que lo único cuyo color debería importarnos son la entrada y la salida; la lógica central suele ser transformación de datos
Es obvio, pero viendo la escala del “debate” sobre los colores de funciones, mucha gente fue condicionada a resolver primero los problemas mediante encapsulación, y por eso se le pasó o se le olvidó este hecho. Supongo que la gente del mundo funcional debe sentirse bastante satisfecha en este punto
Para mí, Rust fue menos un viaje de aprendizaje que un viaje de desaprender y volver a aprender. Es un buen patrón y pienso adoptarlo de ahora en adelante
Edit: código relacionado: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...
parser::push(data)y la parte de que Rust castigó un antipatrón de OOP empresarial?Como alguien muy principiante aprendiendo Rust, no alcanzo a ver cuál es el problema evidente de ese patrón ni cómo lo castiga Rust
Había parámetros de tipo y traits por todas partes, y abusaba de los structs como si fueran estructuras parecidas a clases que ofrecen funcionalidad
Rust encaja mejor cuando evitas, en la medida de lo posible, los parámetros de tipo y las definiciones propias de traits
La encapsulación en el sentido de mantener invariantes está bien. Me recuerda a este artículo, “parse, don’t validate”: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
¿Cómo se compara este diseño con el enfoque de usar canales para enviar datos a handlers dedicados? Al usar canales había varios problemas:
(1) con frecuencia terminaba apareciendo código con forma de telaraña, difícil de seguir
(2) había que implementar manualmente tipos de mensajes que pudieran convertirse en mensajes enviables por la red
(3) había que pasar explícitamente el emisor a las entidades interesadas o permitidas
(4) se puede saber si falló el envío de un mensaje por el canal, pero no si ese mensaje falló al enviarse por la red
Aun así, es bastante práctico. Por ejemplo, si tienes un canal
ws_handler, basta con enviarle datos, y algún handler dedicado podría enviar ese mensaje si es posiblesans-IO puede usarse también en aplicaciones, pero siento que es especialmente útil para bibliotecas. En una biblioteca se vuelve mucho más útil porque no obligas al consumidor a usar un enfoque concreto de I/O.
En Rust ya existe una división del ecosistema entre I/O síncrono y asíncrono, y además hay distintos runtimes asíncronos, así que este punto es importante.
Pero, como dijiste, traen problemas. Por ejemplo, los actores/canales pueden desconectarse. Además, para tener contrapresión, el canal necesariamente debe tener límite. Y como requieren copias, puede ser difícil lograr un throughput alto.
Algo relacionado para ver: mónadas, en especial las mónadas Free(r) y los sistemas de efectos[0].
La idea de separar la lógica de la ejecución es un tema grande que ya se ha trabajado bastante en el ecosistema Haskell.
Edición: no se mencionó cómo se encapsulan las llamadas a
tokio::select!que aparecen cuando se necesita manejar tiempos. ¿Se anda cargando untokio::Runtimepara hacer async el código del loop sin que el código externo tenga que ser async?Edición 2: quizá no intentaba mostrar que la biblioteca encapsulada haga eso, sino que la aplicación externa puede usar el binding dentro de un contexto async.
Lo que me daba más curiosidad era cómo se podría implementar, con estilo sans-IO, una función encapsulada que tenga que esperar alguna acción o un timer. O quizá la respuesta esperada sea busy waiting, o llevar encima una instancia propia de runtime async que básicamente sustituya el busy waiting con algo como
block_in_place.[0]: https://okmij.org/ftp/Computation/free-monad.html
StunBinding. Representa la funcionalidad de un binding STUN. No es una única función que simplemente puedas llamar; necesita un event loop.La clave es que
StunBindingpuede estar dentro de una biblioteca, y del lado de la aplicación se puede componer con la máquina de estados del programa para usarlo. Por supuesto, esto presupone que la aplicación también está estructurada con estilo sans-IO.La biblioteca enlazada,
snownet, hace exactamente esto. El dominio es combinar ICE + WireGuard sin I/O, y se usa en la bibliotecaconnlib, que compone ACL encima de eso.Edición: no hay busy waiting. En cambio,
StunBindingtiene una función que expone qué está esperando mediantepoll_timeout. Cómo materializa eso el llamador, es decir, el event loop, queda a cargo del llamador. Cuando se llama ahandle_timeoutcon elInstantcorrespondiente, ocurre la acción adecuada.¡Oh, es thomaseizinger!
Había mirado por dentro rust-libp2p, así que mientras leía el artículo este patrón me resultó muy familiar; parece que no era casualidad.
Firezone se ve genial. ¡Conecten todo!
Sí, tiene parecidos con rust-libp2p. Pero allí los streams y conexiones reales siguen estando dentro de una estructura parecida a
Future, así que está más entrelazado y no tan estrictamente separado como el sans-IO de aquí.Hay una parte que dice que los flujos de trabajo secuenciales requieren más código. En Rust, las funciones async se compilan como máquinas de estados, y cada punto
.awaitrepresenta una transición a otro estado. Por eso a los desarrolladores les resulta fácil usar código secuencial junto con I/O no bloqueante. Sin async, hay que escribir manualmente una máquina de estados para expresar varios pasos.¿Alguien ha probado combinar async con sans-IO? Al menos conceptualmente, si escribes una función async que hace await a un helper que conoce sans-IO, parecería que todo debería compilarse a una máquina de estados dentro de un struct con una buena interfaz sans-IO, que además pueda llamarse fácilmente desde código no async.
No lo he probado directamente, pero supongo que los principales problemas serían lograr una buena usabilidad y manejar
Pin.En Rust hay generadores/corutinas que pueden cubrir en cierta medida el caso de uso que mencionas, pero hoy son una funcionalidad muy inestable.
Lamentablemente, las corutinas en su forma actual tienen la molesta limitación de exponerse solo mediante el trait
std::ops::Coroutine. Por eso no puedes asignar directamente la máquina de estados interna generada por el compilador, aunque el tamaño de la máquina de estados aparentemente sea una constante en tiempo de compilación.Si es una sola corutina que permanece dentro de una función con lifetimes definidos, no hay problema. El compilador puede darse cuenta de eso y asignar la máquina de estados en la stack.
Pero puede decirse que la aplicación más útil de las corutinas es como elementos de la cola de un dispositivo de event loop. Esta implementación no es posible sin hacer boxing de las corutinas.
Vec>no es una estructura de datos amigable con la caché, y si con I/O de concurrencia extremadamente alta necesitas un millón de elementos en unVec, vas a sufrir.Si algún día Rust obtiene una sintaxis nativa para generadores, quizá se vuelva posible. Podrías hacer
yield transmitpara “escribir” datos sin salir del contexto de la tarea async. Es decir, cadasocket.writese convertiría enyield transmit.Al leer datos, el generador se suspende (
.await) y espera a reanudarse con los datos entrantes. No sé si en nightly existe una sintaxis así, pero debería verse más o menos así:// Made up
gensyntax: gen(yield_type, resume_type)gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
let req = make_stun_request();
yield Transmit {
server,
payload: req
};
En las capas superiores del stack, ambas cosas combinan bastante bien. El I/O no bloqueante, es decir async, facilita esperar al mismo tiempo por I/O de sockets y por tiempo. También se puede hacer con I/O bloqueante configurando un timeout de lectura en el socket, pero usar primitivas async es un poco más fácil.
Yo también estuve pensando constantemente en cómo combinar ambas cosas. Uno de los problemas a los que llegué es que las funciones async se compilan como tipos opacos. Por eso es difícil o imposible usar la capacidad del compilador de generar código para la máquina de estados, porque una vez creada no se puede interactuar con esa máquina de estados. En cierto modo, esto también rompe el borrow checker.
Por ejemplo, supongamos que hay una tarea async con varias etapas, es decir, varios puntos de
await, y que solo uno de esos tramos necesita una referencia mutable a una estructura de datos compartida. En cuanto se expresa como una funciónasync, la referencia mutable queda capturada en el tipoFuturegenerado, y ese tipo existe a lo largo de todas las etapas. Como resultado, Rust no permite ejecutar más de una de esas tareas al mismo tiempo.El consejo habitual en estas situaciones es “captura la referencia mutable durante el menor tiempo posible”, pero con async no se puede. Dividir la función async en varias también se vuelve desprolijo y, en primer lugar, rompe en parte el objetivo de querer expresar todo como una sola función.
Hace un tiempo intenté codificar el protocolo HTTP/1.1 como una máquina de estados Sans-IO con puntos
.awaitpara I/O, pero no llegué muy lejos. Sin embargo, ese I/O no registraba un waker en un runtime async, sino que devolvía el control al usuario para que realizara el I/O directamente. Se puede pensar que.awaitse resuelve hacia “arriba”, no hacia “abajo”.En el contexto de HTTP/1.1, el código async se volvió una especie de plano de cómo el usuario quería que fuera el comportamiento de las llamadas. En ese momento quería que funcionara obligatoriamente en entornos
no_stdy sin asignador, y lo abandoné porque no encontré una forma de evitar el despacho dinámico medianteBox, es decir, la parte que requiere un asignador.https://news.ycombinator.com/item?id=40879547
Hay un ejemplo escrito con la forma
async fn stun. El código completo en funcionamiento está aquí: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...¡Bien hecho! Si expones el estado, puedes hacer que cualquier función async sea pura. El usuario solo tiene que empujar la máquina de estados al siguiente estado.
Hace un tiempo intenté enlazar OpenSSL con async Rust, y esa API async también sigue un diseño parecido.
¿La parte que te parece similar es que la tarea programada como job en sí es independiente de cómo se ejecuta?
Si miras este ejemplo [0], esa API async se parece mucho más a los futures de Rust.
Dentro del job se puede acceder a un “wait context”, se puede suspender bajo ciertas condiciones y se puede disparar un wake para continuar la ejecución.
[0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
Esto es simplemente I/O asíncrono común que usa callbacks en vez de corrutinas.
Al leer el artículo y algunos comentarios, suena como si hubieran reinventado la arquitectura hexagonal o el estilo de arquitectura de puertos/adaptadores.
No tengo muy claro qué debería llevarme de esto. Todo lo que se comenta ya es programación de redes básica.
Parece que se enfocan en el cableado de más alto nivel y se obsesionan demasiado con la gestión de estado, pero eso es una cuestión de gustos y no tiene relación con redes.
Lo más interesante que aprendí del artículo es que Cloudflare opera un servidor STUN público. Pero eso tampoco ayuda mucho. La versión “buena” y “útil” del protocolo STUN era la primera, que soportaba la función
change requestsque permitía la enumeración de NAT. En versiones posteriores de STUN, esa función fue eliminada gracias a las “sugerencias útiles” de los ingenieros de Cisco que contribuyeron a la especificación.Actualmente, en Rust, si por ejemplo implementas una biblioteca WebRTC que usa el runtime async Tokio, resulta muy incómoda de usar para quienes usan I/O síncrono, otro runtime (smol, async-std, etc.) o iouring directamente.
Con este enfoque no se obliga al consumidor a elegir un I/O específico, así que la biblioteca puede ser útil para más personas.