skip to content

Readiness notification can be level-triggered — you are told repeatedly while the condition holds — or edge-triggered, where you are told only when it changes. Explain the difference and the classic stall bug that edge-triggered code hits.

level: seniorimportance: should knowfreq 28%

answer

  1. state vs transition
  2. drain until would-block
  3. partial read = silent permanent stall
  4. level-triggered writability = spin unless deregistered
  5. one-shot for multi-threaded loops

basics

~20 s

Level-triggered reports "data is available" on every wait until you drain it; edge-triggered reports only the transition from empty to non-empty. The stall bug: read once, leave bytes in the buffer, and no further edge ever arrives, so the connection hangs forever with data waiting.

solid answer

~60 s

**Level-triggered** readiness answers "is the condition true right now?" If a socket has unread bytes, every wait reports it until you consume them. Partial reads are safe; you just get told again. **Edge-triggered** readiness answers "did the condition just become true?" You are notified when new data arrives; if you read only part of it, no further notification is generated, because nothing changed — the buffer was already non-empty. The connection then sits with data available and your loop never looks at it again. That is the classic stall, and it typically appears only under load or with large messages, so it survives testing. The rule for edge-triggered mode is therefore: **always drain to exhaustion**. Loop on read until you get the would-block indication, and only then return to the wait. The same applies to writes and to accepting connections — drain the accept queue in a loop, or you silently strand pending clients. Edge-triggered exists because it produces fewer wake-ups per unit of data and pairs well with per-connection state machines; level-triggered is easier to get right and is the sane default.

code

text · 7 lines
text
on_readable(sock):            # edge-triggered
    loop:
        n = read(sock, buf)
        if n == WOULD_BLOCK: break      # only safe exit
        if n == 0: close(sock); break   # peer closed
        consume(buf, n)
        if bytes_this_visit > CAP: requeue(sock); break   # fairness

go deeper

for a junior

Know the one-sentence distinction: level reports the condition while it lasts, edge reports only the change.

for a middle

Be able to walk the partial-read stall and state the drain-to-would-block rule for reads, writes and accepts.

for a senior

Add the level-triggered writability spin and its deregistration fix, fairness caps while draining, and the mandatory non-blocking handles.

for a principal

Argue the choice from measured wake-up overhead versus correctness risk, and cover one-shot registration as the concurrency-control mechanism for multi-threaded loops.

## Two semantics for the same question A readiness demultiplexer must decide what "ready" means over time. The two answers are borrowed from electronics. **Level-triggered**: readiness is a *state*. As long as the receive buffer is non-empty, every call to wait reports the handle as readable. As long as the send buffer has room, it reports writable. **Edge-triggered**: readiness is an *event*. You are told when the state transitions — empty to non-empty, full to has-room. If the state stays true and nothing new happens, no further notification is produced. ## The stall bug, step by step Imagine a handler that reads once per notification into a 1 KB buffer, and 3 KB arrives: ``` t0 3 KB arrives -> edge fires, handle reported readable t1 handler reads 1 KB, returns to the wait loop (2 KB still queued) t2 wait(): nothing reported — no transition occurred; buffer was already non-empty t3 ... connection is stalled forever, with 2 KB sitting in the kernel ``` Under level-triggered semantics t2 would report readable again and the leftover would be consumed. Under edge-triggered it does not. If the peer is waiting for a response before sending more, no new data will ever arrive, so no new edge will ever fire — a permanent deadlock between two live, healthy processes. The reason this survives testing is that small messages usually fit one read, so the bug appears only with large payloads, fragmentation, or load. It typically shows up as a small percentage of connections hanging until timeout. ## The discipline edge-triggered demands 1. **Drain reads to exhaustion.** Loop reading until the call returns the would-block indication. That indication is the only reliable signal that a future edge will be generated. 2. **Drain accepts too.** Multiple connections can arrive between two waits; a single accept per notification strands the rest until the next arrival. Loop until accept says would-block. 3. **Handle writes symmetrically.** Write until would-block, then remember you are write-blocked; the next writable edge fires when the send buffer drains, and you resume from your saved offset. In level-triggered mode you must instead *deregister* interest in writability when you have nothing to send, or the loop spins at 100% CPU being told endlessly that an idle socket is writable. That is the mirror-image bug, and it is the main reason people reach for edge-triggered in the first place. 4. **Beware starvation from draining.** A connection with an unbounded stream of data can monopolise the loop while you drain it. Production loops cap bytes or iterations per handler visit and re-queue the connection themselves rather than relying on the kernel to remind them. 5. **Non-blocking mode is mandatory.** Edge-triggered plus a blocking handle means the drain loop's final read parks the whole event loop. ## Why edge-triggered exists Fewer wake-ups per byte: with a level-triggered loop that does partial reads, you can be woken many times for one message. Edge-triggered gives one notification per arrival burst and pushes the loop toward a per-connection state machine that reads everything available and advances a parser — which is what a high-throughput server wants anyway. It also avoids the writable-storm problem without needing to add and remove write interest on every state change, which itself costs a system call each time. In practice the throughput difference is modest for most services, and the correctness risk is real. The pragmatic default is level-triggered for reads (simple, forgiving) with write interest registered only while there is pending output; move to edge-triggered when profiling shows notification overhead matters and you have the drain discipline enforced in one shared place, not scattered across handlers. ## One-shot as a third option Some interfaces offer a *one-shot* mode: notify once, then automatically disable the registration until the program re-arms it. This is the ally of multi-threaded loops, because it guarantees only one thread is handed a given handle at a time — otherwise two loop threads can both be told the same socket is readable and race inside the same connection state. Re-arming is an explicit system call, which is the cost. ## Saying it well "Level-triggered reports a condition, edge-triggered reports a transition. Edge-triggered is cheaper in wake-ups but requires draining every handle to would-block on reads, writes and accepts — otherwise leftover bytes never generate another edge and the connection stalls. Level-triggered is forgiving on reads but will spin on writability unless you deregister write interest when idle. Multi-threaded loops usually add one-shot to stop two threads handling the same connection."

  • With level-triggered readiness, why can registering interest in writability permanently peg a CPU core?
    An idle socket with room in its send buffer is writable essentially always, so every wait immediately reports it and the loop spins with zero useful work. The fix is to register write interest only while output is pending and remove it as soon as the outbound buffer empties. Edge-triggered mode avoids the storm because it only fires on the transition to writable, which is why write-heavy loops often prefer it.
  • Why do multi-threaded event loops usually pair edge-triggered mode with a one-shot registration?
    Without one-shot, two loop threads waiting on the same queue can both be handed the same handle and run the connection's handler concurrently, corrupting per-connection parser state. One-shot disables the registration on delivery, so exactly one thread owns the handle until it explicitly re-arms after finishing. The cost is a re-arm system call per event, traded against not needing a per-connection lock.

Level-triggered is a mailbox flag that stays up while any letter remains; edge-triggered is a doorbell that rings only when the postman arrives — if you take one letter and walk away, nothing will ring again.

saying these in an interview costs you the question

  • Reading once per edge-triggered notification and assuming another notification will come.
  • Thinking the stall is a kernel bug rather than the defined semantics.
  • Forgetting that accept queues also need draining in a loop under edge-triggered mode.
  • Claiming level-triggered is always safe — it spins on writability if write interest is left registered.
  • Using edge-triggered mode with handles left in blocking mode, so the drain loop's last read parks the loop.

context