- El lenguaje Zig introdujo un nuevo modelo basado en la interfaz
Io para reducir la complejidad del diseño anterior de I/O asíncrona.
- Este modelo mantiene la misma estructura de funciones sin distinguir entre código síncrono y asíncrono, y brinda dos implementaciones:
Io.Threaded y Io.Evented.
Io.Threaded ejecuta de forma predeterminada de manera síncrona, mientras que Io.Evented usa una ejecución asíncrona basada en bucles de eventos.
- Los desarrolladores pueden controlar la ejecución paralela con las funciones
async() y concurrent(), y optimizar el rendimiento sin cambiar el código.
- Este enfoque busca resolver el problema de la coloración de funciones (function coloring) y conservar la simplicidad y control de Zig al tiempo que mantiene rendimiento asíncrono.
Cambios en el diseño asíncrono de Zig
- Zig buscó un nuevo enfoque porque el diseño asíncrono anterior no encajaba bien con su filosofía de minimalismo.
- El diseño anterior tenía baja integración con el resto de funcionalidades.
- El nuevo modelo permite manejar I/O síncrono y asíncrono con la misma estructura de código.
- El nuevo diseño funciona alrededor de una interfaz genérica
Io.
- Todas las funciones de I/O aceptan una instancia de
Io como parámetro para ejecutarse.
- Tiene una estructura similar a la interfaz
Allocator, lo que permite controlar la E/S de forma comparable a la gestión de memoria.
Estructura de la interfaz Io
- La biblioteca estándar incluye dos implementaciones base:
Io.Threaded: ejecución síncrona de forma predeterminada, con paralelismo por hilos si es necesario.
Io.Evented: ejecución asíncrona basada en bucles de eventos (usa io_uring, kqueue, entre otros).
- Los usuarios pueden escribir sus propias implementaciones de
Io, lo que permite un control más fino del modo de ejecución.
Ejemplo de código y comportamiento
- La función de ejemplo
saveFile() realiza la creación, escritura y cierre de un archivo.
- Al usar
Io.Threaded, funciona mediante llamadas al sistema normales.
- Al usar
Io.Evented, se ejecuta con un backend asíncrono.
- En ambos casos, la finalización está garantizada al invocar
writeAll().
- El mismo código funciona de forma idéntica en entornos síncronos y asíncronos.
- Los autores de bibliotecas no necesitan preocuparse por el modo de ejecución.
Ejecución paralela y async() / concurrent()
- La función
async() solicita ejecución asíncrona, pero con Io.Threaded también puede ejecutarse de inmediato.
- Con
Io.Evented, sí permite guardar dos archivos de forma simultánea con ejecución asíncrona real.
- La función
concurrent() se usa cuando se requiere ejecución paralela real.
Io.Threaded aprovecha un pool de hilos.
Io.Evented lo trata igual que async().
- Elegir mal la función (
async en lugar de concurrent) se considera un bug y no se puede prevenir a nivel del lenguaje.
Estilo de código e integración con el lenguaje
- Mantiene el estilo de código normal de Zig, sin sintaxis exclusiva para programación asíncrona.
- Conserva el flujo de control existente, incluyendo
try, defer, etc.
- Andrew Kelley señaló que "se lee como código Zig estándar".
- Como ejemplo se presentó una implementación de resolución DNS asíncrona.
- A diferencia de
getaddrinfo(), devuelve solo la primera respuesta exitosa y cancela el resto de solicitudes.
Planes futuros y estado actual
Io.Evented sigue en fase experimental y aún no es compatible con todos los sistemas operativos.
- Está planificada una implementación de
Io compatible con WebAssembly, aunque requiere desarrollar funcionalidades relacionadas.
- Existen 24 tareas de seguimiento para
Io, y la mayoría aún no está completada.
- Zig todavía está por debajo de la versión 1.0, y las tareas pendientes clave siguen siendo la I/O asíncrona y la generación de código nativo.
- Con este diseño se espera reducir la frecuencia de reescrituras de código por cambios en la interfaz de I/O.
Resumen de la discusión comunitaria
- En varios comentarios, se destacó que el enfoque de Zig es más simple y flexible que el modelo
async/await de Rust.
- En Rust, el uso combinado de múltiples ejecutores puede aumentar la complejidad.
- En Zig, la interfaz
Io habilita la coexistencia de múltiples ejecutores.
- Algunos señalaron que el código puede volverse algo extenso.
- Sin embargo, una API explícita mejora la seguridad, el rendimiento y el control para pruebas.
- También hubo discusión técnica sobre la diferencia entre ejecución asíncrona y ejecución con hilos, así como sobre implementaciones stackful vs stackless coroutine.
- La
Io de Zig se implementa como una extensión de la biblioteca estándar sin tratamiento especial del lenguaje.
- Se espera que en el futuro se agregue soporte para stackless coroutine.
Conclusión
- El nuevo modelo asíncrono de Zig busca combinar el mantenimiento de la simplicidad del lenguaje con I/O de alto rendimiento.
- Mediante la solución al problema de coloración de funciones, la integración de código síncrono y asíncrono y una estructura de control explícita,
se lo considera una etapa clave en la estabilización de Zig 1.0.
1 comentarios
Comentarios en Hacker News
En general, este artículo es preciso y está bien investigado.
Solo hay un par de correcciones menores.
En una instancia de
Io.Threaded,async()en realidad no funciona de forma asíncrona, sino que se ejecuta de inmediato. Sin embargo,std.Io.Threadeddistribuye por defecto el trabajo asíncrono usando un pool de hilos.Eso sí, si se inicializa con
init_single_threaded, entonces sí se comporta como se describe en el artículo.Y una cosa más: antes existía una función llamada
asyncConcurrent(), pero ahora simplemente fue renombrada aconcurrent()Si quieres enviar retroalimentación en el futuro, puedes escribir a lwn@lwn.net.
Gracias por la sugerencia de corrección y por el trabajo relacionado con Zig
Me interesa saber qué tipo de bug aparece si por error usas
asyncConcurrent()donde deberías usarasync().Quisiera entender si, dependiendo del modelo de IO, eso podría convertirse en UB (comportamiento indefinido) o si solo sería un error lógico
concurrent()es que mejora la legibilidad y expresividad del código, dejando claro que “este código debe ejecutarse en paralelo”Creo que este diseño es bastante razonable.
Aun así, la explicación de Zig resulta confusa.
Insisten en que resolvieron el problema del function coloring, pero en realidad no hicieron más que meter el IO dentro de un effect type.
Eso obliga al llamador a conservar el token, así que sigue siendo una forma de coloring.
Me parece similar a la forma en que Go maneja lo asíncrono
El modelo antiguo de async-await de Zig ya había resuelto el problema del coloring.
El compilador generaba automáticamente versiones sincrónicas/asíncronas según el contexto de la llamada
Zig lo resuelve de forma práctica mediante inyección de dependencias, y con eso basta en la práctica.
La complejidad de las llamadas async es inevitable, pero es el costo de tener control fino
Puedes declarar una variable io global y usarla en cualquier parte (aunque, claro, no se recomienda al escribir bibliotecas).
Si ves el artículo What color is your function?, que resume cinco condiciones del problema del function coloring, es muy probable que el enfoque de Zig no cumpla algunas de ellas (en especial la 4 y la 5)
Pero ese enfoque puede provocar problemas como deadlocks.
Parte del código no es thread-safe, así que el coloring en realidad puede ayudar
Este diseño se parece mucho al async de Scala.
En Scala, el contexto de ejecución se pasa como parámetro implícito, mientras que en Zig se recibe explícitamente.
En la práctica, no ofrecía muchas ventajas frente a usar hilos y colas directamente, y manejar el contexto de ejecución causaba comportamientos complejos e impredecibles.
Parece que el equipo de Zig no tiene mucha experiencia con Scala y por eso pensó que este enfoque era novedoso
La JVM resuelve esto con hilos virtuales, pero a un lenguaje de bajo nivel le cuesta más lograr la misma eficiencia.
Por eso un lenguaje como Zig necesita otra clase de solución de escalabilidad
En el sistema viejo de async/await de Zig, las funciones podían hacer suspend/resume.
Quería usar eso al desarrollar SO para implementar suspensión/reanudación de frames basada en interrupciones de dispositivo.
Me da lástima que con el nuevo sistema de io parezca que ahora haya que implementarlo manualmente
@asyncSuspendy@asyncResume.El nuevo Io es una abstracción común para modos síncrono, con hilos y basado en eventos, así que no incluye un mecanismo de suspensión
Viendo el prototipo de Io.Evented, también podría manejarse desde bibliotecas de terceros sobre la base de coroutines sin stack
En el código de ejemplo se dice que cuando
writeAll()retorna, el trabajo ya está terminado,pero como puede haber distintas implementaciones de IO, en realidad la finalización debería estar garantizada cuando empiece el defer.
De lo contrario, habría que rastrear la relación de dependencia entre
createFileywriteAll.Si es así, al final no parecería muy distinto de una llamada bloqueante.
Además, tampoco queda claro por qué esta interfaz se llama IO.
En realidad, parece más una abstracción para “ejecutar en otro contexto”
Documentación relacionada: std.Io
El siguiente ejemplo me parece interesante
En Rust o Python, una coroutine no avanza si no se hace await.
En cambio, si en el ejemplo de Zig
io.asyncavanza por cuenta propia, entonces se parece más a crear una tarea.Es un diseño válido, pero no es la dirección que tomaron otros lenguajes
asyncse ejecuta en el hilo llamador hasta el primer yield.await(io)para garantizar la ejecución.Si se ejecuta de inmediato o si se encola en el pool de hilos depende de la implementación del runtime de Io
await.En evented io, las dos tareas podrían ejecutarse de forma intercalada; en threaded io, podrían avanzar en segundo plano.
O sea, no existen “tareas que se ejecutan en secreto por algún lado”
Como alguien que usa Go todos los días, siento que el Io de Zig corrige varias debilidades de Go.
Pero me pregunto si Zig tiene un concepto de channel.
En Go existe la palabra clave select, pero siempre me ha frustrado que no se pueda usar con sockets
Los channels de Go tienen una sobrecarga de decenas de ciclos, así que para IO de grano fino resultan ineficientes.
En cambio, sí sirven para movimiento de datos de grano grueso o sincronización muchos-a-muchos
std.Io.Queue, que es similar a los channels de Go.También se puede implementar algo parecido a select, aunque sintácticamente es menos ergonómico.
A cambio, funciona sobre distintos runtimes de IO sin GC
Creo que el enfoque “sin color” de Zig es mucho mejor
Las goroutines no son más que green threads, y los channels no son más que colas thread-safe; Zig ya ofrece eso en su biblioteca estándar
La versión async de Io en Zig se ve casi igual al enfoque de Go.
La diferencia es que en Go hay un alto costo de asignación de stack al llamar bibliotecas C, y hacer syscalls directas trae problemas de compatibilidad entre plataformas.
Zig parece haber hecho esto configurable, de modo que se puedan elegir distintos trade-offs sin cambiar el código
El nuevo async IO es excelente para ejemplos simples, pero podría quedarse corto en IO complejo a nivel de servidor.
Dejé un issue relacionado en GitHub
El problema clave es que el diseñador del lenguaje o de la biblioteca debe ofrecer una forma de conectar distintos contextos de ejecución (sync/async).
Para eso hace falta envolver el contexto en una FSM (máquina de estados finitos) y proveer un canal de comunicación entre ambos lados
Artículo relacionado: Function colors represent different execution contexts