zig-httpd-runner-starter
A threaded HTTP server with threaded accepters and threaded runners using arena memory. No heap allocation after startup, lock-free atomic ring buffers between all layers, clean shutdown.
github.com/ghowland/zig-httpd-runner-starter
Current usage:
http://127.0.0.1:8080/run - Dispatch request to a Runner
http://127.0.0.1:8080/shutdown - Shutdown server immediately
The architecture is four layers:
- A listener thread accepts connections and pushes them into a ring buffer.
- A handler threads pop connections, parse HTTP, and route requests.
- The handlers submit to the runner threads via per-core ring buffers and spin-wait for results.
- Currently the runners just write the input into the output.
Each layer has one job and communicates only through atomic ring buffers, no mutexes, no locks on the hot path.
All memory is arena-allocated at startup. Request and response bodies use a 100KB fixed stack buffer (TextBig) that gets copied by value through the ring buffers. No pointer lifetime issues and no allocator needed for the data path. The only allocator use at runtime is a resettable scratch arena for formatting HTTP response headers.
Supported Zig versions
Zig 0.15.1.
AI / LLM usage disclosure
I used Claude Opus 4.6 to rewrite another one of my projects code to make this foundation for a threaded HTTP server with pinned core threaded runners. LLM also wrote the docs/tech_spec.md and README.md.
2 Likes
Nice. Why the choice of copying the 100KB buffer? To simplify synchronisation ?
The 100KB fixed buffer solves a few problems, under constraints:
- I know my incoming HTTP data will be less than 100KB always, even with the attachments I’m planning to use, so it’s a fixed size that meets all incoming data. I would just fail anything bigger than that on ingress in a real version.
- It’s a long running program, and dealing with memory across threads and time is a pain, using fixed sizes avoids this.
- We still have the issue of string formatting memory, so I have the resetable_memory, which could also just alloc the correctly sized string, but this has to be managed.
- The memory originally comes from the HTTP handler in an unpinned core thread, and then moves to a runner in a pinned core thread, not a big deal for 100KB, but the runner threads are isolated via ring buffer, and isolation has reasoning benefits.
I wouldn’t recommend this pattern generally, but it’s how I am doing it for my HTTP server since I know the workloads as I control both sides.
I started using fixed size string of 4kb and later 1.5kb in my game engine, because tracking strings across the game over N frames was insane, so just having them all on the stack made it a lot easier. In that system, I used 2 arenas as frame memory, and would blue-green flip and reset them each frame so memory can persist for 2 frames before being cleared, which protects on the border of frame flips, if there are formatting or other operations going on between them.
Strings are a major pain for memory management, I prefer fixed size when I can get away with it.