
Learn Zig Series (#18) - Async Concepts and Event Loops
Learn Zig Series (#18) - Async Concepts and Event Loops

What will I learn
- You will learn why Zig removed async/await and what replaced it;
- the difference between blocking I/O, non-blocking I/O, and I/O multiplexing;
- how
poll()andepollwork at the OS level and how Zig exposes them; - building a simple event loop from scratch using
std.posix.poll; - multiplexing multiple TCP connections without threads;
- comparing Zig's approach to Python asyncio and Node.js event loops;
- when to use threads vs event loops vs blocking I/O;
- practical patterns for writing concurrent network services in Zig.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- @scipio/zig-programming-tutoroial-ep001-intro" target="_blank" rel="noopener noreferrer">Zig Programming Tutorial - ep001 - Intro
- @scipio/learn-zig-series-2-hello-zig-variables-and-types" target="_blank" rel="noopener noreferrer">Learn Zig Series (#2) - Hello Zig, Variables and Types
- @scipio/learn-zig-series-3-functions-and-control-flow" target="_blank" rel="noopener noreferrer">Learn Zig Series (#3) - Functions and Control Flow
- @scipio/learn-zig-series-4-error-handling-zigs-best-feature" target="_blank" rel="noopener noreferrer">Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- @scipio/learn-zig-series-5-arrays-slices-and-strings" target="_blank" rel="noopener noreferrer">Learn Zig Series (#5) - Arrays, Slices, and Strings
- @scipio/learn-zig-series-6-structs-enums-and-tagged-unions" target="_blank" rel="noopener noreferrer">Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- @scipio/learn-zig-series-7-memory-management-and-allocators" target="_blank" rel="noopener noreferrer">Learn Zig Series (#7) - Memory Management and Allocators
- @scipio/learn-zig-series-8-pointers-and-memory-layout" target="_blank" rel="noopener noreferrer">Learn Zig Series (#8) - Pointers and Memory Layout
- @scipio/learn-zig-series-9-comptime-zigs-superpower" target="_blank" rel="noopener noreferrer">Learn Zig Series (#9) - Comptime (Zig's Superpower)
- @scipio/learn-zig-series-10-project-structure-modules-and-file-io" target="_blank" rel="noopener noreferrer">Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- @scipio/learn-zig-series-11-mini-project-building-a-step-sequencer" target="_blank" rel="noopener noreferrer">Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- @scipio/learn-zig-series-12-testing-and-test-driven-development" target="_blank" rel="noopener noreferrer">Learn Zig Series (#12) - Testing and Test-Driven Development
- @scipio/learn-zig-series-13-interfaces-via-type-erasure" target="_blank" rel="noopener noreferrer">Learn Zig Series (#13) - Interfaces via Type Erasure
- @scipio/learn-zig-series-14-generics-with-comptime-parameters" target="_blank" rel="noopener noreferrer">Learn Zig Series (#14) - Generics with Comptime Parameters
- @scipio/learn-zig-series-15-the-build-system-buildzig" target="_blank" rel="noopener noreferrer">Learn Zig Series (#15) - The Build System (build.zig)
- @scipio/learn-zig-series-16-sentinel-terminated-types-and-c-strings" target="_blank" rel="noopener noreferrer">Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- @scipio/learn-zig-series-17-packed-structs-and-bit-manipulation" target="_blank" rel="noopener noreferrer">Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- @scipio/learn-zig-series-18-async-concepts-and-event-loops" target="_blank" rel="noopener noreferrer">Learn Zig Series (#18) - Async Concepts and Event Loops (this post)
Learn Zig Series (#18) - Async Concepts and Event Loops
Welcome back! In @scipio/learn-zig-series-17-packed-structs-and-bit-manipulation" target="_blank" rel="noopener noreferrer">episode #17 we went deep into packed structs and bit manipulation -- defining fields down to individual bits, @bitCast for reinterpreting bit patterns, endianness handling with std.mem.bigToNative, and a real DNS header parser. I teased at the end that we'd start looking at concurrency and event-driven programming. Well, here we are, and the story is more interesting than you might expect.
Because Zig had async/await. Past tense. It was in the language for years, with stackless coroutines, an event loop in the standard library, the whole deal. And then Andrew Kelley and the core team ripped it all out. Completely. Not deprecated, not hidden behind a flag -- removed. In Zig 0.11 the async/await keywords stopped compiling. The std.event.Loop module was deleted. If you search for old Zig tutorials from 2021-2022, you'll find async examples that don't compile anymore.
Why? And what do you use instead? That's what this episode is about ;-)
Here we go!
Solutions to Episode 17 Exercises
Before we get into event loops, here are the solutions to last episode's exercises on packed structs and bit manipulation. Complete code, copy-paste-and-run.
Exercise 1 -- IpFlags packed struct for IPv4 flags + fragment offset:
const std = @import("std");
const testing = std.testing;
const IpFlagsOffset = packed struct(u16) {
// After byte-swap from big-endian to native (little-endian):
// bits 0-12: fragment offset
// bit 13: more fragments (MF)
// bit 14: don't fragment (DF)
// bit 15: reserved (always 0)
fragment_offset: u13,
more_fragments: bool,
dont_fragment: bool,
reserved: u1 = 0,
};
fn parseIpFlagsOffset(raw_be: u16) IpFlagsOffset {
const native = std.mem.bigToNative(u16, raw_be);
return @bitCast(native);
}
test "DF=1 only" {
// Big-endian: 0x4000 = 0100 0000 0000 0000
// bit 14 (DF) = 1, rest = 0
const result = parseIpFlagsOffset(0x4000);
try testing.expect(result.dont_fragment);
try testing.expect(!result.more_fragments);
try testing.expectEqual(@as(u13, 0), result.fragment_offset);
}
test "fragment offset 185" {
// Fragment offset 185 = 0x00B9 in low 13 bits
// No flags set: 0x00B9 in big-endian
const result = parseIpFlagsOffset(0x00B9);
try testing.expect(!result.dont_fragment);
try testing.expect(!result.more_fragments);
try testing.expectEqual(@as(u13, 185), result.fragment_offset);
}
test "MF=1 with offset" {
// MF bit is bit 13 in big-endian = 0x2000
// offset 100 = 0x0064
// combined: 0x2064
const result = parseIpFlagsOffset(0x2064);
try testing.expect(!result.dont_fragment);
try testing.expect(result.more_fragments);
try testing.expectEqual(@as(u13, 100), result.fragment_offset);
}
The key: IPv4 sends flags + fragment offset as a big-endian u16. After byte-swapping to native order, the fragment offset occupies the low 13 bits and the flags sit above it. The packed struct maps directly to this layout.
Exercise 2 -- BitVector backed by a u64:
const std = @import("std");
const testing = std.testing;
const BitVector = struct {
bits: u64,
pub fn init() BitVector {
return .{ .bits = 0 };
}
pub fn set(self: *BitVector, index: u6) void {
self.bits |= @as(u64, 1) << index;
}
pub fn clear(self: *BitVector, index: u6) void {
self.bits &= ~(@as(u64, 1) << index);
}
pub fn isSet(self: BitVector, index: u6) bool {
return (self.bits & (@as(u64, 1) << index)) != 0;
}
pub fn count(self: BitVector) u7 {
return @popCount(self.bits);
}
pub fn iterator(self: BitVector) Iterator {
return .{ .remaining = self.bits };
}
const Iterator = struct {
remaining: u64,
pub fn next(self: *Iterator) ?u6 {
if (self.remaining == 0) return null;
const index: u6 = @ctz(self.remaining);
self.remaining &= self.remaining - 1; // clear lowest set bit
return index;
}
};
};
test "set, check, clear, count" {
var bv = BitVector.init();
bv.set(0);
bv.set(5);
bv.set(63);
try testing.expect(bv.isSet(0));
try testing.expect(bv.isSet(5));
try testing.expect(bv.isSet(63));
try testing.expect(!bv.isSet(1));
try testing.expectEqual(@as(u7, 3), bv.count());
bv.clear(5);
try testing.expect(!bv.isSet(5));
try testing.expectEqual(@as(u7, 2), bv.count());
}
test "iterator yields ascending indices" {
var bv = BitVector.init();
bv.set(63);
bv.set(0);
bv.set(5);
var iter = bv.iterator();
try testing.expectEqual(@as(u6, 0), iter.next().?);
try testing.expectEqual(@as(u6, 5), iter.next().?);
try testing.expectEqual(@as(u6, 63), iter.next().?);
try testing.expectEqual(@as(?u6, null), iter.next());
}
The iterator trick uses @ctz (count trailing zeros) to find the lowest set bit, then remaining &= remaining - 1 to clear it. This classic bit manipulation pattern yields set bits in ascending order without needing a loop over all 64 positions.
Exercise 3 -- I2C address byte with packed struct and @bitCast:
const std = @import("std");
const testing = std.testing;
const I2cAddress = packed struct(u8) {
rw: bool, // bit 0: read/write (0=write, 1=read)
device: u7, // bits 1-7: device address
};
fn buildAddress(device: u7, read: bool) u8 {
return (@as(u8, device) << 1) | @intFromBool(read);
}
fn parseAddress(raw: u8) struct { device: u7, rw: bool } {
return .{
.device = @truncate(raw >> 1),
.rw = (raw & 1) == 1,
};
}
test "build and parse write address for EEPROM" {
const raw = buildAddress(0x50, false);
// 0x50 << 1 | 0 = 0xA0
try testing.expectEqual(@as(u8, 0xA0), raw);
const parsed = parseAddress(raw);
try testing.expectEqual(@as(u7, 0x50), parsed.device);
try testing.expect(!parsed.rw);
}
test "build and parse read address for EEPROM" {
const raw = buildAddress(0x50, true);
// 0x50 << 1 | 1 = 0xA1
try testing.expectEqual(@as(u8, 0xA1), raw);
const parsed = parseAddress(raw);
try testing.expectEqual(@as(u7, 0x50), parsed.device);
try testing.expect(parsed.rw);
}
test "bitCast matches manual construction" {
const manual = buildAddress(0x50, true);
const via_struct = I2cAddress{ .device = 0x50, .rw = true };
const from_struct: u8 = @bitCast(via_struct);
try testing.expectEqual(manual, from_struct);
// And back
const back: I2cAddress = @bitCast(manual);
try testing.expectEqual(@as(u7, 0x50), back.device);
try testing.expect(back.rw);
}
The neat part: @bitCast on the packed struct produces the exact same byte as the manual shift-and-OR construction. That's the whole point of packed structs -- they're just a declarative way to describe bit layouts that the compiler turns into the same machine code you'd write by hand.
Right, on to event loops ;-)
Why Zig killed async/await
This is actually a fascinating story in language design, and I think it's worth understanding because it says something deep about Zig's philosophy.
Zig's async/await (from roughly 0.5 through 0.10) worked through stackless coroutines. When you called an async function, the compiler would transform it into a state machine, allocate a frame on the heap, and suspend/resume at await points. There was a std.event.Loop that used epoll (Linux) or kqueue (macOS) under the hood to manage I/O readiness. It worked. People built real things with it.
But the implementation had problems. Deep problems. The async transformation complicated the compiler massively -- it interacted badly with comptime, with error handling, with the debug info generator. Every new language feature had to be "async-aware" or it would break in subtle ways. Andrew Kelley estimated that async accounted for roughly a third of the compiler's complexity while serving maybe 5% of use cases.
The core team decided: a systems programming language shouldn't force a specific concurrency model on you. Instead, Zig should give you the building blocks -- threads via std.Thread, raw OS primitives via std.posix, and the type system to make them safe -- and let you build (or import) whatever concurrency model fits your problem.
This is the same philosophy that drives Zig's approach to memory management (no built-in GC, bring your own allocator) and error handling (explicit errors, no exceptions). The language gives you control. It doesn't make choices for you.
So what do you actually use instead? Three things:
std.Threadfor CPU-bound parallelism (we'll cover threading in a later episode)- OS I/O multiplexing (
poll,epoll,kqueue) for network/file I/O - Third-party event loop libraries like
zig-aioorlibxevfor production systems
This episode focuses on #2 -- understanding I/O multiplexing from first principles and building with it in Zig.
Blocking vs non-blocking I/O
To understand event loops, you first need to understand what "blocking" means at the OS level. When you call read() on a socket, two things can happen:
Blocking (the default): the thread stops and waits until data arrives. Could be microseconds, could be minutes. During that wait, the thread does absolutely nothing. In a single-threaded program, the entire application freezes:
const std = @import("std");
const net = std.net;
pub fn main() !void {
// Open a listening socket
const address = net.Address.initIp4(.{ 127, 0, 0, 1 }, 8080);
var server = try std.posix.socket(
std.posix.AF.INET,
std.posix.SOCK.STREAM,
0,
);
defer std.posix.close(server);
try std.posix.bind(server, &address.any, address.getOsSockLen());
try std.posix.listen(server, 128);
std.debug.print("Listening on :8080\n", .{});
// This BLOCKS until a client connects
const client_fd = try std.posix.accept(server, null, null, 0);
defer std.posix.close(client_fd);
var buf: [1024]u8 = undefined;
// This BLOCKS until the client sends data
const n = try std.posix.read(client_fd, &buf);
std.debug.print("Received: {s}\n", .{buf[0..n]});
}
That code handles exactly ONE client. While it's blocked on accept(), it can't do anything else. While it's blocked on read(), same thing. If two clients connect at the same time, the second one waits until the first is completely done.
Non-blocking: you set the NONBLOCK flag on the file descriptor. Now read() returns immediately -- either with data (if some was available) or with error.WouldBlock (if nothing was ready). Your code stays in control:
const std = @import("std");
const posix = std.posix;
pub fn main() !void {
const address = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 8080);
var server = try posix.socket(
posix.AF.INET,
posix.SOCK.STREAM | posix.SOCK.NONBLOCK, // <-- non-blocking
0,
);
defer posix.close(server);
try posix.bind(server, &address.any, address.getOsSockLen());
try posix.listen(server, 128);
std.debug.print("Listening on :8080 (non-blocking)\n", .{});
while (true) {
const result = posix.accept(server, null, null, posix.SOCK.NONBLOCK);
if (result) |client_fd| {
std.debug.print("Got connection: fd={d}\n", .{client_fd});
posix.close(client_fd);
} else |err| {
if (err == error.WouldBlock) {
// No connection waiting right now -- do other stuff
std.time.sleep(10 * std.time.ns_per_ms);
continue;
}
return err;
}
}
}
Non-blocking is better -- you're not frozen. But now you're polling in a tight loop, burning CPU. You keep asking "any data yet? any data yet? any data yet?" a thousand times a second. That's wasteful. What you really want is for the OS to tell you when something is ready, so you can sleep efficiently in between.
That's what poll() does.
poll() -- the universal I/O multiplexer
poll() is a POSIX system call that takes a list of file descriptors and says "wake me up when ANY of these are ready for reading, writing, or have an error." It's supported on Linux, macOS, BSDs, and basically everything except Windows (which has its own thing with IOCP).
Here's how it works in Zig:
const std = @import("std");
const posix = std.posix;
pub fn main() !void {
const address = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 8080);
var server = try posix.socket(
posix.AF.INET,
posix.SOCK.STREAM | posix.SOCK.NONBLOCK,
0,
);
defer posix.close(server);
// Allow address reuse so we can restart quickly
try posix.setsockopt(server, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
try posix.bind(server, &address.any, address.getOsSockLen());
try posix.listen(server, 128);
std.debug.print("Listening on :8080 with poll()\n", .{});
// Our poll set: just the server socket for now
var fds: [64]posix.pollfd = undefined;
var nfds: usize = 1;
fds[0] = .{
.fd = server,
.events = posix.POLL.IN, // interested in: "ready to accept"
.revents = 0,
};
while (true) {
// Block until at least one fd is ready (timeout = -1 means wait forever)
const ready = try posix.poll(fds[0..nfds], -1);
if (ready == 0) continue; // timeout (shouldn't happen with -1)
// Check each fd
var i: usize = 0;
while (i < nfds) {
if (fds[i].revents == 0) {
i += 1;
continue;
}
if (fds[i].fd == server) {
// Server socket: new connection waiting
if (fds[i].revents & posix.POLL.IN != 0) {
const client = posix.accept(
server,
null,
null,
posix.SOCK.NONBLOCK,
) catch |err| {
if (err == error.WouldBlock) {
i += 1;
continue;
}
return err;
};
std.debug.print("New connection: fd={d}\n", .{client});
// Add to poll set
if (nfds < fds.len) {
fds[nfds] = .{
.fd = client,
.events = posix.POLL.IN,
.revents = 0,
};
nfds += 1;
} else {
std.debug.print("Too many connections, rejecting\n", .{});
posix.close(client);
}
}
} else {
// Client socket: data available
if (fds[i].revents & posix.POLL.IN != 0) {
var buf: [1024]u8 = undefined;
const n = posix.read(fds[i].fd, &buf) catch 0;
if (n == 0) {
// Connection closed
std.debug.print("Connection closed: fd={d}\n", .{fds[i].fd});
posix.close(fds[i].fd);
// Remove from poll set by swapping with last
fds[i] = fds[nfds - 1];
nfds -= 1;
continue; // don't increment i
}
std.debug.print("fd={d}: {s}", .{ fds[i].fd, buf[0..n] });
// Echo it back
_ = posix.write(fds[i].fd, buf[0..n]) catch {};
}
if (fds[i].revents & posix.POLL.HUP != 0) {
std.debug.print("Hang up: fd={d}\n", .{fds[i].fd});
posix.close(fds[i].fd);
fds[i] = fds[nfds - 1];
nfds -= 1;
continue;
}
}
i += 1;
}
}
}
This is a fully functional multi-client echo server in about 80 lines. No threads. No async/await. No framework. Just one thread, one poll() call, and a loop.
The key insight: poll() is the event loop. The while (true) with poll() at the top IS the pattern that Node.js, Python's asyncio, and every other event-driven framework is built on top of. They just hide it behind nicer APIs. Here you're seeing the raw mechanism.
The pollfd struct has three fields:
fd: the file descriptor to monitorevents: what you're interested in (POLL.INfor read-ready,POLL.OUTfor write-ready)revents: what actually happened (filled in by the kernel afterpoll()returns)
When you call poll(), the thread sleeps efficiently (the kernel puts it to sleep, no CPU burned) until at least one file descriptor has an event. Then you iterate through the array checking revents to see which ones are ready.
Building a structured event loop
That raw poll loop works, but it's messy. Let's wrap it in a proper struct that manages connections and provides a cleaner interface. This is the foundation of what eventually becomes something like libxev or libuv:
const std = @import("std");
const posix = std.posix;
const net = std.net;
const EventLoop = struct {
const max_fds = 256;
server_fd: posix.socket_t,
fds: [max_fds]posix.pollfd,
nfds: usize,
running: bool,
pub fn init(port: u16) !EventLoop {
const address = net.Address.initIp4(.{ 127, 0, 0, 1 }, port);
const fd = try posix.socket(
posix.AF.INET,
posix.SOCK.STREAM | posix.SOCK.NONBLOCK,
0,
);
errdefer posix.close(fd);
try posix.setsockopt(
fd,
posix.SOL.SOCKET,
posix.SO.REUSEADDR,
&std.mem.toBytes(@as(c_int, 1)),
);
try posix.bind(fd, &address.any, address.getOsSockLen());
try posix.listen(fd, 128);
var self = EventLoop{
.server_fd = fd,
.fds = undefined,
.nfds = 1,
.running = true,
};
self.fds[0] = .{
.fd = fd,
.events = posix.POLL.IN,
.revents = 0,
};
return self;
}
pub fn deinit(self: *EventLoop) void {
var i: usize = 0;
while (i < self.nfds) : (i += 1) {
posix.close(self.fds[i].fd);
}
}
pub fn run(self: *EventLoop) !void {
std.debug.print("Event loop running (max {d} connections)\n", .{max_fds - 1});
while (self.running) {
const ready = try posix.poll(self.fds[0..self.nfds], 1000);
if (ready == 0) continue; // timeout, check self.running
var i: usize = 0;
while (i < self.nfds) {
if (self.fds[i].revents == 0) {
i += 1;
continue;
}
if (self.fds[i].fd == self.server_fd) {
try self.handleAccept();
} else {
if (!self.handleClient(i)) continue; // fd was removed, don't increment
}
i += 1;
}
}
}
fn handleAccept(self: *EventLoop) !void {
const client = posix.accept(
self.server_fd,
null,
null,
posix.SOCK.NONBLOCK,
) catch |err| {
if (err == error.WouldBlock) return;
return err;
};
if (self.nfds >= max_fds) {
std.debug.print("Max connections reached, dropping\n", .{});
posix.close(client);
return;
}
self.fds[self.nfds] = .{
.fd = client,
.events = posix.POLL.IN,
.revents = 0,
};
self.nfds += 1;
std.debug.print("[+] Connection (total: {d})\n", .{self.nfds - 1});
}
fn handleClient(self: *EventLoop, index: usize) bool {
var buf: [4096]u8 = undefined;
if (self.fds[index].revents & (posix.POLL.HUP | posix.POLL.ERR) != 0) {
self.removeClient(index);
return false;
}
if (self.fds[index].revents & posix.POLL.IN != 0) {
const n = posix.read(self.fds[index].fd, &buf) catch {
self.removeClient(index);
return false;
};
if (n == 0) {
self.removeClient(index);
return false;
}
// Echo back
_ = posix.write(self.fds[index].fd, buf[0..n]) catch {};
}
return true;
}
fn removeClient(self: *EventLoop, index: usize) void {
std.debug.print("[-] Disconnect (remaining: {d})\n", .{self.nfds - 2});
posix.close(self.fds[index].fd);
self.fds[index] = self.fds[self.nfds - 1];
self.nfds -= 1;
}
};
pub fn main() !void {
var loop = try EventLoop.init(8080);
defer loop.deinit();
try loop.run();
}
This is structuraly identical to what's happening inside Node.js. Node's libuv library is a C event loop that uses epoll on Linux, kqueue on macOS, and IOCP on Windows. When you write server.on('connection', callback) in JavaScript, that callback gets stored in a list and invoked when poll()/epoll_wait() tells libuv the socket is ready. The JavaScript callback abstraction is nicer, sure. But underneath it's this exact same pattern.
The difference is that in Zig you OWN the loop. You can customize the allocation strategy (remember @scipio/learn-zig-series-7-memory-management-and-allocators" target="_blank" rel="noopener noreferrer">ep007? bring your own allocator). You can add custom file descriptors for timers, signals, or IPC. You can profile exactly which syscalls are happening. There's no magical runtime making decisions for you.
epoll -- when poll() isn't enough
poll() has a scaling problem. Every time you call it, you pass the entire array of file descriptors to the kernel. The kernel scans through all of them to find which ones are ready. With 10 connections, that's fine. With 10,000, it's slow -- O(n) per call, and you're making that call thousands of times per second.
Linux solved this with epoll. Instead of passing the whole list every time, you register file descriptors once with an epoll instance, and the kernel maintains an internal data structure that tracks readiness efficently. When you call epoll_wait(), it returns only the file descriptors that are actually ready -- O(1) in practice.
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
pub fn main() !void {
const address = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 8080);
const server = try posix.socket(
posix.AF.INET,
posix.SOCK.STREAM | posix.SOCK.NONBLOCK,
0,
);
defer posix.close(server);
try posix.setsockopt(
server,
posix.SOL.SOCKET,
posix.SO.REUSEADDR,
&std.mem.toBytes(@as(c_int, 1)),
);
try posix.bind(server, &address.any, address.getOsSockLen());
try posix.listen(server, 128);
// Create epoll instance
const epfd = try posix.epoll_create1(0);
defer posix.close(epfd);
// Register server socket with epoll
var server_event = linux.epoll_event{
.events = linux.EPOLL.IN,
.data = .{ .fd = server },
};
try posix.epoll_ctl(epfd, linux.EPOLL.CTL_ADD, server, &server_event);
std.debug.print("Listening on :8080 with epoll\n", .{});
var events: [64]linux.epoll_event = undefined;
while (true) {
// Wait for events -- returns only READY fds
const n_ready = posix.epoll_wait(epfd, &events, -1);
for (events[0..n_ready]) |ev| {
if (ev.data.fd == server) {
// Accept new connection
const client = posix.accept(
server,
null,
null,
posix.SOCK.NONBLOCK,
) catch |err| {
if (err == error.WouldBlock) continue;
return err;
};
// Register with epoll
var client_event = linux.epoll_event{
.events = linux.EPOLL.IN,
.data = .{ .fd = client },
};
try posix.epoll_ctl(
epfd,
linux.EPOLL.CTL_ADD,
client,
&client_event,
);
std.debug.print("[+] New connection fd={d}\n", .{client});
} else {
// Client data
var buf: [4096]u8 = undefined;
const n = posix.read(ev.data.fd, &buf) catch 0;
if (n == 0) {
std.debug.print("[-] Closed fd={d}\n", .{ev.data.fd});
posix.close(ev.data.fd);
// epoll auto-removes closed fds
continue;
}
_ = posix.write(ev.data.fd, buf[0..n]) catch {};
}
}
}
}
Notice the API difference: with poll() you manage the array yourself and scan all entries after every call. With epoll you register once with epoll_ctl(CTL_ADD, ...) and the kernel gives you back only the ready descriptors. For high-connection-count servers (web servers, game servers, chat systems), this scales dramatically better.
The tradeoff: epoll is Linux-only. macOS has kqueue (similar concept, different API). Windows has IOCP (completely different model -- completion-based rather than readiness-based). For cross-platform code, you either use poll() (works everywhere but scales worse) or you write a platform abstraction layer. This is exactly what libraries like libxev do -- they pick the best multiplexer for each platform.
How this compares to Python and Node.js
If you come from Python or JavaScript (and if you've been following my @scipio/learn-python-series-intro" target="_blank" rel="noopener noreferrer">Python series you probably know Python), here's how the models compare:
Python asyncio: uses epoll/kqueue under the hood, but wraps everything in coroutines with async/await. When you await socket.recv(), Python's event loop suspends your coroutine, registers the socket for readiness, and resumes you when data arrives. The implementation is in C (in _selector and _asyncio modules). You never see the raw epoll calls.
Node.js: uses libuv (a C library) which wraps epoll/kqueue/IOCP. Everything is callback-based: socket.on('data', callback). The event loop is hidden entirely. You register callbacks and Node calls them when I/O is ready. async/await in JavaScript is just syntactic sugar over Promises, which are sugar over callbacks.
Zig (now): gives you the raw OS primitives. std.posix.poll(), std.posix.epoll_ctl(), std.posix.socket(). No hidden runtime, no coroutine transformation, no callback registration system. You build the loop yourself, or you use a library like libxev that builds it for you.
The philosophical difference is real. Python and Node say: "concurrency is hard, let us handle the low-level stuff." Zig says: "concurrency is hard, and the low-level stuff is where the bugs hide, so you should understand and control it." Neither approach is wrong -- they serve different needs. If you're building a web app, asyncio or Node are excellent. If you're building the web server that asyncio runs on, or a database engine, or a game server that needs microsecond-level control over I/O scheduling -- you want what Zig gives you.
Having said that, you don't HAVE to use raw poll() in Zig. The ecosystem has proper event loop libraries:
libxevby Loris Cro (Zig core team member): production-grade, cross-platform event loop withepoll/kqueue/IOCP backendszig-aio: io_uring-based async I/O (Linux-only, extremely high performance)
These give you nice APIs while still being transparent about what's happening underneath. No magic.
When to use what
This is the practical question. Three tools, three use cases:
Blocking I/O (the default):
- Simple command-line tools that do one thing at a time
- Scripts that read a file, process it, write output
- Client programs that connect to one server
- Anything where "wait for this one thing" is all you need
// Simple: just read the whole file
const content = try std.fs.cwd().readFileAlloc(allocator, "data.txt", 1024 * 1024);
Threads (std.Thread):
- CPU-bound work: parsing, compression, encryption, number crunching
- When you need true parallelism (multiple CPU cores doing diferent work)
- Background tasks that don't need to coordinate with I/O
- Worker pools for request processing
// Spawn a thread for heavy computation
const handle = try std.Thread.spawn(.{}, computeHash, .{data});
// ... do other stuff ...
handle.join();
Event loops / I/O multiplexing (poll, epoll, kqueue):
- Servers handling many simultaneous connections
- Programs waiting on multiple I/O sources (network + stdin + timers)
- When you need thousands of concurrent connections on one thread
- Real-time systems where thread creation overhead matters
// One thread handling many sockets
const ready = try posix.poll(fds[0..nfds], timeout);
The common mistake is reaching for threads when you need concurrency but not parallelism. If your program is waiting on 100 network connections, you don't need 100 threads -- you need one thread with poll(). Threads add overhead: each one consumes stack memory (typically 2-8 MB), context switching isn't free, and shared state needs synchronization. For I/O-bound workloads, a single-threaded event loop often outperforms a thread-per-connection model.
The other common mistake is building a custom event loop when a library would do. If you're writing a production network service in Zig, use libxev or a similar library. Building your own event loop is educational (that's why we did it in this episode!) but production event loops need to handle edge cases: partial writes, EINTR, TCP keepalives, connection timeouts, backpressure, graceful shutdown. A mature library handles all of that.
A practical pattern: handling timeouts
One thing event loops are great at is timeouts. With blocking I/O, implementing a timeout requires either threads or signals (both messy). With poll(), it's built right in -- the last parameter is a timeout in milliseconds:
const std = @import("std");
const posix = std.posix;
fn waitForDataWithTimeout(fd: posix.socket_t, timeout_ms: i32) !enum { data, timeout, closed } {
var fds = [1]posix.pollfd{.{
.fd = fd,
.events = posix.POLL.IN,
.revents = 0,
}};
const ready = try posix.poll(&fds, timeout_ms);
if (ready == 0) return .timeout;
if (fds[0].revents & posix.POLL.HUP != 0) return .closed;
if (fds[0].revents & posix.POLL.IN != 0) return .data;
return .closed;
}
pub fn main() !void {
const address = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 8080);
var server = try posix.socket(
posix.AF.INET,
posix.SOCK.STREAM | posix.SOCK.NONBLOCK,
0,
);
defer posix.close(server);
try posix.setsockopt(
server,
posix.SOL.SOCKET,
posix.SO.REUSEADDR,
&std.mem.toBytes(@as(c_int, 1)),
);
try posix.bind(server, &address.any, address.getOsSockLen());
try posix.listen(server, 128);
std.debug.print("Waiting for connection (5s timeout)...\n", .{});
switch (try waitForDataWithTimeout(server, 5000)) {
.data => {
const client = try posix.accept(server, null, null, 0);
defer posix.close(client);
std.debug.print("Got connection!\n", .{});
std.debug.print("Waiting for data (3s timeout)...\n", .{});
switch (try waitForDataWithTimeout(client, 3000)) {
.data => {
var buf: [1024]u8 = undefined;
const n = try posix.read(client, &buf);
std.debug.print("Received: {s}\n", .{buf[0..n]});
},
.timeout => std.debug.print("Client timed out, no data\n", .{}),
.closed => std.debug.print("Client disconnected\n", .{}),
}
},
.timeout => std.debug.print("No connections in 5 seconds, exiting\n", .{}),
.closed => {},
}
}
The waitForDataWithTimeout function is clean and composable. You can use it anywhere you need to wait for I/O with a deadline. The return type is an enum that tells you exactly what happened -- data arrived, the timeout expired, or the connection was closed. Pattern matching with switch handles all cases. No exceptions, no error codes to look up, no surprises.
This pattern extends naturally to more complex scenarios: connection establishment timeouts, keepalive ping/pong, request processing deadlines. The event loop gives you precise control over timing that you simply don't have with blocking I/O.
Exercises
Modify the
EventLoopstruct from the "structured event loop" section to track how many bytes each client has sent (add abytes_receivedfield per connection). When a client disconnects, print the total bytes received from that client. You'll need to store per-connection state -- consider using a separate array indexed the same way as thefdsarray, or using a struct that contains both thepollfdand the metadata. Test by connecting withnc localhost 8080(ortelnet), sending a few messages, then disconnecting.Write a "multi-source monitor" program that uses
poll()to simultaneously watch stdin (fd 0) and a TCP server socket. When data arrives on stdin, print it prefixed with[stdin]. When a TCP client connects, accept it and print[net] new connection. When a connected client sends data, print[net] data: .... When either stdin reaches EOF or the user types "quit", shut down the event loop cleanly (close all sockets, print a summary of connections handled). This combines file descriptor types in a single poll set.Extend the epoll echo server with a per-connection inactivity timeout. If a client hasn't sent any data in 10 seconds, disconnect them. You'll need to track the last-activity timestamp per connection (use
std.time.milliTimestamp()) and on eachepoll_waitcall, sweep through connections to find stale ones. Use a timeout onepoll_waititself (e.g. 1000ms) so you get control back periodically even when no I/O events happen. This is how production servers implement connection timeouts.
Before you close this tab...
- Zig had async/await but removed it (0.11+). The feature added massive compiler complexity for limited use cases. Instead, Zig provides raw OS primitives and lets you build or import the concurrency model you need.
- Blocking I/O stops your thread until data arrives. Simple but can't handle multiple connections without multiple threads. Non-blocking I/O returns immediately with
error.WouldBlockif nothing is ready. Better, but busy-polling wastes CPU. poll()solves busy-polling: you give the kernel a list of file descriptors, it sleeps your thread efficiently, and wakes you when any of them are ready. This is the foundation of all event loop implementations.epoll(Linux) improves onpoll()for high connection counts. Instead of passing the whole fd list every call, you register fds once and get back only the ready ones. O(1) vs O(n). macOS useskqueue(similar idea, different API).- Building an event loop in Zig is remarkably transparent:
std.posix.poll(),std.posix.epoll_ctl(), andstd.posix.socket()are thin wrappers over the syscalls. No hidden runtime, no magic. You see every syscall happening. - Use blocking I/O for simple tools and single-connection clients. Use threads for CPU-bound parallelism. Use event loops for I/O-bound concurrency (many connections, multiple I/O sources, timeouts).
- For production Zig network services, use a library like
libxev(by Loris Cro, cross-platform) orzig-aio(io_uring-based) rather than rolling your own event loop. They handle the platform differences and edge cases you don't want to debug at 3 AM.
Next episode we'll look at SIMD -- using Zig's @Vector type to process multiple data elements in a single instruction. If you thought packed structs gave you control over memory layout, wait until you're running the same operation on 16 bytes simulateously ;-)