R3.05.1 · dive 1
The busy-poll cost
A worker that waits by polling pays a wasted round-trip on every empty queue and picks a job up to one interval late.
The loop is plain: pop the work list; if the result is nil, sleep a fixed interval; repeat. The interval is the only dial, and it cannot be set well — a short one cuts latency but multiplies the empty round-trips, a long one cuts the round-trips but lengthens pickup latency. Set the arrival rate and the interval on the right and read the empty polls, the average pickup latency, and the round-trips per second.
The poll-cost calculator · one job arrival over a 1000 ms window
The polling loop
The naive wait is a spin. The worker pops the work list; an empty queue returns nil; the worker sleeps, then polls again.
# the polling loop — RPOP, sleep, retry loop: job = RPOP wait # nil when the queue is empty if job == nil: sleep(interval) # burn the interval, then poll again continue process(job) # a job arrived — run it
Every pass that returns nil is a wasted round-trip: a command sent to Redis and a nil sent back, for no work. On a queue that is empty most of the time, that is the steady state — the worker spends its life asking an empty queue whether it is still empty. A short interval makes that question cheap to ask but asks it constantly; the round-trips pile up while nothing happens.
Every empty poll is a wasted round-trip. On an idle queue the polling worker burns round-trips at the poll rate, doing no work for each one.
The latency a poll adds
A job rarely arrives exactly when the worker polls. It arrives somewhere in the gap between two polls, and waits the rest of the gap before the next poll picks it up. Over many arrivals, the wait averages about half the interval.
# a job arrives between two polls and waits for the next one poll poll poll poll | | ↑ | | |<-- interval -->| | | job arrives here, waits ~½ interval for the next poll # average pickup latency over uniform arrivals avg_latency ≈ interval / 2
A 100 ms interval adds about 50 ms of average pickup latency on top of the work itself. The interval is a floor on responsiveness that no amount of worker speed removes: the job is already in Redis, but the worker will not look until its next scheduled poll.
A polled job waits, on average, about half the interval before pickup — a responsiveness floor the interval sets, independent of how fast the worker runs.
No interval wins
The two costs pull against each other on the one dial. Shorten the interval to cut latency and the empty round-trips multiply; lengthen it to cut the round-trips and pickup latency grows. There is no value that is cheap on both axes, because polling pays for checking whether work exists separately from doing the work. Sweep the interval below and watch the latency bar fall as the round-trips bar climbs — no setting puts both low.
The interval trade · average latency vs round-trips per second on an idle queue
Latency is about half the interval; round-trips per second are one over it. The two move in opposite directions on the one dial, so no interval is cheap on both — the fix is to stop polling.
In EchoMQ — the contrast the rest of the module removes
EchoMQ's worker does not run this loop on an idle queue. When no job is immediately available, it parks on a
blocking fetch — EchoMQ.Worker.do_wait_for_job/3 runs
BZPOPMIN emq:{queue}:marker timeout on a dedicated blocking connection — so it
spends no round-trips while idle and picks a job up the instant the producer rings the marker. The polling loop
above is what that blocking fetch replaces.
It is also what the Go port's empty-queue worker loop does:
apps/echomq-go/pkg/echomq/worker_impl.go runs
time.Sleep(100 * time.Millisecond) / time.Sleep(time.Second)
when the queue is empty — a fixed-interval poll, the cross-runtime contrast to the marker block. The next
two dives build the blocking primitive and the marker handshake that remove the cost.
// worker_impl.go — the Go worker loop polls with a fixed sleep (the contrast, NOT the marker block) if err == redis.Nil { time.Sleep(100 * time.Millisecond) // empty queue → wait a fixed interval, then poll again continue }
Wait-by-polling pays a wasted round-trip on every empty poll and adds about half an interval of pickup latency. The interval dials one cost against the other and never makes both cheap.
The worker blocks on BZPOPMIN emq:{queue}:marker via
do_wait_for_job/3 instead, spending no idle round-trips. The Go port's
time.Sleep empty-queue loop is the polling form this module replaces.
The cost is paid twice and dialled once; EchoMQ's worker blocks on the marker rather than polling, so the next two dives build the primitive and the handshake that remove it.
References
The non-blocking pop the loop spins on, the blocking move that removes the interval, and the queue protocol that parks on the marker instead.
Sources
- Redis — RPOP — the non-blocking pop the polling loop spins on, returning nil on an empty queue.
- Redis — BLMOVE — the blocking move that removes the poll interval, built in the next dive.
- Redis — Patterns — the Redis patterns index, home of the reliable-queue and blocking-consumer guidance.
- BullMQ — the queue protocol — the marker-based worker wait EchoMQ ports in place of the poll.
Related in this course
- R3.05 · Blocking vs polling — the module hub.
- R3.05.2 · Blocking pop — the primitive that removes the poll interval and the wasted round-trips.
- R3.05.3 · The marker wake-up — the EchoMQ marker handshake that wakes the worker.
- R3.01 · The processing list — the wait/active lists the worker pops from.
- R3 · Reliable Queues — the chapter.