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

Serving Many Clients

A server that handles one connection at a time is a server one slow client can stop. The traditional answers are a thread per connection, which costs a stack each and stops scaling in the thousands, and a single-threaded event loop, which scales and infects every function you write with callbacks.

Zig’s answer is neither, and this page runs it in your browser on a target with no threads at all.

const std = @import("std");

/// A connection, with the transport left out. The handler below reads bytes
/// and writes bytes and never learns whether they came from a socket, which
/// is what lets this page run in a browser with no sockets at all. Chapter
/// two is the same handler with a `std.Io.net.Server` in front of it.
const Connection = struct {
    request: []const u8,
    reply: [64]u8 = undefined,
    reply_len: usize = 0,

    fn written(self: *const Connection) []const u8 {
        return self.reply[0..self.reply_len];
    }
};

/// Deliberately not `!void`. A task spawned into a Group cannot return an
/// error to anyone, so a per-connection failure has to become part of that
/// connection's reply. One bad client must not take down the server.
fn handle(conn: *Connection, counter: *usize, mutex: *std.Io.Mutex, io: std.Io) void {
    var w: std.Io.Writer = .fixed(&conn.reply);

    var it = std.mem.tokenizeScalar(u8, conn.request, ' ');
    const verb = it.next() orelse "";

    if (std.mem.eql(u8, verb, "ECHO")) {
        w.print("+{s}\r\n", .{it.rest()}) catch {};
    } else if (std.mem.eql(u8, verb, "COUNT")) {
        // Shared state, so it needs the lock. `io.async` may or may not be
        // parallel depending on the implementation, and code that is only
        // correct under the single-threaded one is a race waiting for a
        // deployment.
        mutex.lock(io) catch return;
        defer mutex.unlock(io);
        counter.* += 1;
        w.print(":{d}\r\n", .{counter.*}) catch {};
    } else {
        w.print("-ERR unknown command '{s}'\r\n", .{verb}) catch {};
    }

    conn.reply_len = w.buffered().len;
}

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

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

    var conns = [_]Connection{
        .{ .request = "ECHO first" },
        .{ .request = "COUNT" },
        .{ .request = "ECHO second" },
        .{ .request = "COUNT" },
        .{ .request = "SUBSCRIBE news" },
        .{ .request = "COUNT" },
    };

    var counter: usize = 0;
    var mutex: std.Io.Mutex = .init;

    // A Group owns its tasks: awaiting it waits for every one, and nothing
    // can outlive this scope. That is the property a bare `Thread.spawn`
    // per connection does not give you, and it is why a server built this
    // way can shut down rather than leaking connections it forgot about.
    var group: std.Io.Group = .init;
    for (&conns) |*conn| {
        group.async(io, handle, .{ conn, &counter, &mutex, io });
    }
    try group.await(io);

    // Replies printed in connection order, not completion order. The tasks
    // finished in whatever order the implementation chose; if this printed
    // as they completed, the output would differ between a threaded Io and
    // the single-threaded one this page runs on.
    for (&conns, 1..) |*conn, n| {
        try out.print("conn {d}: {s: <16} -> ", .{ n, conn.request });
        for (conn.written()) |byte| switch (byte) {
            '\r' => try out.writeAll("\\r"),
            '\n' => try out.writeAll("\\n"),
            else => try out.writeByte(byte),
        };
        try out.writeByte('\n');
    }

    try out.print("\nCOUNT reached {d} across {d} connections\n", .{ counter, conns.len });
    try out.flush();
}

The handler does not know about sockets

const Connection = struct {
    request: []const u8,
    reply: [64]u8 = undefined,
    reply_len: usize = 0,
};

The transport is missing on purpose. handle reads bytes and writes bytes and never learns where they came from, which is why this page runs with no network underneath it. Put a std.Io.net.Server in front of the same function and it is the TCP round trip.

That is the whole argument for writing protocol code against std.Io.Reader and std.Io.Writer instead of against a socket: the tests get to be fast and the browser gets to run the example.

io.async, not Thread.spawn

var group: std.Io.Group = .init;
for (&conns) |*conn| {
    group.async(io, handle, .{ conn, &counter, &mutex, io });
}
try group.await(io);

The Io implementation decides what “async” means. std.Io.Threaded runs these on a thread pool; the single-threaded WASI one running this page runs them inline. The source does not change, which is why Threads is a page you cannot run here and this one is.

If you are arriving from an older Zig looking for async and await as keywords: they were removed. This is what replaced them, and it is a library, not syntax. Concurrency covers the shape in general.

A Group so nothing outlives the scope

Group owns its tasks. Awaiting it waits for all of them, and cancelling it cancels all of them, so a connection handler cannot outlive the server that started it. A bare Thread.spawn per connection gives you no such guarantee, and shutting down cleanly means tracking every thread you ever started. Cancellation covers what a handler sees when the group it belongs to is cancelled.

A handler cannot return an error

fn handle(conn: *Connection, counter: *usize, mutex: *std.Io.Mutex, io: std.Io) void {

Note the void. There is nobody to return an error to: the caller has moved on to the next connection. So a per-connection failure has to become part of that connection’s reply, which is the same conclusion the text protocol chapter reached from the other direction. One client sending garbage must not affect any other client.

Shared state still needs a lock

mutex.lock(io) catch return;
defer mutex.unlock(io);
counter.* += 1;

The single-threaded implementation this page runs on would be perfectly happy without the mutex. That is exactly the trap: code that is only correct under the implementation you tested with is a race waiting for a deployment, and the Io you get in production is the threaded one.

lock takes the io and returns an error, because it can block and therefore can be cancelled.

Why the replies print in order

The tasks completed in whatever order the implementation chose. The loop that prints them runs afterwards, over the connections in order, so the output is the same either way. Printing from inside the handlers would interleave differently on a threaded Io than on this one.

The COUNT values are the exception, and they are honest: they show the order the tasks actually reached the mutex. Run this natively and they may well come out shuffled.