2 puntos por GN⁺ 2025-07-14 | 1 comentarios | Compartir por WhatsApp
  • La introducción de la nueva interfaz de I/O de Zig permite que quien llama elija e inyecte directamente la forma de implementar el I/O
  • La nueva interfaz Io fue rediseñada para soportar al mismo tiempo asincronía y paralelismo, con foco en la reutilización de código y la optimización
  • Se planea ofrecer diversas implementaciones en la biblioteca estándar, como Blocking I/O, bucles de eventos, pools de hilos, green threads y corrutinas stackless
  • La nueva API permitirá cancelación de futures y gestión de recursos, además de buffering y comportamientos de entrada/salida granulares
  • Al resolver el problema del function coloring, será posible optimizar con una sola biblioteca tanto la operación sincrónica como asíncrona

Resumen general

Zig evolucionó recientemente con el diseño de una nueva interfaz de I/O, enfocándose en la flexibilidad de las operaciones de I/O y el soporte de paralelismo. Este cambio separa el paradigma existente de async/await para que quienes desarrollan programas puedan adoptar estrategias de I/O mucho más variadas.

Nueva interfaz de I/O

Antes, los objetos relacionados con I/O se creaban y usaban directamente dentro del código, pero ahora se cambió para que la interfaz Io sea inyectada por quien llama.

  • Este enfoque, similar al patrón Allocator, permite que el lado llamador elija e inyecte la implementación concreta de I/O
  • También permite aplicar de forma consistente una estrategia de I/O al código de paquetes externos

Cambios principales

  • La interfaz Io ahora también se encarga de las operaciones de concurrencia
  • Si el código expresa correctamente la concurrencia, la implementación de Io puede ofrecer paralelismo según corresponda

Código de ejemplo

  • Se comparan dos casos: código sin concurrencia (serial) y código que expresa posibilidad de paralelismo con io.async y await
    • Código serial: guarda en dos archivos uno tras otro, sin poder aprovechar oportunidades de paralelismo
    • Código paralelo: guarda archivos usando futures, funcionando con mayor eficiencia en un event loop asíncrono

Combinación de await y try

  • Al usar await junto con try, existe el problema de que si ocurre un error en un future, puede que no se liberen los recursos de otro future
  • Con defer y future.cancel se puede dejar en claro la cancelación y limpieza adecuadas

API Future.cancel

  • Future.cancel() y Future.await() son idempotentes (llamarlas varias veces no genera efectos secundarios)
  • Si se llama cancel sobre un future ya completado, solo se liberan los recursos; si la tarea no había terminado, devuelve error.Canceled

Implementaciones de I/O en la biblioteca estándar

La interfaz Io es una interfaz basada en polimorfismo en tiempo de ejecución, que puede implementarse directamente o usarse mediante implementaciones de paquetes de terceros. La biblioteca estándar de Zig planea ofrecer varios tipos de implementaciones de I/O.

  • Blocking I/O: usa simplemente entrada/salida blocking al estilo C existente, sin overhead adicional
  • Pool de hilos: distribuye operaciones de Blocking I/O en un pool de hilos del sistema operativo, introduciendo algo de paralelismo. En casos como clientes de red, aún requiere optimización
  • Green threads: aprovecha llamadas al sistema asíncronas como io_uring en Linux para manejar múltiples hilos green (ligeros) sobre hilos del sistema operativo. Requiere soporte de plataforma (prioridad inicial en Linux x86_64)
  • Corrutinas stackless: corrutinas basadas en máquinas de estado que no requieren pila explícita. Buscan compatibilidad con ciertas plataformas como WASM. Requieren reintroducir una convención propietaria del compilador de Zig

Objetivos de diseño

Reutilización de código

El mayor problema del I/O asíncrono es la reutilización de código. En otros lenguajes, existen funciones blocking y async por separado, lo que divide el código. El enfoque de Zig permite:

  • que una sola biblioteca soporte eficazmente modos sincrónicos y asíncronos
  • que async/await elimine el fenómeno de “function coloring”, y que el sistema Io no dependa de un modelo de ejecución específico ni siquiera en tiempo de ejecución

En conclusión, resuelve por completo el problema del function coloring

Optimización

  • La nueva interfaz Io está implementada como una llamada virtual no genérica basada en vtable
  • Las llamadas virtuales reducen el crecimiento del código, aunque agregan un pequeño overhead en ejecución. En builds optimizados, si solo hay una implementación de Io, es posible hacer de-virtualization (eliminación de llamadas virtuales)
  • Si se usan varias implementaciones de Io, se mantienen las llamadas virtuales para evitar duplicación de código

Estrategia de buffering

  • Antes, cada implementación (reader/writer) se encargaba del buffering, pero ahora este se realiza en el nivel de las interfaces Reader y Writer
  • Excepto por el flush del buffer, no se pasa por la ruta de llamada virtual, lo que facilita la optimización

Operaciones semánticas de I/O

La interfaz Writer ofrece dos nuevos primitivos para operaciones de optimización específicas

  • sendFile: inspirado en POSIX sendfile, mueve datos entre descriptores de archivo dentro del kernel. Minimiza copias de memoria
  • drain: soporta escritura vectorizada + splatting. Permite enviar múltiples segmentos de datos en lote y puede traducirse a la syscall writev. Con el parámetro splat, se puede reutilizar repitiendo el último elemento (útil en streams como compresión)

Hoja de ruta

Parte de este cambio comenzará a introducirse desde Zig 0.15.0, pero como requiere una gran reorganización de la biblioteca, la adopción completa tendrá que esperar a la siguiente versión. Módulos principales como SSL/TLS y el servidor/cliente HTTP también serán rediseñados con el nuevo sistema Io.

FAQ

P: Zig es un lenguaje de bajo nivel, ¿por qué async es importante?

  • Zig apunta a solidez, optimización y reutilización
  • Al estandarizar la entrada/salida non-blocking, también se pueden ajustar y reutilizar bibliotecas y código de terceros de acuerdo con la estrategia global de I/O

P: ¿Los autores de paquetes ahora deben usar async en todo su código?

  • No. No todo el código necesita expresar concurrencia
  • El código secuencial normal también funciona de acuerdo con la estrategia de I/O elegida por el usuario

P: ¿Cualquier modelo de ejecución funcionará bien con solo conectarlo como plugin?

  • En la mayoría de los casos, sí
  • Pero si hay errores de programación en el código (por ejemplo, no cumplir requisitos de trabajo concurrente), no funcionará correctamente

También se menciona, junto con ejemplos de ejecución, la necesidad de diseñar correctamente el flujo de funcionamiento y de entender la diferencia entre asincronía y paralelismo.

Conclusión

Con la introducción de la nueva interfaz Io, Zig aumentó notablemente la flexibilidad para elegir estrategias de entrada/salida, la reutilización de código y la capacidad de optimización. Gracias a esto, sin las limitaciones de escribir funciones separadas para lo asíncrono y lo sincrónico, quienes desarrollan podrán expresar con mayor claridad las estructuras de concurrencia y paralelismo, y responder de forma eficaz a distintas plataformas y modelos de ejecución.

1 comentarios

 
GN⁺ 2025-07-14
Comentarios en Hacker News
  • Quiero volver a señalar este punto. El artículo incluso menciona que Zig resolvió por completo el problema del function coloring, pero no estoy de acuerdo. Si volvemos a pensar en las 5 reglas del famoso texto "What color is your function?", en Zig no hay colores separados como async/sync o red/blue, pero al final siguen existiendo solo dos casos: funciones de IO y funciones sin IO. Aunque técnicamente resolvieron el problema de que la forma de llamar funciones cambie según el color, sigue siendo necesario pasar IO como argumento a las funciones que lo requieren, mientras que las que no lo necesitan no lo reciben. En el fondo, se siente como que la esencia no cambió. Las funciones de IO solo pueden llamarse desde funciones de IO, y eso tampoco se escapa del problema del coloring. Claro, también puedes pasar un executor nuevo, pero no estoy seguro de que eso sea realmente lo deseable. En Rust también se puede hacer algo parecido. El hecho de que las llamadas a funciones coloreadas sean incómodas sigue siendo igual. Que algunas funciones clave de librerías estén coloreadas tampoco aplica ni para Zig ni para Rust. La esencia del problema del coloring está en que las funciones que necesitan contexto —es decir, un async executor, auth, allocator, etc.— deben recibir obligatoriamente ese contexto al momento de llamarlas. No creo que Zig haya resuelto de verdad esa parte. Eso sí, la abstracción de Zig está muy bien hecha, y Rust se queda corto en ese aspecto. Pero el problema del function coloring en sí sigue ahí

    • La diferencia clave frente al típico async function coloring es que el Io de Zig no es simplemente un valor especial para manejo asíncrono, sino un valor inevitablemente necesario para todo IO: leer archivos, dormir, obtener la hora, etc. Io no es una propiedad de la función, sino un valor normal que puede estar en cualquier lado. En la práctica, gracias a esa característica, da la impresión de que el problema del coloring está resuelto. En la mayoría de los codebases, el IO ya está en algún scope, así que solo las funciones de cálculo realmente puras terminan sin necesitar IO. Si una función de pronto pasa a necesitar IO, en la mayoría de los casos puede tomarlo directamente de my_thing.io y usarlo. No hay la molestia de tener que pasar un Allocator a todas las funciones como en Rust. O sea, si cambia una ruta del código y ahora hay que hacer IO, no hace falta propagar cambios por cada función: se puede usar de inmediato. En términos teóricos, estoy de acuerdo en que el function coloring sigue existiendo, pero en la práctica casi todas las funciones quedan async-colored, así que el problema real casi desaparece. De hecho, los desarrolladores de Zig consideran que pasar un Allocator explícitamente no genera la molestia del function coloring. Creo que con Io pasará algo parecido y no será un gran problema

    • Creo que falta mencionar el punto importante. Cuando usas librerías de Rust, inevitablemente tienes que ajustarte a condiciones como async/await, tokio, send+sync, y si la API es sync, en una app async en la práctica no sirve. En cambio, la forma de pasar IO en Zig resuelve ese problema de raíz. Gracias a eso, no hace falta sufrir implementando procedural macro o multiversionado a la fuerza, y de hecho ese enfoque tampoco termina resolviendo bien el problema del multiversionado de librerías. Hay varias discusiones sobre la mezcla async/sync en Rust, y también se explica en este enlace https://nullderef.com/blog/rust-async-sync/. Ojalá Zig también logre resolver bien en adelante aspectos como cooperative scheduling, async de alto rendimiento y async thread-per-core

    • No soy experto en teoría de categorías, pero al final, si sigues este camino de manejo de contexto, terminas llegando al monad de IO. Este contexto puede existir de forma implícita, pero si quieres ayuda real del compilador, tiene que aparecer como una entidad concreta dentro del sistema. Y aunque las ambiciones de los lenguajes de sistemas han terminado enterradas una y otra vez en cementerios de Async o corutinas, el hecho de que Andrew haya redescubierto de alguna manera el monad de IO y lo haya implementado bien es una esperanza generacional. Las funciones del mundo real sí tienen color. O defines reglas claras de movimiento, o inevitablemente te vas por el camino cada vez más complejo de co_await en C++ o tokio. Creo que este es justamente ‘The Way’

    • Hay un truco simple para volver todas las funciones rojas (o azules)

      var io: std.Io = undefined;
      
      pub fn main() !void {
        var impl = ...;
        io = impl.io();
      }
      

      Si usas io como variable global, ya no tienes que preocuparte por el coloring. Es una broma, pero sin duda hay algo de fricción en tener que usar la interfaz Io; aun así, es un problema esencialmente distinto de la fricción real que aparece al usar async/await. Como yo lo veo, el núcleo del problema del function coloring es la coloración estática que introduce la keyword async y que impide reutilizar código. En Zig, da igual si una función se vuelve async o no, porque de todos modos recibe IO como argumento, así que desde esa perspectiva el coloring deja de tener sentido. En segundo lugar, con async/await te obligan a usar corutinas sin stack —o sea, cambios de stack controlados por el compilador—, pero el nuevo sistema de IO de Zig puede funcionar como Blocking IO aunque internamente use async. Ahí es donde creo que está el verdadero problema práctico del function coloring

    • Go también sufre un problema de “coloring sutil”. Cuando usas goroutines, siempre tienes que pasar un argumento context para manejar cancelación, y muchas funciones de librería también requieren context, así que termina contaminando todo el código. Técnicamente puedes no usarlo, pero pasar context.Background al azar no es algo recomendado

  • El concepto de sans-io ya se había discutido en Rust y otros ecosistemas; como referencia están https://www.firezone.dev/blog/sans-io, https://sans-io.readthedocs.io/, https://news.ycombinator.com/item?id=40872020

    • Si una función llama directamente a métodos de IO, me cuesta llamarlo sans-io porque la estructura ya no permite separar el IO desde afuera. Como dicen los enlaces, en protocolos basados en streams de bytes, la implementación debería manejar solo buffers de entrada/salida, y la parte que recibe datos de la red tendría que ser pasada explícitamente por quien llama para que sea realmente sans-io. La salida también puede escribirse solo al buffer, o devolver inmediatamente un byte stream cuando ocurre un evento. La forma de retorno es una decisión de implementación, pero el buffer interno es útil cuando se requieren respuestas automáticas. La clave es que la estructura no haga IO directamente
  • Yo creo que el problema del function coloring está en que, ya sea que lo resuelvas en el stack o haciendo unwind del stack, al final una de las dos cosas siempre queda. Zig dice que resuelve el problema del coloring, pero su implementación de IO sigue permitiendo usar blocking/thread pool/green thread. Pero ese Blocking IO en realidad nunca fue el problema. Si mantienes la práctica de no usar estado global, casi cualquier lenguaje puede hacer algo así. Las stackless coroutines siguen sin implementarse, así que da la impresión de “solo falta dibujar el resto”. Si de verdad quieres llamadas de función universales, creo que hay dos formas

    • volver todas las funciones async, pero permitir con un argumento decidir si se ejecutan de forma síncrona o no (con costo de rendimiento)

    • compilar cada función dos veces y elegir la llamada según el contexto (con aumento en tamaño de código y dificultad al manejar function pointers)

      • No soy del equipo central, pero escuché que el plan es dejar que usuarios y casos reales usen bastante la implementación semiblocking, estabilizar la API y luego aplicar justamente esa solución: insertar corutinas reales basadas en stack jumping. En este momento, el compilador de máquina de estados de corutinas de LLVM tiene el problema de depender de libc o malloc. Como la nueva interfaz de io de Zig soporta async/await en userland, más adelante será fácil migrar a una solución correcta de frame jumping y también será más cómodo de depurar. Si las corutinas resultan demasiado difíciles, también dejaron el API de io preparado para aguantar con cambios menores, así que no planean apresurarse demasiado con stackless coroutine

      • ValueTask<T> de C#/.NET cumple un papel parecido. Si termina de forma síncrona no hay overhead, y solo cuando hace falta se usa como Task<T>. Normalmente el código solo hace await, y en tiempo de ejecución el runtime o el compilador decide por su cuenta si va por la vía síncrona o asíncrona

  • Me gusta Zig, pero me da algo de pena ver que se concentren en green threads (fibers, stackful coroutines). Rust también descartó antes de la 1.0 un Runtime trait parecido por problemas de rendimiento. En la práctica, el SO, el lenguaje y las librerías ya aprendieron varias veces los problemas de ese enfoque, y hay material al respecto https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf. En los 90 las fibers eran vistas como una forma escalable de manejar concurrencia, pero hoy en día, con stackless coroutines y los avances del SO y el hardware, ya no se recomiendan. Si siguen así, Zig va a chocar con límites de rendimiento parecidos a Go y será difícil verlo como un competidor real en performance. Ojalá std.fs siga existiendo para los casos que requieren rendimiento

    • Es un malentendido pensar que estamos “apostando todo” a green threads (fibers). En el artículo referenciado por el OP se menciona explícitamente que esperan una implementación basada en stackless coroutines, y también hay una propuesta relacionada https://github.com/ziglang/zig/issues/23446. El rendimiento es importante, y si las fibers no dan el ancho en performance, no se usarán de forma general. Nada de lo discutido en este artículo impide que stackless coroutine termine siendo la implementación base de Io

    • Dudo de la afirmación de que green thread tenga mal rendimiento. Las principales plataformas de servidores concurrentes de alto nivel (Go, Erlang, Java) usan green threads o buscan usarlos. Puede que green thread no sea ideal en lenguajes más de bajo nivel (como Rust) por problemas de compatibilidad con C FFI, pero no me parece correcto decir que el rendimiento sea siempre el problema

    • Como es solo una opción entre varias, no creo que se pueda hablar de “all-in”. La implementación se decide en el ejecutable, no en el código de librería

    • Zig apunta a un efecto parecido a la decisión de Rust de quitar green threads y reemplazarlos con async runtime. La intuición clave es formalizar que async=IO e IO=async. En Rust te dan un async runtime enchufable como tokio; en Zig, un IO runtime enchufable. La dirección al final es sacar el runtime del lenguaje, dejarlo conectable desde userland y hacer que todos compartan una interfaz común

    • El material (P1364R0) fue polémico, y yo creo que era una postura motivada para eliminar cierto enfoque. Como material de discusión también pueden servir https://old.reddit.com/r/cpp/comments/1jwlur9/stackful_coroutines_faster_than_stackless/, https://old.reddit.com/r/programming/comments/dgfxde/fibers_arent_useful_for_much_any_more/f3bmpww/

  • Me parece algo extraño forzar polimorfismo en runtime incluso para operaciones estándar de IO comunes en un lenguaje de sistemas como Zig. En la mayoría de los casos reales, la implementación de IO puede quedar decidida estáticamente, así que me pregunto por qué habría que imponer overhead en runtime

    • Creo que el overhead de dynamic dispatch en IO en la práctica será casi insignificante. Dependerá del destino del IO, claro, pero al final es mucho más común que el IO no sea el cuello de botella del CPU. Por eso justamente se habla de IO bound

    • A la pregunta “¿por qué imponer overhead de runtime a todos?”, parece que la intención es que, en sistemas que usan un solo tipo de io, el compilador optimice y elimine por completo el costo de la double indirection. Y además, como el IO de por sí ya tiene otros bottlenecks, agregar una indirección más casi no pesa

    • En la filosofía de Zig se le da más importancia al tamaño del binario. Con Allocator existe exactamente el mismo trade-off: por ejemplo, ArrayListUnmanaged no es generic respecto al allocator, así que cada asignación incurre en dynamic dispatch. En la práctica, el costo de asignar archivos o escribirlos supera por muchísimo el overhead de una llamada indirecta. Esa obsesión por el tamaño del binario es muy del estilo de Zig. Por cierto, la devirtualization —la optimización que convierte llamadas dinámicas en estáticas— es un mito

    • El polimorfismo en runtime no es algo intrínsecamente malo. Salvo en situaciones como tight loops con branches o cuando el compilador no puede aplicar inline, no suele ser un problema

  • No me encanta que el nuevo parámetro io quede expuesto por todos lados, pero me gusta muchísimo que permita usar fácilmente varias implementaciones (basadas en threads, fibers, etc.) sin imponer una implementación al usuario, igual que la interfaz Allocator. En general es una mejora bastante grande, y si entre varias implementaciones de stdlib hay una opción de io síncrono/blocking sin overhead adicional, entonces de verdad estaría siguiendo la filosofía de Zig de “no pagas por lo que no usas”

    • ¿De verdad es posible eso de “no pagas por lo que no usas”? A menos que tengas un equipo pequeñísimo y con una disciplina enorme, al final alguien más lo va a usar y yo también voy a terminar pagando el costo. Y además, seguir pasando io por todas partes me parece más molesto que simplemente llamar donde hace falta
  • En Zig, io.async expresa asincronía —que el orden de las tareas no necesariamente esté garantizado, pero el resultado sea correcto—, no concurrencia. O sea, el punto clave es que separaron el significado de async del de las llamadas a io. Me parece un diseño muy inteligente

  • Me gusta que la interfaz de IO permita crear un vfs (Virtual File System) a nivel de lenguaje

    • Al ver el código de ejemplo pensé que, desde una perspectiva de seguridad, quizá también se podría aplicar seguridad basada en capabilities. Por ejemplo, pasarle a una librería una instancia de io que solo pueda leer dentro de un directorio específico. Referencia: https://news.ycombinator.com/item?id=44549430
  • Para aprender Zig hice un servidor ssh sencillo. Gracias a esta estructura nueva de IO/event loop me resultó mucho más fácil entender el flujo del código. Gracias a Andy

    • Me da curiosidad qué parte de este nuevo diseño fue lo que te hizo más fácil entender el event loop/io
  • El texto está muy bien escrito y me pareció muy interesante. Sobre todo me entusiasman las implicaciones para WebAssembly. La idea de poder usar WASI desde userspace y además tener Bring Your Own IO me parece realmente muy interesante