jonnifyredis

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

330ms
200ms
polling worker timeline (1000 ms) job —

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

200ms
high low latency 100 ms round-trips/s 5 /s shorten → less latency, more round-trips · lengthen → the reverse

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
}
the pattern

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.

in EchoMQ

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.

Door · not depth The full worker fetch loop — how the loop branches between an immediate fetch and a blocking wait, and how each runtime parks — is the dedicated EchoMQ course. From here, R3.05 opens onto E2 · the engine, the as-built worker fetch loop. This dive teaches the cost of polling; that course teaches the engine that avoids 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