All work

Steal or wait?

C HTTP server on epoll. Mutex round-robin against three lock-free work-stealing pools, 48 benchmark runs.

Year
2025-26
Role
Author
Stack
C, pthreads, epoll, Chase-Lev deque
Links
Source, Paper (LaTeX)
epoll acceptdeque 0deque 1deque 2deque 3stealsendfile() to client

One thread accepts connections and pushes them onto per-worker deques. A worker with an empty deque steals from another worker.

The question

A thread pool needs a rule for which thread gets the next connection. The simple rule is round robin into mutex-protected queues. The usual alternative is work stealing: each worker owns a lock-free deque, and idle workers take work from busy ones. I wanted to measure whether stealing is worth its complexity in a small file server, and which steal policy works best.

The server

I wrote an HTTP/1.1 static file server in C from scratch. It uses epoll in edge-triggered mode for I/O and sendfile() for zero-copy transfers. The scheduler is pluggable, and four schedulers share the rest of the code:

scheduler · 4 workers

Each worker pops new work from the bottom of its own deque. A worker with nothing to do picks a random victim and steals the oldest connection from the top.

A toy simulation of the balancing idea, with the same arrivals in both modes. It does not model the benchmark. In the real runs, round robin also lost to its single accept thread and queue mutexes.

Random steal

Each worker pops new work from the bottom of its own deque. A worker with nothing to do picks a random victim and steals the oldest connection from the top.

Round robin

Each worker only reads its own mutex queue. When a big request lands in one queue, other workers sit idle while connections wait.

The deque owner pushes and pops at the bottom without locks. A thief takes from the top with one compare-and-swap on a versioned index, which prevents the ABA problem.

Measuring it

The server records latency in a log2-bucketed histogram, which gives p50, p95, p99, and p99.9 without storing every sample. Each deque also counts failed compare-and-swap operations, so the cost of contention is visible.

A Python script ran 48 benchmarks with wrk: 4 schedulers, at 1, 2, 4, and 8 threads, with 3 runs each of 15 seconds. A second script made the throughput, speedup, and scaling plots.

Results at 8 threads

Requests per second higher is better

  • Round robin2,998Round robin: 2,998 req/s, 1.00x round robin, p99 349 µs
  • Random steal10,528Random steal: 10,528 req/s, 3.51x round robin, p99 175 µs
  • Longest-queue steal7,993Longest-queue steal: 7,993 req/s, 2.67x round robin, p99 175 µs
  • Adaptive steal5,609Adaptive steal: 5,609 req/s, 1.87x round robin, p99 524 µs

p99 latency lower is better

  • Round robin349 µsRound robin: 2,998 req/s, 1.00x round robin, p99 349 µs
  • Random steal175 µsRandom steal: 10,528 req/s, 3.51x round robin, p99 175 µs
  • Longest-queue steal175 µsLongest-queue steal: 7,993 req/s, 2.67x round robin, p99 175 µs
  • Adaptive steal524 µsAdaptive steal: 5,609 req/s, 1.87x round robin, p99 524 µs
8 threads, from the wrk benchmark runs. Hover or focus a bar for all three numbers.
SchedulerRequests/svs RRp99 latency
Round robin2,9981.00x349 µs
Random steal10,5283.51x175 µs
Longest-queue steal7,9932.67x175 µs
Adaptive steal5,6091.87x524 µs

One limit: the load generator ran on the same 4-core machine as the server. That lowers absolute throughput at high thread counts, so the numbers above are best read as a comparison between schedulers, not as the server's maximum.

What I would do next

Accept connections on every worker with SO_REUSEPORT to remove the serial accept loop, and run the load generator on a separate machine. I wrote the full study as an IEEE-format paper, which is in the repository.