Projects — Phase 5¶
Two shippable deliverables anchor this phase. The first is HARD GATE #1 of the roadmap and is non-negotiable — an epoll-based HTTP/1.1 echo server that sustains 100k rps on a 4-core laptop and passes a battery of correctness checks. The second is a lock-free SPSC queue with TSan-clean tests and a real benchmark against a mutex baseline. Together they take a full two months (M8-M9). Everything else in this file — the stretch projects and week-by-week plan — supports these two goals.
Ship the two deliverables and you have credibility with any systems-C hiring manager alive. Ship neither and you’ve studied concurrency but not built anything with it, which is the difference between reading about deadlifts and lifting.
Deliverable 1 — HARD GATE #1: The epoll HTTP/1.1 echo server¶
An HTTP/1.1 server that speaks enough of the protocol to hold Connection: keep-alive connections open, parses request line and headers, and echoes the request body back as the response body. All I/O non-blocking, edge-triggered epoll, no per-connection thread. Target: 100 000 requests/second sustained on a 4-core laptop under wrk -c 1000 -t 4 -d 60s. Absolute minimum for graduation: 50 000 rps. 100k is the stretch. Below 50k means you have a bottleneck and you’re not done.
Architecture you’re targeting: one acceptor thread (or SO_REUSEPORT with per-thread listens), N worker threads (N = physical cores), each with its own epoll fd, EPOLLET | EPOLLONESHOT on client fds so exactly one worker owns a given fd at a time, per-connection state machine, persistent per-connection read buffer that survives partial reads, write buffer that drains on EPOLLOUT. Exactly the shape from files 06-08.
Acceptance criteria — 12 checkboxes¶
Every one of these must be checked before this counts as done. Ship the list in your README so anyone reviewing can verify.
Builds clean with
-Wall -Wextra -Werror -O2 -pedantic. Zero warnings. If a warning is genuinely wrong, silence with a documented#pragmaand a comment explaining why.ASan clean under load.
-fsanitize=address+wrk -c 200 -t 4 -d 60sfinishes with zero AddressSanitizer reports.TSan clean under load.
-fsanitize=thread+ same wrk invocation, zero ThreadSanitizer reports. This is the hard one.Handles 10 000 concurrent keepalive connections.
wrk -c 10000 -t 8 -d 30s --timeout 10s. All connections must succeed; no client-side timeouts.Correct handling of partial reads. Fuzz with a client that sends the request 1 byte per
write, sleep 1 ms between bytes. Server must not drop the request or hang; must parse it correctly and reply.Correct handling of partial writes. Set
SO_SNDBUFto 4 KB on the client, POST a 1 MB body, verify the echo response is byte-perfect and the connection stays healthy afterward.24-hour soak test with no fd leak. Run the server against
wrkin awhile true; do wrk ...; doneloop for 24 hours.ls /proc/$PID/fd | wc -lat hour 0 and hour 24 must be within ±5 fds of each other (allow for the epoll fd, listen fd, and per-thread housekeeping). This is the single most valuable check on the list.Correct HTTP/1.1 keepalive lifecycle. Client sends 3 pipelined GETs with
Connection: keep-alive; server responds to all 3 on the same connection. Client sends one GET withConnection: close; server responds and closes.No zombie fds after SIGTERM.
kill -TERM $PID; the server must close all client fds, close the listen fd, and exit within 5 seconds. Post-exit:ss -tnp | grep :8080returns nothing.Benchmark methodology documented. In
BENCHMARK.md: laptop model, CPU, kernel version, kernel tuning applied (or explicitly “none”), exactwrkcommand, exact server command, 5 runs recorded, median reported. No hand-waving numbers.wrk graph published. A PNG (matplotlib or gnuplot) showing throughput vs concurrency (c=1,10,100,1000,10000) and tail latency (p50/p99/p99.9 from wrk2
--latency) at c=1000. Two graphs, one image or two.README + blog post shipped, submitted to r/programming and Show HN. Even if it gets zero upvotes, the act of writing it up forces you to defend every design decision. This is the study-story you’ll tell for the next two years.
The graduation demo¶
Sit down in front of a fresh terminal, no notes:
Compile clean:
make clean && make CFLAGS="-Wall -Wextra -Werror -O2".Start the server:
./httpecho 8080.Load test in another shell:
wrk -c 1000 -t 4 -d 30s http://127.0.0.1:8080/.Read the throughput number. If it’s below 50k rps, you’re not done.
Rebuild with TSan and rerun with
-c 200. TSan silent.kill -TERM;ls /proc/self/fdcount matches the pre-server count.
If you can do that whole sequence without notes, you graduated the hard gate.
Deliverable 2 — SPSC lock-free queue with benchmark¶
A single-producer, single-consumer lock-free ring buffer using <stdatomic.h>, benchmarked against a pthread_mutex_t-guarded bounded queue. Target: 5-10× throughput on 2 threads on your laptop. Below 5× means either the mutex baseline is too slow (you weren’t fair) or the atomic queue has too many memory barriers (over-engineered).
Acceptance criteria¶
Uses only
<stdatomic.h>. No inline assembly, no compiler intrinsics beyond what_Atomicandatomic_*provide.Memory-order rationale documented for every atomic op. Every
atomic_load_explicit/atomic_store_explicitin the code has a comment:// acquire — reads must not be hoisted above thisor similar. If you can’t defend the memory order on every op, downgrade tomemory_order_seq_cstand note that you did.TSan clean under a 1M-op benchmark. Two threads, producer pushes 1M elements, consumer pops 1M, checks a monotonic sequence number is preserved. Zero TSan reports.
Benchmark methodology written up in
BENCHMARK.md. CPU model, cache size, methodology (pinned threads to specific cores or not, warmup iterations, item size). Report throughput as items/sec, both queues.Ships as
spsc.h+spsc.cwith a header-only version optional. The mutex baseline asmpmc_mutex.h+mpmc_mutex.c.Chase-Lev deque is not required. SPSC (one producer, one consumer) is the exercise; MPSC or MPMC lock-free is a stretch (see below) but not the graduation target.
Reference numbers to sanity-check yourself¶
Rough ballpark on a modern laptop (M-series Mac or Ryzen 7 laptop, 2 threads):
Mutex-based bounded queue: ~5-15 M ops/sec (limited by cache-line ping-pong on the mutex).
SPSC atomic ring: ~50-150 M ops/sec (limited by cache-line traffic on head/tail; if head and tail are in the same cache line you’ll be closer to 30 M).
Ratio: 5-10×, consistent with the folklore in the Rigtorp SPSC / rigtorp.se writeup.
If your numbers are wildly off in either direction, something is wrong; investigate before shipping.
Stretch projects — 3-4 options if you want more¶
Only if the two deliverables are truly done. Ranked by ML-engineer relevance.
(a) io_uring variant of the HTTP server¶
Same architecture, swap epoll for io_uring via liburing. Compare throughput and p99 latency to the epoll baseline. Document what you’d have to change to deploy this behind a Docker container (spoiler: seccomp profile). This is the exact comparison every ML inference server team has done in-house. Time budget: 2-3 weekends.
(b) HTTP/2 support¶
Add HTTP/2 (multiplexed streams, HPACK header compression, flow control). Benchmark with h2load -c 100 -m 32 -n 1M http://localhost:8080/. Note: HTTP/2 is a real protocol — HPACK alone is a week if you write it from scratch. Consider integrating nghttp2 (link against it) rather than reimplementing. This is what envoy does. Time budget: 3-4 weekends.
(c) TLS via BearSSL¶
Wrap the accept path with TLS handshake via BearSSL (bearssl.org), a small (~30 kLOC) constant-time TLS library. Avoid OpenSSL for this exercise; the API is too big to learn quickly, and BearSSL forces you to understand the state machine. Benchmark with h2load against https://localhost:8443/. Time budget: 2-3 weekends.
(d) A small pub/sub server¶
Redis-alike: TCP + a tiny text protocol (SUBSCRIBE topic, PUBLISH topic msg), fanout writes to all subscribers of a topic. Reuse the epoll HTTP server as the network chassis; add a shared subscription table (RCU or copy-on-write for the read-heavy topic list). Benchmark: 1 publisher, N subscribers, measure message-fanout throughput. Time budget: 3-4 weekends.
Sequencing guide — 8 weeks, week by week¶
You have M8 (weeks 1-4) and M9 (weeks 5-8), 10-15 hours per week. Plan:
Week |
Files |
Practice |
Ship |
|---|---|---|---|
1 (M8) |
01, 02 |
pthread producer-consumer; atomic counter with memory-order variants |
pthreads-toy repo |
2 (M8) |
02, 03 |
SPSC ring buffer draft #1 (correctness only, no benchmark) |
SPSC draft, TSan-verified |
3 (M8) |
04 |
Thread pool from scratch; parallel wc; profile queue contention |
Thread-pool library |
4 (M8) |
05 |
80-line echo server; UDP echo; break the server in 5 ways, fix each |
Echo-server v0 |
5 (M9) |
06, 07 |
Convert echo to non-blocking; add epoll; ET + drain loop |
Epoll-echo v1 |
6 (M9) |
07 |
Add HTTP/1.1 parsing; add keepalive; benchmark to ~50k rps |
HTTP-echo v1 |
7 (M9) |
08 |
Redis/valkey walkthrough; multi-thread the HTTP server; push to 100k rps |
HTTP-echo v2 |
8 (M9) |
09 |
TSan/ASan pass; 24h soak; SPSC benchmark; README + blog |
HARD GATE demo + SPSC ship |
How to know you’re on track vs slipping¶
End of week 2: you have a TSan-clean SPSC ring buffer. If not, you’re behind — spend a weekend catching up.
End of week 4: you have a blocking echo server that survives IPv4 + IPv6,
Ctrl-C, and peer-kill without leaking fds. If not, you have a socket-basics gap; re-read file 05.End of week 6: you have an epoll-based HTTP echo hitting at least 20k rps. If not, either your parser is slow, your event loop is level-triggered without drain, or you’re blocking on something in the hot path.
End of week 7: you’re at 50k+ rps. If not, profile with
perf topand find the bottleneck — most likely a syscall in the hot loop or a lock on the accept path.End of week 8: all 12 acceptance boxes checked. If not, list which ones remain and slip week 8 into an intercalary week — better to graduate late than to skip the checks.
Slippage tolerance: two weeks over 8 weeks is acceptable. Four weeks over 8 weeks means you should re-examine whether Phase 4 (systems programming) was solid; sometimes the debt is upstream.
What most people get wrong about this¶
They chase the 100k rps number before they’ve cleared the correctness box. A server that hits 120k rps but fails the 24-hour soak test with an fd leak is not a passable submission — it’s a demo. The order that works: correctness first (TSan clean, ASan clean, no fd leaks), scale second (10k concurrent), throughput third (50k → 100k rps). Every commit must keep correctness intact; you never trade correctness for a throughput number. The 12-checkbox list is deliberately correctness-heavy for this reason.
Second mistake: not shipping the blog post. The blog post is the artifact that turns “I wrote a server” into “I understand servers.” Writing forces you to justify every choice — why ET over LT, why EPOLLONESHOT, why 4 workers not 8, why HTTP/1.1 keepalive done this way. If you can’t write it up, you don’t fully understand it. The blog post is the graduation ceremony.
References¶
The Rigtorp SPSC queue —
rigtorp.se/ringbuffer/— the canonical modern C++ SPSC design. The C translation is straightforward.1024cores.net (Dmitry Vyukov) — the classic archive of lock-free primitives. Old but foundational.
libevent, libev, libuv — three C event loop libraries at different points on the abstraction curve. Read one of their epoll backends for a second reference beyond redis’s
ae_epoll.c.nginx source —
nginx.org/en/download.html— worth a second walkthrough after you’ve done redis.src/event/ngx_epoll_module.cis the file.Beej’s Guide to Network Programming — always a warm re-read when a specific socket detail confuses you.
Gil Tene, “How NOT to measure latency” — Strange Loop 2013 talk. Watch before running any tail-latency benchmark.
Return to README.md · Next: ../07_applied_c_ml_inference/README.md