Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
What will I learn?
- Why the Part 1 tester was quietly benchmarking the TCP handshake in stead of the server, and how keep-alive (connection reuse) fixes it;
- How reusing a connection forces us to parse response framing honestly (
Content-Lengthand chunked bodies) instead of leaning on the EOF crutch from Part 1; - How to publish live statistics every second from running workers without a lock strangling the hot path (episode 30 atomics, put to work);
- The single most important idea in load testing that almost every homemade tool gets wrong -- coordinated omission -- and why an honest tester must schedule requests against a clock, not a worker;
- How to build a rate-limited, closed-vs-open load model so you can say "hold 5,000 req/s" in stead of only "go as fast as you can";
- How this rate-scheduling shape compares to what you'd reach for in C, Rust, or Go.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- Part 1 of this mini project (episode 101) -- we extend that exact code;
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2 (this post)
Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
Last time we built a load tester that worked -- and I told you, right at the end, that it also lied in three specific ways. It opened a fresh TCP connection for every single request, so a fat slice of every "latency" we measured was really the kernel doing a three-way handshake. It reported nothing until the run was over, so a five-minute test kept you in the dark for five minutes. And it had exactly one speed: flat-out. Today we fix all three, and in the process we bump into the single most misunderstood idea in the whole discipline of load testing -- coordinated omission -- which is a fancy name for the way naive testers accidentally hide the exact latency spikes you ran the test to find. Having said that, roll up your sleeves, because Part 2 is where this tool grows teeth.
Same rules of engagement as last time: point this ONLY at a server you own or a throwaway local one. Everything below I ran against the little HTTP server we built in episodes 51 through 54, on 127.0.0.1:8080. We're extending the exact code from episode 101 -- the Sample struct, the oneRequest function, the worker pool, the percentile function -- so if that's hazy, keep Part 1 open in a tab.
Reusing the connection: keep-alive
The Part 1 oneRequest did a full tcpConnectToAddress on every call, sent Connection: close, and let the server hang up. Clean, correct, and slow -- against localhost the handshake is cheap, but against anything across a real network the TCP setup can dwarf the actual request, and you end up benchmarking round-trip-time, not the server. Real HTTP clients hold a connection open and pump many requests down it (that's what Connection: keep-alive and HTTP/1.1's default persistence buy you). So the first job is to let each worker connect once and then loop, reusing the same stream.
/// A single persistent connection a worker owns for its whole run. Connect once
/// (ep 21), then fire many requests down the same socket. `alive` lets us drop
/// back to a fresh connect if the server ever closes on us mid-run.
const Conn = struct {
stream: std.net.Stream,
alive: bool,
fn open(addr: std.net.Address) !Conn {
return .{ .stream = try std.net.tcpConnectToAddress(addr), .alive = true };
}
fn close(self: *Conn) void {
if (self.alive) self.stream.close();
self.alive = false;
}
};
The request bytes change in one small-but-crucial way: we swap Connection: close for Connection: keep-alive, which is a promise to the server that we intend to send more requests, so please don't hang up. That single header flip is the entire protocol-level difference -- everything else that follows is us dealing with the consequences of the server keeping the socket open.
/// Same as Part 1's builder, but we now ask the server to KEEP the connection.
/// The cost of that convenience is that we can no longer use "socket closed" as
/// our end-of-response signal -- the whole next section exists to pay that bill.
fn buildRequest(alloc: std.mem.Allocator, host: []const u8, path: []const u8) ![]u8 {
return std.fmt.allocPrint(alloc,
"GET {s} HTTP/1.1\r\n" ++
"Host: {s}\r\n" ++
"User-Agent: zig-loadtest/0.2\r\n" ++
"Connection: keep-alive\r\n\r\n",
.{ path, host },
);
}
The bill keep-alive hands you: framing the response
Here's the part everyone underestimates. In Part 1 we read until EOF -- stream.read returned 0, the server had closed, done. That was only legal because we'd said Connection: close. The moment we keep the socket open, EOF never comes between requests, and a reader that waits for it will hang forever after the first response. We now have to know exactly where one response ends so the next one can begin, and HTTP/1.1 gives us precisely two ways to know: a Content-Length header telling us the body's byte count, or Transfer-Encoding: chunked where the body arrives in length-prefixed pieces (we dissected both back in episode 84). An honest load tester has to handle both, because you don't get to pick which one the server under test uses.
Let me name the shape of a parsed response first, so every function downstream is honest about what it produces (episode 6 discipline, same as the Sample struct):
/// The result of framing ONE response off a shared, streaming buffer. `consumed`
/// is the total bytes this response occupied (headers + body) so the caller can
/// slide the buffer forward and start reading the NEXT response where this ended.
const Response = struct {
status: u16,
consumed: usize,
keep_alive: bool, // did the server agree to keep the socket open?
};
Now the framing itself. We find the end of the headers (\r\n\r\n), pull the status off the first line exactly like Part 1, then decide the body length from the headers. This is the fiddly, bug-prone code -- which, per the lesson we keep hammering, means it's exactly what we isolate as a pure function and unit-test without a socket in sight.
/// Parse ONE HTTP/1.1 response out of `buf`. Returns null if we don't yet have
/// a complete response (caller should read more bytes and retry). This is a pure
/// function of a byte slice -- no I/O -- so it's the piece we hammer with tests.
fn frameResponse(buf: []const u8) ?Response {
// 1. Headers end at the first blank line. No blank line yet -> need more bytes.
const head_end = std.mem.indexOf(u8, buf, "\r\n\r\n") orelse return null;
const headers = buf[0..head_end];
const body_start = head_end + 4;
// 2. Status code: the three digits after "HTTP/1.1 " (Part 1's parseStatus).
const status = parseStatus(headers);
// 3. Persistence: HTTP/1.1 keeps the connection alive UNLESS told otherwise.
const keep = !containsHeaderValue(headers, "connection", "close");
// 4. Body length: Content-Length is the common, easy case.
if (headerValue(headers, "content-length")) |cl| {
const len = std.fmt.parseInt(usize, std.mem.trim(u8, cl, " "), 10) catch return null;
if (buf.len < body_start + len) return null; // body not fully arrived yet
return .{ .status = status, .consumed = body_start + len, .keep_alive = keep };
}
// 5. Otherwise it may be chunked -- hand off to the chunk walker.
if (containsHeaderValue(headers, "transfer-encoding", "chunked")) {
const body_len = chunkedBodyLen(buf[body_start..]) orelse return null;
return .{ .status = status, .consumed = body_start + body_len, .keep_alive = keep };
}
// 6. No length and not chunked: server will close to signal end (like Part 1).
return .{ .status = status, .consumed = buf.len, .keep_alive = false };
}
The chunked walker is the trickiest morsel. A chunked body is a run of <hex-length>\r\n<that many bytes>\r\n pieces, terminated by a zero-length chunk 0\r\n\r\n. We walk it piece by piece and return the total byte length only once we've seen the terminating zero chunk -- returning null the instant we run out of buffer, which is the reader's cue to fetch more and try again:
/// Walk a chunked body and return its TOTAL length in bytes (including the size
/// lines and the terminating 0-chunk), or null if the body isn't complete yet.
/// Each chunk: "\r\n\r\n", ended by a "0\r\n\r\n".
fn chunkedBodyLen(body: []const u8) ?usize {
var pos: usize = 0;
while (true) {
const nl = std.mem.indexOfPos(u8, body, pos, "\r\n") orelse return null;
const size = std.fmt.parseInt(usize, body[pos..nl], 16) catch return null;
const data_start = nl + 2;
if (size == 0) return data_start + 2; // 0-chunk: +2 for the final CRLF
const next = data_start + size + 2; // skip data AND its trailing CRLF
if (next > body.len) return null; // chunk data not all here yet
pos = next;
}
}
Two small header helpers keep frameResponse readable -- a case-insensitive value lookup and a "does this header contain this token" check. Headers are case-insensitive by spec, and forgetting that is a bug that only shows up against one particular server, which is the worst kind:
/// Case-insensitive lookup of a header's value, e.g. headerValue(h, "content-length").
/// Returns the slice after the colon, still needing a trim. Null if absent.
fn headerValue(headers: []const u8, name: []const u8) ?[]const u8 {
var it = std.mem.splitSequence(u8, headers, "\r\n");
while (it.next()) |line| {
const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
if (std.ascii.eqlIgnoreCase(line[0..colon], name)) return line[colon + 1 ..];
}
return null;
}
/// True if header `name` exists AND its value contains `token` (case-insensitive).
/// Used for "Connection: close" and "Transfer-Encoding: chunked" detection.
fn containsHeaderValue(headers: []const u8, name: []const u8, token: []const u8) bool {
const val = headerValue(headers, name) orelse return false;
var buf: [64]u8 = undefined;
if (val.len >= buf.len) return false;
const lower = std.ascii.lowerString(buf[0..val.len], val);
return std.mem.indexOf(u8, lower, token) != null;
}
And now the tests -- pure input, pure output, deterministic, no network. This is where framing bugs go to die:
test "frameResponse handles Content-Length" {
const r = frameResponse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").?;
try std.testing.expectEqual(@as(u16, 200), r.status);
try std.testing.expectEqual(@as(usize, 44), r.consumed);
try std.testing.expect(r.keep_alive);
}
test "frameResponse returns null on a partial body" {
// Content-Length says 5 but only 2 bytes arrived -> not complete yet.
try std.testing.expect(frameResponse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhi") == null);
}
test "chunkedBodyLen sums chunks and the terminator" {
// "5\r\nhello\r\n0\r\n\r\n" -> 5 data + framing + 0-chunk = 14 bytes total.
try std.testing.expectEqual(@as(usize, 14), chunkedBodyLen("5\r\nhello\r\n0\r\n\r\n").?);
// A missing terminator means "not done yet".
try std.testing.expect(chunkedBodyLen("5\r\nhello\r\n") == null);
}
That partial-body test is the one that saves your afternoon. Under real load a read() returns whatever bytes happened to arrive, which is almost never a whole response on a boundary. A framer that assumes each read gives it a complete response works perfectly on localhost and then falls apart the instant there's a network in between -- the classic "works on my machine" load-testing bug.
Streaming stats: telling you the truth while it runs
Part 1 stayed silent until the join. That's fine for a five-second test and maddening for a five-minute one. What we want is a line printed every second -- requests so far, current throughput, a rough p99 -- so you watch the server buckle in real time in stead of performing an autopsy afterward. The trick is doing this without a lock on the hot path, because a mutex every request would be us benchmarking our own contention (episode 30's whole point).
The move: each worker keeps a single atomic counter of completed requests -- a u64 it bumps with a relaxed fetchAdd, which is essentially free. A separate reporter thread wakes once a second, reads all the counters, and prints a delta. We deliberately do NOT try to compute exact percentiles live (that'd need the sorted latency pile); the live line is a cheap pulse, and the precise stats still come at the end where sorting is safe and single-threaded.
/// Per-worker live counters. Only `done` is touched on the hot path, with the
/// cheapest possible ordering -- we don't publish other memory through it, we
/// just want a monotonically-rising number the reporter can sample (ep 30).
const Counters = struct {
done: std.atomic.Value(u64) = .{ .raw = 0 },
failed: std.atomic.Value(u64) = .{ .raw = 0 },
};
/// The reporter thread: every second, sum all workers' counters and print the
/// delta since last tick. It reads only atomics -- never touches a worker's
/// Sample list -- so it can't race the workers writing their own private piles.
fn reporter(all: []Counters, running: *std.atomic.Value(bool)) void {
const stderr = std.io.getStdErr().writer();
var last: u64 = 0;
while (running.load(.monotonic)) {
std.time.sleep(std.time.ns_per_s);
var total: u64 = 0;
var failed: u64 = 0;
for (all) |*c| {
total += c.done.load(.monotonic);
failed += c.failed.load(.monotonic);
}
stderr.print("[live] {d} reqs, {d}/s, {d} failed\n",
.{ total, total - last, failed }) catch {};
last = total;
}
}
Notice the live line goes to stderr, not stdout. The final machine-readable summary belongs on stdout; the human-facing progress pulse belongs on stderr, so you can pipe the real result somewhere and still watch the show in your terminal. That's a small Unix-hygiene detail (episode 24), but it's the difference between a tool that composes and one that dumps everything into one stream and makes you grep.
The big one: coordinated omission
Now the idea that separates a real load tester from a toy, and that our Part 1 tool got wrong in the most seductive way. In Part 1, each worker did: fire a request, wait for the response, fire the next. That's a closed-loop model -- the load is coupled to the server's own speed. Sounds fine, right? It is a catastrophe for latency measurement, and here's why. Suppose the server freezes for one full second. In a closed loop, your workers are all blocked on that frozen request -- they simply don't send the requests they would have sent during that second. So the freeze produces exactly ONE slow sample per worker, when in reality thousands of requests should have been affected. Your p99 comes back looking healthy because the tester politely stopped generating load precisely when the server was in trouble. This is coordinated omission: the load generator "coordinates" with the server's stalls and omits the very measurements that matter. Gil Tene named it, and once you see it you can't unsee it.
The fix is to schedule requests against a wall clock, not against the previous response. If you want 5,000 req/s, then request number n is supposed to be sent at time start + n/5000 seconds, full stop -- whether or not the previous one has come back. When the server stalls, the requests pile up behind their scheduled send-times, and each one's latency is measured from when it should have gone out, not from when we finally got around to it. That's an open-loop model, and it's the only way the p99 tells the truth. Let me show the honest latency calculation, because it's a two-line change that fixes the entire lie:
/// Open-model request timing. `scheduled_ns` is when this request was SUPPOSED
/// to be sent (start + n * interval). We measure latency from THAT instant, not
/// from when we actually sent it -- so a backed-up queue shows up as high
/// latency on every delayed request, which is the whole point (anti-CO).
fn timedOpen(conn: *Conn, req: []const u8, scratch: []u8, scheduled_ns: u64, clock: *std.time.Timer) Sample {
const now = clock.read();
// If we're already past the scheduled time, that lateness is REAL latency
// the user experienced -- bake it in instead of pretending it didn't happen.
const backlog = if (now > scheduled_ns) now - scheduled_ns else 0;
const service = oneRequestOn(conn, req, scratch); // the actual send+recv time
return .{
.latency_ns = service.latency_ns + backlog,
.status = service.status,
.ok = service.ok,
};
}
That backlog term is the entire cure. In the closed model it's structurally always zero -- you never send late because you never send until you're ready. In the open model, when the server stalls for a second, every request that was scheduled during that second carries a growing backlog, and your latency histogram fills with the truth in stead of a comfortable fiction. The oneRequestOn helper is just Part 1's oneRequest adapted to fire down an existing Conn and frame the response with our new frameResponse in stead of reading to EOF:
/// Part 1's oneRequest, upgraded: reuse an open connection and frame the reply
/// properly. On any I/O error we mark the conn dead so the worker reconnects,
/// and STILL return a Sample -- a failed request under load is data, not noise.
fn oneRequestOn(conn: *Conn, req: []const u8, scratch: []u8) Sample {
var timer = std.time.Timer.start() catch unreachable;
conn.stream.writeAll(req) catch {
conn.alive = false;
return .{ .latency_ns = timer.read(), .status = 0, .ok = false };
};
var total: usize = 0;
while (total < scratch.len) {
const n = conn.stream.read(scratch[total..]) catch { conn.alive = false; break; };
if (n == 0) { conn.alive = false; break; } // server hung up
total += n;
// Stop as soon as we have ONE complete response -- don't wait for EOF.
if (frameResponse(scratch[0..total])) |resp| {
conn.alive = conn.alive and resp.keep_alive;
return .{ .latency_ns = timer.read(), .status = resp.status, .ok = resp.status >= 200 and resp.status < 400 };
}
}
return .{ .latency_ns = timer.read(), .status = 0, .ok = false };
}
Wiring the rate-controlled worker
The rate-limited worker ties it together: it computes each request's scheduled send-time from a fixed interval, sleeps until that instant (if it's ahead of schedule), reuses its connection, and reconnects on the fly if the server ever drops it. The per-worker rate is the global target divided across the pool:
/// One open-model worker. It targets `interval_ns` between its OWN requests,
/// measures latency against the schedule (anti-coordinated-omission), reuses one
/// connection, and reconnects transparently if the server closes on it.
fn rateWorker(
addr: std.net.Address,
req: []const u8,
interval_ns: u64,
out: *std.ArrayList(Sample),
counters: *Counters,
running: *std.atomic.Value(bool),
scratch: []u8,
) void {
var clock = std.time.Timer.start() catch unreachable;
var conn = Conn.open(addr) catch return;
defer conn.close();
var n: u64 = 0;
while (running.load(.monotonic)) {
const scheduled = n * interval_ns; // when request n SHOULD go out
const now = clock.read();
if (now < scheduled) std.time.sleep(scheduled - now); // ahead? wait our turn
if (!conn.alive) conn = Conn.open(addr) catch { std.time.sleep(interval_ns); continue; };
const sample = timedOpen(&conn, req, scratch, scheduled, &clock);
out.append(sample) catch {};
_ = counters.done.fetchAdd(1, .monotonic);
if (!sample.ok) _ = counters.failed.fetchAdd(1, .monotonic);
n += 1;
}
}
The orchestration is Part 1's spawn, sleep, signal, join, merge with two additions: we hand each worker its slice of the target rate, and we spin up the reporter thread alongside the workers. I'll show just the changed heart of run, since the allocation-of-per-worker-lists scaffolding is verbatim from episode 101:
// Split the target rate across workers. 5000 req/s over 50 workers = 100/s
// each = a 10ms interval per worker. Integer-safe: guard the zero-rate case.
const per_worker_hz = if (target_rps > worker_count) target_rps / worker_count else 1;
const interval_ns = std.time.ns_per_s / per_worker_hz;
const counters = try alloc.alloc(Counters, worker_count);
defer alloc.free(counters);
for (counters) |*c| c.* = .{};
var running = std.atomic.Value(bool).init(true);
const rep = try std.Thread.spawn(.{}, reporter, .{ counters, &running });
for (lists, bufs, counters, 0..) |*l, *b, *c, i| {
l.* = std.ArrayList(Sample).init(alloc);
b.* = try alloc.alloc(u8, 64 * 1024);
threads[i] = try std.Thread.spawn(.{}, rateWorker,
.{ list_addr, request, interval_ns, l, c, &running, b.* });
}
std.time.sleep(duration_ms * std.time.ns_per_ms);
running.store(false, .monotonic);
for (threads) |t| t.join();
rep.join(); // stop the live pulse before we print the final verdict
Run it against a healthy local server and the live pulse ticks by, then the final verdict lands on stdout:
$ zig build run -- --rate 5000 --workers 50 --duration 10
[live] 4998 reqs, 4998/s, 0 failed
[live] 9997 reqs, 4999/s, 0 failed
[live] 14996 reqs, 4999/s, 0 failed
...
requests : 49981 (49981 ok, 0 failed)
achieved : 4998 req/s (target 5000)
p50 : 0.71 ms
p95 : 1.83 ms
p99 : 3.90 ms
Now the payoff. Point the SAME tool, open-model, at a server you deliberately hobble (add a sleep(500ms) to one route), and compare it to what the closed-loop Part 1 tool reported against the identical stall:
closed-loop (Part 1) : p99 512 ms <- one slow sample per worker, rest "fine"
open-model (Part 2) : p99 1730 ms <- every request queued behind the stall
The Part 1 number is a lie of omission. During that half-second freeze, a 5,000 req/s target means ~2,500 requests should have been sent and made to wait -- the open model counts all their backlog, the closed model simply never sent them and reported a rosy p99. If you've ever had a tool tell you "p99 is 50ms" and then watched real users rage about multi-second hangs, coordinated omission was very likely the culprit.
Performance and design considerations
Keep-alive is the big win here, and it's worth being precise about why. In Part 1, every request paid for a connect() -- a full round trip before a single byte of the actual request went out, plus a fresh entry churning through the kernel's socket table and leaving TIME_WAIT litter behind. Reusing the connection means we pay that once per worker and then amortise it across thousands of requests. Against localhost the effect is modest (the loopback handshake is nearly free); across a real network it's the difference between measuring the server and measuring the speed of light down a fiber. And there's a subtle honesty bonus: with one connection per worker, the descriptor count is bounded by the worker count, so we sidestep the ProcessFdQuotaExceeded wall (episode 71) that the connection-per-request model slams into at high concurrency.
The open-model scheduling costs almost nothing on the hot path -- a subtraction and a conditional sleep -- but it changes what the tool is. It's now a machine that offers a fixed load and reports what the server does under it, in stead of a machine that goes as fast as the server lets it and calls the result "performance". Those are genuinely different experiments, and the open one is the one that predicts production, where your users arrive on their schedule, not yours.
One honest limitation I'll flag rather than hide: our sleep-based pacing is only as precise as the OS scheduler, which on a stock kernel means millisecond-ish granularity. At very high target rates (hundreds of thousands per second) the per-request sleep stops being accurate and you'd batch requests per tick or move to a busy-wait -- a real refinement, but out of scope for a tool that's already grown enough teeth for one day. Nota bene: knowing where your tool stops being accurate is itself a feature; a benchmark that can't tell you its own error bars is one you shouldn't trust.
How this compares to C, Rust, and Go
In C, keep-alive and framing are exactly the same bytes -- you'd recv into a growing buffer and scan for \r\n\r\n and Content-Length by hand, precisely as we did. What Zig gave us for free is the ?Response optional forcing us to handle the "response not complete yet" case at the call site; in C that partial-read state is an easy if to forget, and forgetting it is the segfault-under-load bug that never reproduces in the debugger. The atomics for the live counters map straight onto C11 _Atomic -- same model, same relaxed ordering -- so that part ports almost mechanically.
In Rust, you'd likely lean on tokio and represent each connection as a task holding its TcpStream, using tokio::time::interval for the open-model pacing (which is genuinely nicer than hand-rolled sleep math). Crates like hyper would hand you HTTP/1.1 framing for free, chunked decoding included -- a real productivity win, at the cost of a dependency tree between your timer and the socket. The borrow checker guarantees your per-task Sample vectors don't race, which is the same property we got from giving each worker its own list, just enforced by the compiler in stead of by discipline. For a tool whose entire job is to measure the wire, some days I still want to see the exact read the clock wraps -- which is the whole argument for the from-scratch version.
In Go, this is nearly a freebie: net/http's Transport does keep-alive and connection pooling automatically, http.Client frames the response for you, and a time.Ticker drives the open-model schedule in a few lines. It's no accident that vegeta -- probably the best-known coordinated-omission-aware load tester -- is written in Go and pins its whole design on exactly the open-model insight we built by hand today. What our version buys is the same thing it always buys in this series: you can point at the precise byte where the clock starts, the precise place the backlog is added, and know with certainty why your p99 says what it says. For learning, seeing the machinery is the value; for shipping load tests tomorrow, honestly, reach for vegeta and now you'll understand what it's doing under the hood ;-)
Where this is heading
Step back and count how much of today was, again, assembled from parts we'd already sharpened. The persistent connection is episode 21's socket with its lifetime stretched; the response framing is episode 84's HTTP/1.1 rules turned into code; the live counters are episode 30's atomics doing their lightest possible job; the stderr-vs-stdout split is episode 24's output hygiene. The genuinely new idea -- coordinated omission -- isn't Zig at all, it's a way of thinking about measurement that will change how you read every latency number for the rest of your career. That's the best kind of mini project: you finish with a tool AND a permanently upgraded pair of eyes.
We've now spent a good long run building programs that DO things -- servers, proxies, scanners, testers -- and lived off Zig's standard-library containers (ArrayList, HashMap) as if they were free. They aren't free; somebody wrote them, and the choices inside them are the difference between a tool that flies and one that crawls. So the next stretch turns the lens inward, onto the data structures themselves -- how they're actually built, byte by byte, and why the shape of your data quietly decides the speed of everything on top of it. The load tester runs and tells the truth; next we go one layer deeper and build the machinery it stood on.
Bedankt en tot de volgende keer!
Leave Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2 to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn AI Series (#131) - Graph Neural Networks
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Ethical Hacking (#90) - Security as a Mindset - Thinking Like a Defender
- Learn AI Series (#130) - Causal Inference and ML
- Learn Zig Series (#111) - Bloom Filters
- Learn Ethical Hacking (#89) - The Attacker's Perspective - A Day in the Life
- Learn AI Series (#129) - AutoML and Neural Architecture Search
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Ethical Hacking (#88) - Building Your Security Career - From Episode 1 to Here
- Learn AI Series (#128) - Privacy-Preserving AI