⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eOn an older Zig?

Queues

Tasks that only start and finish need a Future. Tasks that hand work to each other while they run need a queue. std.Io.Queue is the bounded channel: fixed capacity, both ends can block, and the sender can close it.

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var buf: [256]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
    const out = &file_writer.interface;

    // The queue owns no memory. Capacity is the buffer you hand it.
    var storage: [4]u32 = undefined;
    var queue: std.Io.Queue(u32) = .init(&storage);

    // `put` blocks when the queue is full, unless you ask for a minimum of
    // zero: then it takes what fits and tells you how much that was.
    const accepted = try queue.put(io, &.{ 1, 2, 3, 4, 5, 6 }, 0);
    try out.print("accepted {d} of 6\n", .{accepted});

    // Closing says there will be no more. Anything already buffered is still
    // delivered before the receiver is told.
    queue.close(io);

    var total: u32 = 0;
    while (queue.getOne(io)) |value| {
        total += value;
    } else |err| {
        try out.print("drained {d}, then {s}\n", .{ total, @errorName(err) });
    }

    // A closed queue refuses new elements even though the buffer is empty now.
    queue.putOne(io, 7) catch |err| {
        try out.print("put after close: {s}\n", .{@errorName(err)});
    };

    try out.flush();
}

Capacity is a buffer you supply

var storage: [4]u32 = undefined;
var queue: std.Io.Queue(u32) = .init(&storage);

The queue allocates nothing. It is a ring over the array you give it, so its capacity, its lifetime and where it lives are all yours to decide, the same deal Io.Writer offers. A queue on the stack of the function that spawns both tasks is the common case.

put and get take a minimum

const accepted = try queue.put(io, &.{ 1, 2, 3, 4, 5, 6 }, 0);

The last argument is how many elements the call must move before it may return. putAll and putOne are the everything-or-block spellings. A minimum of zero is the one that never blocks: it moves what fits and returns the count, which is 4 above because that is the capacity. get mirrors it exactly.

That parameter is what makes batching cheap. A consumer that asks for a minimum of one and offers a buffer of 64 gets everything currently queued in a single call, and blocks only when there is nothing at all.

Closing is how the receiver learns to stop

queue.close(io);

A closed queue rejects new elements immediately, but delivers everything already buffered before it reports error.Closed. So the drain loop reads until the error and knows it has lost nothing:

while (queue.getOne(io)) |value| {
    total += value;
} else |err| { ... }

The consumer never has to be told how many elements are coming, which is the detail that lets a producer decide as it goes.

Back pressure between two tasks

Built and run natively by CI. The producer has to make progress while the consumer waits, which is io.concurrent, and single-threaded wasm has none to give.
const std = @import("std");

/// Fills the queue and closes it. Capacity is two, so this blocks partway
/// through and resumes as the consumer drains: back pressure, for free.
fn produce(io: std.Io, queue: *std.Io.Queue(u32)) void {
    for (1..6) |i| {
        queue.putOne(io, @intCast(i)) catch return;
    }
    queue.close(io);
}

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var buf: [256]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
    const out = &file_writer.interface;

    var storage: [2]u32 = undefined;
    var queue: std.Io.Queue(u32) = .init(&storage);

    var producer = try io.concurrent(produce, .{ io, &queue });
    defer producer.await(io);

    // The consumer never asks how many are coming. It reads until the sender
    // closes, which is the whole reason `error.Closed` exists.
    while (queue.getOne(io)) |value| {
        try out.print("got {d}\n", .{value});
    } else |err| {
        try out.print("sender is done: {s}\n", .{@errorName(err)});
    }

    try out.flush();
}

With a capacity of two and five elements to send, the producer blocks partway through and resumes as the consumer drains. Nothing coordinates that: a full queue blocking the sender is the back pressure, and the pair self-regulates at the speed of the slower side.

This is the case that needs io.concurrent rather than io.async. A producer started with async may run inline, all the way to the point where it fills the queue and blocks, with the consumer that would drain it still waiting for async to return. That deadlock is the reason the two spellings exist, and it is why this snippet is native and the one at the top of the page is not.

Cancellation

Every blocking call here is a cancellation point: put, get, and their One and All forms return Cancelable errors, and the Uncancelable variants opt out for cleanup paths. See Cancellation; the rule that error.Canceled should propagate applies to a blocked producer as much as anywhere else.