4 puntos por GN⁺ 2025-12-04 | 1 comentarios | Compartir por WhatsApp
  • 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

 
GN⁺ 2025-12-04
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.Threaded distribuye 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 a concurrent()

    • Soy Daroc. Ya apliqué dos correcciones al artículo para reflejar estos comentarios.
      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
    • Tengo una pregunta para Andrew.
      Me interesa saber qué tipo de bug aparece si por error usas asyncConcurrent() donde deberías usar async().
      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
    • Lo bueno de 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

    • Si una función ya se considera “coloreada” solo por llamarse con argumentos distintos, entonces todas las funciones estarían coloreadas y el concepto perdería sentido ;)
      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
    • En realidad, el problema central del function coloring es la duplicación de rutas de código síncronas/asíncronas.
      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
    • El io de Zig no es un effect type contagioso.
      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)
    • En la práctica, parece que Zig colorea todo como async y solo te deja elegir si usar hilos worker o no.
      Pero ese enfoque puede provocar problemas como deadlocks.
      Parte del código no es thread-safe, así que el coloring en realidad puede ayudar
    • Viéndolo como desarrollador de Haskell, Zig parece haber implementado un mónada IO sin soporte del lenguaje
  • 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

    • Si usas hilos del SO directamente, te topas con límites de escalabilidad según la ley de Little.
      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
    • Como referencia, el ExecutionContext API de Scala ayuda a entender mejor estos conceptos
  • 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

    • Existen los builtins de bajo nivel @asyncSuspend y @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
    • Al final, es posible que suspend/resume se implemente como una función de biblioteca estándar en espacio de usuario.
      Viendo el prototipo de Io.Evented, también podría manejarse desde bibliotecas de terceros sobre la base de coroutines sin stack
    • También me pregunto si sería posible implementar suspend/resume con un solo pool de hilos
    • Y no estoy seguro de qué sentido tiene implementar coroutines cooperativas como async preventivo
  • 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 createFile y writeAll.
    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

    var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
    var b_future = io.async(saveFile, .{io, data, "saveB.txt"});
    const a_result = a_future.await(io);
    const b_result = b_future.await(io);
    

    En Rust o Python, una coroutine no avanza si no se hace await.
    En cambio, si en el ejemplo de Zig io.async avanza 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

    • C# también funciona de manera parecida. Una función async se ejecuta en el hilo llamador hasta el primer yield
    • En Zig pasa igual: hay que llamar a .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
    • En la práctica, la ejecución progresa en el momento del 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”
    • JavaScript también funciona así
  • 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

    • Se señala que envolver todo el IO en channels sale caro.
      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
    • Zig tiene 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
    • Me gustaría saber si has probado el lenguaje Odin. Es un “better C” más inspirado en Go que Zig
    • Me gusta que, como en el async/await de C#, no obligue a usar funciones coloreadas.
      Creo que el enfoque “sin color” de Zig es mucho mejor
    • Es un error pensar que el modelo de concurrencia de Go tiene algo especial.
      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