2 pontos por GN⁺ 2025-07-14 | 1 comentários | Compartilhar no WhatsApp
  • Com a introdução da nova interface de E/S do Zig, quem chama pode escolher e injetar diretamente a forma de implementação de E/S
  • A recém-projetada interface Io oferece suporte simultâneo a assincronicidade e paralelismo, com foco em reutilização de código e otimização
  • Estão previstos vários implementadores da biblioteca padrão, como Blocking I/O, loops de eventos, pool de threads, green threads e corrotinas stackless
  • A nova API permite cancelamento de futures e gerenciamento de recursos, além de buffering e comportamento de entrada/saída mais granular
  • O problema de function coloring é resolvido, permitindo otimizar tanto a operação síncrona quanto assíncrona com uma única biblioteca

Visão geral

O Zig vem evoluindo com foco em flexibilidade nas operações de E/S e suporte a paralelismo ao projetar recentemente uma nova interface de E/S assíncrona. Essa mudança separa o paradigma tradicional de async/await, permitindo que quem realmente escreve o programa adote estratégias de E/S muito mais variadas.

Nova interface de E/S

Antes, os objetos relacionados a E/S eram criados e usados diretamente no código, mas agora a interface Io passa a ser injetada por quem chama.

  • Assim como no padrão Allocator, o lado chamador escolhe e injeta a implementação concreta de E/S
  • Também é possível aplicar estratégias de E/S de forma consistente ao código de pacotes externos

Principais mudanças

  • A interface Io agora também cuida das operações de concorrência (concurrency)
  • Quando o código expressa corretamente a concorrência, a implementação de Io pode fornecer paralelismo (parallelism)

Exemplo de código

  • São comparados dois casos: código sem concorrência (serial) e código que expressa possibilidade de paralelismo com io.async e await
    • Código serial: grava em dois arquivos em sequência, sem aproveitar oportunidades de paralelismo
    • Código paralelo: salva arquivos usando futures, funcionando com mais eficiência em um loop de eventos assíncrono

Combinação de await e try

  • Ao usar await e try juntos, existe o problema de que, se ocorrer erro em um future, os recursos de outro future podem não ser devolvidos
  • Com defer e future.cancel, é possível deixar claro o cancelamento e a limpeza adequados

API Future.cancel

  • Future.cancel() e Future.await() são idempotentes (chamá-los várias vezes não causa efeitos colaterais)
  • Se cancel for chamado em um future já concluído, apenas os recursos são liberados; se o trabalho ainda não tiver terminado, ele retorna error.Canceled

Implementações de E/S na biblioteca padrão

A interface Io é baseada em polimorfismo em tempo de execução, podendo ser implementada diretamente ou usada a partir de pacotes de terceiros. A biblioteca padrão do Zig pretende oferecer vários tipos de implementação de E/S.

  • Blocking I/O: usa simplesmente a E/S bloqueante existente em estilo C, sem overhead adicional
  • Pool de threads: distribui operações de Blocking I/O em um pool de threads do sistema operacional, introduzindo algum paralelismo. Em clientes de rede e casos parecidos, otimizações serão necessárias
  • Green threads: usa system calls assíncronas como io_uring no Linux para processar várias threads verdes (leves) em threads do sistema operacional. Exige suporte de plataforma (prioridade inicial para Linux x86_64)
  • Corrotinas stackless: corrotinas baseadas em máquinas de estado que não exigem pilha explícita. Voltadas à compatibilidade com algumas plataformas, como WASM. Exigem a reintrodução da convenção protetiva do compilador Zig

Objetivos de design

Reutilização de código

O maior problema da E/S assíncrona é a reutilização de código, já que em outras linguagens funções bloqueantes e assíncronas costumam existir separadamente, fragmentando o código. A abordagem do Zig:

  • Permite que uma única biblioteca dê suporte efetivo tanto ao modo síncrono quanto ao assíncrono
  • Elimina o fenômeno de function coloring com async/await e, por meio do sistema Io, evita dependência rígida de um único modelo de execução mesmo em runtime

Em resumo, o problema de function coloring é completamente resolvido

Otimização

  • A nova interface Io é implementada de forma não genérica, com chamadas virtuais baseadas em vtable
  • Chamadas virtuais reduzem o inchaço de código, mas têm um pequeno overhead em tempo de execução. Em builds otimizadas, se houver uma única implementação de Io, pode haver de-virtualization (remoção de chamadas virtuais)
  • Ao usar várias implementações de Io, as chamadas virtuais são mantidas (para evitar duplicação de código)

Estratégia de buffering

  • Antes, cada implementação (reader/writer) era responsável pelo buffering; agora o buffering é feito no nível das interfaces Reader e Writer
  • Exceto pelo flush do buffer, o caminho não passa por chamadas virtuais, o que facilita a otimização

Operações de E/S semânticas

A interface Writer oferece dois novos primitivos para operações com otimizações específicas

  • sendFile: inspirado no sendfile do POSIX, move dados entre descritores de arquivo dentro do kernel. Minimiza cópias em memória
  • drain: oferece suporte a escrita vetorizada + splatting. Permite enviar vários segmentos de dados em lote e pode ser convertido em system call writev. O parâmetro splat pode repetir o último elemento, útil em streams como compressão

Roadmap

Parte dessa mudança chega a partir do Zig 0.15.0, mas como é necessária uma grande reformulação das bibliotecas, a adoção completa terá de esperar o próximo release. Módulos importantes como SSL/TLS e servidor/cliente HTTP também devem ser redesenhados com o novo sistema Io

FAQ

P: Zig é uma linguagem low-level; por que async é importante?

  • O Zig busca robustez, otimização e reutilização
  • Ao padronizar a E/S non-blocking, outras bibliotecas e códigos de terceiros também podem ser ajustados à estratégia geral de E/S, garantindo melhor reutilização

P: Autores de pacotes agora precisam usar async em todo o código?

  • Não
  • Nem todo código precisa expressar concorrência
  • Código sequencial comum também funciona de acordo com a estratégia de E/S escolhida pelo usuário

P: Basta plugar qualquer modelo de execução que tudo sempre funcionará?

  • Na maioria dos casos, sim
  • Porém, erros de programação no código (por exemplo, não atender aos requisitos de trabalho concorrente) impedem o funcionamento correto

Junto com exemplos de execução, o texto menciona a necessidade de entender a diferença entre assincronicidade e paralelismo, além de projetar corretamente o fluxo de execução

Conclusão

Com a introdução da nova interface Io, o Zig aumenta bastante a flexibilidade na escolha da estratégia de E/S, a reutilização de código e a capacidade de otimização. Com isso, sem as limitações tradicionais na escrita de funções síncronas ou assíncronas, desenvolvedores podem expressar com mais clareza estruturas de concorrência e paralelismo e responder de forma mais eficaz a diferentes plataformas e modelos de execução.

1 comentários

 
GN⁺ 2025-07-14
Comentários do Hacker News
  • Quero destacar este ponto de novo. O artigo chega a dizer que o Zig resolveu completamente o problema de function coloring, mas eu não concordo. Se voltarmos às cinco regras do famoso texto "What color is your function?", no Zig pode até não haver uma distinção de cor como async/sync ou red/blue, mas no fim ainda só existem dois casos: funções de IO e funções que não são de IO. Pode até ter havido uma solução técnica para o problema de a forma de chamar a função mudar conforme a cor, mas ainda assim você precisa passar IO como argumento para funções que precisam de IO, e não passa para as que não precisam. No fundo, a essência não mudou. Funções de IO só podem ser chamadas a partir de funções de IO, e isso também não escapa do problema de coloring. Claro, também dá para passar um novo executor, mas fica a dúvida se isso é mesmo o que se quer. Em Rust também dá para fazer algo parecido. O incômodo das chamadas de função coloridas continua igual. A parte de algumas funções centrais da biblioteca serem colored também não se aplica nem a Zig nem a Rust. A essência do problema de coloring é que funções que precisam de contexto — isto é, async executor, auth, allocator etc. — exigem que esse contexto seja fornecido na chamada. É difícil dizer que o Zig realmente resolveu essa parte. Ainda assim, a abstração do Zig é muito boa, e o Rust deixa a desejar nisso. Mas o problema de function coloring em si continua lá

    • A diferença principal em relação ao típico async function coloring é que o Io do Zig não é só um valor especial para processamento assíncrono, e sim um valor inevitavelmente necessário para todo IO, como ler arquivos, dormir, obter horário etc. Io não é uma propriedade da função, e sim um valor comum que pode estar em qualquer lugar. Na prática, por causa dessa característica, parece que o problema de coloring foi resolvido. Na maioria das codebases, o IO já existe em algum ponto do escopo, então só funções realmente puras de computação acabam não precisando de IO. Se alguma função de repente passar a precisar de IO, na maioria dos casos dá para pegar e usar direto de my_thing.io. Não existe o incômodo de ter de passar um Allocator para toda função, como em Rust. Ou seja, se o caminho do código mudar e precisar fazer IO, não é necessário propagar mudanças por todas as funções; dá para usar na hora. Em termos teóricos, concordo que function coloring ainda existe, mas na prática é como se praticamente todas as funções já fossem async-colored, então o problema real quase desaparece. Na prática, os desenvolvedores de Zig consideram que passar Allocator explicitamente não causa o incômodo de function coloring. Acho que com Io também não vai ser um grande problema

    • Acho que faltou mencionar o ponto mais importante. Ao usar bibliotecas em Rust, você inevitavelmente precisa se adequar a async/await, tokio, condições como send+sync, e a realidade é que, se a API for sync, ela se torna inútil dentro de um app async. Já a forma como o Zig passa IO resolve isso na raiz. Graças a isso, não é preciso sofrer com procedural macro nem forçar multiversioning, e na prática esse tipo de abordagem também não resolve bem o problema de multiversão de bibliotecas. Há várias discussões sobre a mistura de async/sync em Rust, e o link a seguir também explica isso: https://nullderef.com/blog/rust-async-sync/. Espero que no futuro o Zig também resolva bem partes como cooperative scheduling, async de alto desempenho e async com thread-per-core

    • Não sou especialista em teoria das categorias, mas no fim, quando você segue esse caminho de gerenciar contexto, acaba chegando à mônada de IO. Esse contexto pode existir implicitamente, mas para receber ajuda adequada do compilador ele precisa se manifestar como uma entidade real no sistema. E, embora as ambições das linguagens de programação de sistemas tenham acabado enterradas em túmulos de async ou corrotinas, o fato de o Andrew ter meio que redescoberto a mônada de IO e implementado isso direito me parece uma esperança para a geração. Funções do mundo real têm cor. Ou se definem regras claras de movimentação, ou se acaba caindo num caminho cada vez mais complexo, como co_await do C++ ou tokio. Para mim, este é “The Way”

    • Existe um truque simples para deixar todas as funções vermelhas (ou azuis)

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

      Se você colocar io como variável global, não precisa mais se preocupar com coloring. Estou brincando, mas de fato há um pouco de atrito no fato de ser preciso usar a interface Io, porém isso é essencialmente diferente do friction real que surge ao usar async/await. Para mim, o núcleo do problema de function coloring é a coloração estática imposta pela keyword async, que impede o reuso do código. No Zig, tanto faz tornar uma função async ou não, porque em ambos os casos ela recebe IO como argumento; desse ponto de vista, coloring em si perde o sentido. Em segundo lugar, ao usar async/await você acaba sendo forçado a usar corrotinas stackless — isto é, troca de stack controlada pelo compilador —, mas o novo sistema de IO do Zig pode funcionar como Blocking IO por baixo mesmo usando async internamente. Acho que é aí que está o verdadeiro problema prático de function coloring

    • Go também sofre de um problema de “coloring sutil”. Ao usar goroutines, você sempre precisa passar um argumento de context para lidar com cancelamento, e muitas funções de biblioteca também exigem context, o que contamina o código inteiro. Tecnicamente dá para não usar context, mas sair passando context.Background aleatoriamente não é uma prática recomendada

  • O conceito de sans-io já foi discutido antes em Rust e outros lugares; links de referência: https://www.firezone.dev/blog/sans-io, https://sans-io.readthedocs.io/, https://news.ycombinator.com/item?id=40872020

    • Acho difícil chamar isso de sans-io se a função chama métodos de IO diretamente, porque aí não dá para separar o IO de fora. Como está nos links, em protocolos baseados em byte stream, a implementação deve lidar apenas com buffers de entrada/saída, e a parte de receber dados da rede precisa necessariamente ser passada diretamente pelo lado chamador para ser realmente sans-io. A saída também pode só escrever em buffers ou retornar imediatamente um byte stream quando um evento acontece. A forma de retorno é uma escolha de implementação, mas buffers internos são úteis em situações que exigem resposta automática. O ponto central é uma estrutura que não faz IO diretamente
  • Acho que o problema de function coloring é que, seja processando na stack ou fazendo unwind da stack, no fim uma dessas duas opções sempre permanece. O Zig diz que resolveu o problema de coloring, mas sua implementação de IO ainda permite usar blocking/thread pool/green thread. Só que esse tipo de blocking IO nunca foi o problema original. Mantendo a convenção de não usar estado global, quase toda linguagem consegue fazer isso. Stackless coroutine ainda não foi implementada, então dá uma sensação de “falta só desenhar o resto das peças”. Se a ideia for realmente ter chamada de função universal, acho que há dois caminhos

    • Tornar todas as funções async, mas com um argumento para decidir se vai rodar de forma síncrona ou não (com perda de desempenho)

    • Compilar cada função duas vezes e escolher a versão adequada na chamada (com aumento de tamanho de código e dificuldade para lidar com ponteiros de função)

      • Não faço parte do core team, mas ouvi dizer que, depois que usuários e equipes em produção usarem bastante a implementação semiblocking e a API for estabilizada, o plano é aplicar justamente essa solução: inserir corrotinas de verdade baseadas em stack jumping. Hoje o compilador de máquina de estados de corrotinas do LLVM tem o problema de depender de libc ou malloc. Como a nova interface de io do Zig dá suporte a async/await em userland, no futuro, mesmo que entre uma solução de frame jumping adequada, a migração será fácil e a depuração, conveniente. Se corrotinas se mostrarem difíceis, a ideia também é permitir que a API de io continue funcionando com pequenas mudanças, sem pressa excessiva de partir logo para stackless coroutine

      • ValueTask<T> do C#/.NET cumpre um papel parecido. Se terminar de forma síncrona, não há overhead; só quando necessário ele vira Task<T>. Normalmente o código só usa await, e em tempo de execução o runtime ou o compilador escolhe automaticamente entre síncrono e assíncrono

  • Gosto de Zig, mas fico um pouco decepcionado ao ver o foco em green threads (fibers, corrotinas stackful). O Rust também descartou um Runtime trait parecido antes da versão 1.0 por questões de desempenho. Na prática, sistemas operacionais, linguagens e bibliotecas já aprenderam várias vezes os problemas dessa abordagem, e há material sobre isso: https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf. Fibers eram vistas nos anos 90 como solução escalável para concorrência, mas hoje, com stackless coroutine e a evolução de SOs e hardware, já não são recomendadas. Se isso continuar assim, o Zig vai acabar batendo em limites de desempenho parecidos com os do Go e terá dificuldade para ser um verdadeiro competidor em performance. Espero que std.fs continue existindo para casos em que desempenho é importante

    • É um mal-entendido achar que estamos fazendo “all-in” em green threads (fibers). O artigo citado pelo OP menciona explicitamente a expectativa de uma implementação baseada em stackless coroutine, e há até uma proposta relacionada: https://github.com/ziglang/zig/issues/23446. Performance é importante, e, se fibers ficarem abaixo do esperado em desempenho, elas não serão adotadas universalmente. O que se discute neste artigo não impede que stackless coroutine se torne a implementação padrão de Io

    • Fico em dúvida sobre a afirmação de que green thread tem desempenho ruim. Plataformas de servidores concorrentes de ponta (Go, Erlang, Java) usam ou tentam usar green threads. Green threads podem não ser ideais em linguagens de nível mais baixo, como Rust, por causa de compatibilidade com C FFI, mas é difícil dizer que desempenho, por si só, seja sempre o problema

    • Como é só uma opção entre várias, não acho que dê para chamar isso de “all-in”. A implementação escolhida é decidida no executável, não no código da biblioteca

    • O que o Zig busca é um efeito parecido com a escolha do Rust de remover green threads e trocar por async runtime. A intuição central é formalizar que “async=IO, IO=async”. O Rust oferece runtimes async plugáveis, como tokio; o Zig oferece runtimes de IO plugáveis. No fim, a direção é tirar o runtime da linguagem e permitir encaixá-lo no espaço do usuário, enquanto todos compartilham uma interface comum

    • O material (P1364R0) era controverso, e eu acho que o argumento foi motivado por uma intenção de eliminar uma abordagem específica. Como material de discussão, também vale ver 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/ etc.

  • Em uma linguagem de sistemas como Zig, parece um pouco estranho forçar polimorfismo em tempo de execução até para operações comuns de IO da stdlib. Na maioria dos casos reais, a implementação de IO pode ser decidida estaticamente, então fico me perguntando por que impor esse overhead de runtime

    • Acho que o overhead de dynamic dispatch em IO, na prática, tende a ser quase insignificante. Depende do alvo de IO, claro, mas no fim das contas há muito mais casos em que o IO não é o gargalo de CPU. É por isso que existe o nome IO-bound

    • Sobre a pergunta “por que impor overhead de runtime a todo mundo?”, em sistemas que usam só um tipo de io, parece que a intenção é que o compilador otimize e elimine o custo da double indirection. E, de qualquer forma, IO normalmente já tem outro bottleneck, então adicionar mais uma indireção quase não pesa

    • Pela filosofia do Zig, parece haver uma preocupação maior com tamanho de binário. Allocator tem exatamente o mesmo trade-off: por exemplo, ArrayListUnmanaged não é generic em relação ao allocator, então cada alocação passa por dynamic dispatch. Na prática, custo de alocação em arquivo ou de escrita costuma superar de longe o overhead de uma chamada indireta. Essa obsessão com tamanho de binário é bem o estilo do Zig. E, aliás, devirtualization — a otimização que troca chamadas dinâmicas por estáticas — é um mito

    • Polimorfismo em tempo de execução não é inerentemente ruim. A menos que você esteja em um tight loop com branch extra ou impedindo o compilador de fazer inline, isso normalmente não vira um problema

  • Não gosto muito de ver o novo parâmetro io aparecendo por toda parte, mas gosto bastante do fato de poder usar facilmente várias implementações (baseadas em thread, em fiber etc.) sem obrigar o usuário a adotar uma implementação específica, como acontece com a interface Allocator. No geral, é uma grande melhora e, se entre as várias implementações da stdlib existir uma implementação de io síncrona/blocking sem overhead adicional, isso seguirá exatamente a filosofia do Zig de “não pagar pelo que você não usa”

    • Será que “não pagar pelo que você não usa” é mesmo possível? A não ser que a equipe seja minúscula e extremamente disciplinada, no fim outra pessoa vai usar, e eu também vou acabar pagando o custo. E passar io o tempo todo parece mais incômodo do que simplesmente chamar direto quando precisa
  • No Zig, io.async expressa assincronicidade — isto é, a ordem das tarefas pode não ser garantida, mas o resultado continua correto —, e não concorrência (concurrency). Em outras palavras, o ponto central é que ele separa o significado de async e das chamadas de io. Acho esse design muito inteligente

  • Gosto do fato de que a interface de IO permite criar uma vfs (Virtual File System) em nível de linguagem

    • Ao ver o código de exemplo, pensei se também não daria para aplicar segurança baseada em capabilities do ponto de vista de segurança. Por exemplo, passar para uma biblioteca uma instância de io que só possa ler dentro de um diretório específico. Referência: https://news.ycombinator.com/item?id=44549430
  • Para aprender Zig, fiz um servidor ssh simples. Graças a essa estrutura nova de IO/event loop, ficou bem mais fácil entender o fluxo do código. Obrigado, Andy

    • Fiquei curioso sobre qual parte do novo design fez você entender melhor o event loop/io
  • O texto está muito bem escrito e foi uma leitura muito interessante. Fiquei especialmente animado com as implicações para WebAssembly. A ideia de poder usar WASI em userspace e também Bring Your Own IO parece realmente fascinante