skip to content

selectors and Readiness Loops

One thread can watch thousands of connections by asking the kernel which are ready, through epoll or kqueue behind one portable API. Interviewers use it to check you know what an event loop is.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why does selectors.EVENT_WRITE fire on nearly every loop pass, and when should you register for it?

level: middleimportance: must knowfreq 45%

answer

  1. Reads are events, writes are a state
  2. Idle write interest never blocks
  3. A core pinned while the system idles
  4. Register writes only with queued bytes
  5. modify() back when the buffer drains

basics

~20 s

A socket is writable whenever its kernel send buffer has room, which is almost always. Register selectors.EVENT_WRITE only while unsent bytes are actually queued for that socket; otherwise select() returns instantly every pass and the loop spins at full CPU.

solid answer

~40 s

`selectors.EVENT_READ` and `selectors.EVENT_WRITE` are not symmetric. Read readiness is rare and event-driven - it means bytes arrived. Write readiness only means the socket's send buffer is not full, which is the normal state, so a socket registered for `EVENT_WRITE` is reported ready on essentially every `select()` call. Leave idle connections registered for it and `select()` never blocks: the loop burns a core doing nothing. The correct pattern is level-triggered interest management. Register `EVENT_READ` only. When a handler produces a response, call `send()` once; `send()` may write fewer bytes than you gave it, so keep the remainder in the connection's output buffer and `modify()` the registration to `EVENT_READ | EVENT_WRITE`. On the next write-ready event, flush more, and when the buffer empties, `modify()` back to `EVENT_READ` alone.

code

python · 17 lines
python
import selectors
import socket

a, b = socket.socketpair()
sel = selectors.DefaultSelector()
sel.register(a, selectors.EVENT_READ | selectors.EVENT_WRITE)

for key, mask in sel.select(timeout=0):
    print("readable:", bool(mask & selectors.EVENT_READ),
          "writable:", bool(mask & selectors.EVENT_WRITE))

sel.modify(a, selectors.EVENT_READ)
print("selects now:", sel.select(timeout=0))

sel.close()
a.close()
b.close()

go deeper

for a junior

Know that a selector registration carries a mask of read and write interest, and that write readiness is nearly always true. Recall that the fix for a spinning loop is to stop asking about writes when nothing is queued.

for a middle

Explain the full cycle: opportunistic send, short-write remainder into an output buffer, modify() to add write interest, flush, modify() back when empty. Be able to describe the busy-loop symptom precisely.

for a senior

Show that you use the absence of write readiness as backpressure - capping queues, pausing the producer, or shedding the connection - and that you can spot an idle service pinning a core in a CPU profile.

for a principal

Own the flow-control policy across the service: what a bounded output queue means for latency and for correctness, when dropping a slow consumer beats buffering for it, and how that choice is made consistently rather than per handler.

### The asymmetry Both `selectors.EVENT_READ` and `selectors.EVENT_WRITE` answer the same question - "will this call block?" - but the underlying conditions have wildly different duty cycles. A connected socket is **readable** when its receive buffer holds bytes, or when the peer has closed (so `recv()` will return `b""` immediately), or when there is a pending error. On a mostly-idle connection that is a rare event, driven by the peer. A connected socket is **writable** when its send buffer has room for at least some data. For a healthy connection that has nothing outstanding, the send buffer is empty, so the socket is writable *all the time*. Write readiness is therefore not an event in any useful sense - it is a steady state, interrupted only when a peer stops reading and TCP flow control fills the buffer. ### The busy loop That asymmetry is the source of the classic beginner bug in hand-written event loops: ```python sel.register(conn, selectors.EVENT_READ | selectors.EVENT_WRITE, data=state) while True: for key, mask in sel.select(timeout=None): # returns instantly, forever ... ``` With even one idle connection registered for writes, `select(timeout=None)` - the call that is supposed to be the loop's parking spot - returns immediately every single pass with that socket marked writable and nothing to do. The process pins a core at 100%, throughput of real work collapses because the loop spends its time re-entering the kernel, and the symptom is confusing precisely because nothing is wrong with the network: it happens hardest when the system is *idle*. ### Interest management is the fix The rule is: **hold write interest only while you have bytes queued.** A connection's registration moves between two states. ```python def queue(key, payload): key.data.out += payload sel.modify(key.fileobj, selectors.EVENT_READ | selectors.EVENT_WRITE, key.data) def on_writable(key): sent = key.fileobj.send(key.data.out) del key.data.out[:sent] if not key.data.out: sel.modify(key.fileobj, selectors.EVENT_READ, key.data) ``` Two details make this correct. First, `send()` on a non-blocking socket is allowed to accept fewer bytes than you offered and returns the count actually taken - that short write is the whole reason an output buffer and write interest exist at all. Second, the transition back to `EVENT_READ` alone must happen the moment the buffer drains, or you are back in the busy loop. A reasonable optimisation is to attempt an opportunistic `send()` as soon as you have a response and only register write interest if something is left over. Most responses fit in the send buffer, so most connections never hold write interest at all. ### The other meanings of write-ready Write readiness carries two further signals worth knowing. On a **non-blocking connect**, `connect()` returns immediately with `BlockingIOError` and the handshake proceeds in the background. Completion - success or failure - is reported as write readiness. The idiom is to register the connecting socket for `EVENT_WRITE`, and when it fires, check `getsockopt(SOL_SOCKET, SO_ERROR)`: zero means connected, anything else is the connection error, which is why "writable" can also be how you learn a connection was refused. On an established connection, the *disappearance* of write readiness is the backpressure signal. If write interest is held and the socket stops being reported writable, the peer is not draining - and if your code keeps appending to the output buffer regardless, that buffer is the thing that grows without bound. Write readiness is the point at which a loop can notice a slow consumer and push back, by pausing reads on the source, capping the queue, or dropping the connection. ### Level-triggered, not edge-triggered The `selectors` module presents a **level-triggered** interface on every backend: a condition that persists is reported on every call until it stops being true. That is what makes "registered for writes and idle" spin, and it is also what makes partial handling safe - if you read only some of what is available, the next `select()` reports the socket readable again. The lower-level `select.epoll` object can be put in edge-triggered mode, where a condition is reported only on transitions and you must drain until `BlockingIOError` or lose the wake-up; `selectors` deliberately does not expose that, and the level-triggered contract is what the interest-management pattern above relies on.

  • Your loop calls send() and it returns a smaller number than the length of the payload. What must the code do?
    Keep the unsent tail. `send()` on a non-blocking socket transfers as much as the kernel send buffer will take and returns that count; the rest is your responsibility. Slice off the accepted bytes, leave the remainder in the connection's output buffer, and add `selectors.EVENT_WRITE` to that socket's registration so the loop is told when there is room for more. Treating the return value as "all of it" silently truncates responses under load - exactly when it is hardest to reproduce.
  • How is write readiness used to detect a slow consumer?
    A connection that holds write interest but is no longer reported writable has a full send buffer, which means the peer has stopped draining. That is the backpressure signal. The loop should stop growing that connection's output buffer: pause reading from whatever feeds it, enforce a cap on queued bytes, or close the connection. Without such a rule the queue for one stalled peer grows until the process runs out of memory.
  • Why does a selector report the same readable socket again if you only read part of what was available?
    Because the interface is level-triggered: it reports conditions that are currently true, not transitions. As long as bytes remain in the receive buffer, the socket keeps being reported readable on every `select()`. That makes partial handling safe and is why a fairness cap - read at most N bytes per connection per pass - does not lose data. Edge-triggered interfaces, which `selectors` does not expose, require draining to `BlockingIOError` or the wake-up is lost.

Read interest is a doorbell - it rings when someone arrives. Write interest is a light that says the outbox is not full, and it is on all day; wire your loop to it and you will be woken every second to look at an empty outbox.

saying these in an interview costs you the question

  • Registers every connection for read and write permanently
  • Says write readiness means the peer received the data
  • Assumes send() always accepts the whole payload
  • Blames the busy loop on a select() timeout bug
  • Never removes write interest after the buffer drains

context

open as a page

What does selectors.DefaultSelector let one thread do that a blocking socket.recv() cannot?

level: juniorimportance: should knowfreq 35%

basics

~20 s

It asks the kernel which of many registered sockets are ready right now, so one thread can serve thousands of connections. A blocking socket.recv() parks that thread on one connection until that single connection has data.

open as a page

How does asyncio's selector-based event loop use the selectors module underneath?

level: middleimportance: should knowfreq 40%

basics

~20 s

It owns a selectors.DefaultSelector, registers each managed socket for EVENT_READ or EVENT_WRITE, and calls select() with a timeout set by its nearest scheduled timer. Ready events become queued callbacks that the same single thread then runs.

open as a page

Your selectors-based webhook receiver leaks memory across a 340-case replay pack. How do you find the cause?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Measure the selector's registration count first. If it never returns to its idle value, connections are being abandoned without unregister() and close(). If it stays flat, the growth is in the per-connection buffers you attached at registration.

open as a page