A server can ask the kernel which sockets are ready or which asynchronous operations have completed. That information alone does not decide what application code runs next.
The server still needs to:
An event loop provides this control structure. It waits for events, dispatches them to handlers, and repeats.
Two classic architectures define what those events mean:
Both can support thousands of concurrent connections with a small number of threads. Their main difference is who performs the I/O operation and when.
An event loop is not a specific system call. It is an application or runtime structure built around an event source such as a readiness multiplexer, completion queue, timer facility, or internal task queue.
A simplified loop looks like this:
The precise ordering varies among runtimes. Some process timers before I/O, others after it, and many use several phases. The stable idea is that the loop repeatedly converts external state changes into short units of application work.
While the loop waits in the kernel, its thread consumes no CPU for the idle wait. When the kernel returns events, the same thread executes handlers.
The loop alternates between waiting in the kernel and running handlers. During one handler phase it may run callback A, callback B, and timer C before returning to the wait.
This arrangement is efficient when most connections are idle and each ready handler performs a small amount of work.
It becomes fragile when a handler blocks or computes for too long. The loop cannot return to the wait phase or dispatch unrelated events until that handler yields control.
In a thread-per-connection design, a blocked call stack naturally remembers the connection's state:
If read_request() waits, that thread's stack remains suspended at the call.
An event loop cannot keep its one thread blocked for every connection. It stores each connection's progress explicitly:
When the connection becomes active, the loop finds this object, advances it as far as possible, saves the new state, and moves to another event.
| Connection | Before this turn | After this turn |
|---|---|---|
| A | READING_REQUEST | WRITING_RESPONSE |
| B | READING_REQUEST | Still reading |
| C | WRITING_RESPONSE | CONNECTION_CLOSED |
The connection objects represent logical concurrency. Only one handler may be executing on the loop thread at an instant, but thousands of connections can be waiting in different states.
A Reactor is driven by readiness.
The application registers descriptors and interests such as readable or writable. A kernel interface waits across the registered set and returns ready handles. The loop dispatches each ready handle to application code, and that code performs the non-blocking I/O.
recv().The name reflects the application reacting to a condition.
The architecture contains four practical pieces:
select, poll, epoll, kqueue, or an equivalent wait mechanismA readiness handler performs the I/O itself:
The handler consumes available input, interprets the result, and returns. If parsing produces a response, it places bytes in the connection's output buffer and enables writable interest.
A writable handler sends from output_sent, advances that offset after partial writes, and disables writable interest once no output remains. Leaving writable interest enabled permanently can keep the Reactor awake because sockets are often writable.
The readiness notification and the subsequent I/O call are separate:
An event says descriptor 17 may be readable, the loop dispatches on_readable(connection_17), the handler calls recv(fd 17, ...), and the result is bytes, EOF, EAGAIN, or an error.
The loop invokes a handler synchronously. The handler runs until it returns. This is often called run-to-completion handler execution, although “completion” here means the callback finishes its current turn, not that the entire client request finishes.
A request can require many turns:
Persistent connection state connects those turns.
A Proactor is driven by operation completion.
The application submits a concrete asynchronous operation with its buffer and context. The operating system or runtime performs or coordinates the operation. When it finishes, a completion dispatcher invokes the corresponding completion handler.
receive(socket, buffer, request).The application does not first receive a readable event and then call recv(). The receive was already submitted.
The architecture contains:
The control flow is completion-oriented:
The handler receives the operation result. It does not call recv() to obtain that completed data. If more input is needed, it submits another receive.
The buffer and request object must remain valid from submission through terminal completion. That explicit in-flight state is the Proactor counterpart to a Reactor's readiness interests.
The same server can be expressed in either architecture:
| Question | Reactor | Proactor |
|---|---|---|
| Event means | Operation may make progress | Submitted operation finished |
| Operation is issued | Inside readiness handler | Before waiting for completion |
| Handler receives | Ready handle and condition | Request identity and result |
| Data buffer chosen | When handler performs I/O | When operation is submitted |
| Partial progress | Handler retries or updates interest | Completion reports count; handler may submit remainder |
| Main kernel abstraction | Readiness demultiplexer | Asynchronous operation/completion queue |
A Reactor asks:
Which connection should I try now?
A Proactor asks:
Which submitted operation finished, and what was its result?
epoll naturally supports Reactor-style networking. Completion interfaces such as I/O completion ports and io_uring naturally support Proactor-style loops.
The mapping is not absolute. A completion-capable interface can also expose poll-like operations, and a runtime can wrap readiness in futures or callbacks. The pattern should be identified from the application control flow, not only from the operating-system API name.
Loading simulation...
One application can use a Reactor for network sockets and completion operations for file reads. A runtime can also expose Proactor-like callbacks while implementing some operations with readiness and others with helper threads.
Four different kinds of work arrive at one place. Anything that blocks the loop delays all four, not just the one that caused it.
The loop normalizes these sources into runnable handlers.
This is why “the service uses an event loop” does not reveal its exact kernel mechanism. Event loop describes the scheduling structure. Reactor and Proactor describe how I/O work enters that structure.
An event loop rarely processes only I/O.
It may also manage:
Before waiting, the loop finds the nearest timer deadline and uses it to bound the kernel wait:
The next timer is due in 40 ms, so the loop waits for I/O with a timeout no longer than 40 ms. Either the I/O arrives first or the timeout expires, and then the loop dispatches the I/O along with any due timers.
If another thread adds a task while the loop sleeps, it needs a wakeup mechanism. On Unix-like systems, a pipe or eventfd can make the internal task queue visible to the same I/O wait.
The internal queue itself must be synchronized when several threads can post to it, but the loop can remain the single owner of connection state.
Consider three clients whose events are returned together:
B and C each need a millisecond of work and still wait roughly fifty for it, because the loop cannot reach them until A's handler returns.
B and C were ready at the same time as A, but their latency includes A's 50 ms handler.
The same problem affects timers. A timer due after 10 ms cannot run while a handler occupies the event-loop thread for 50 ms.
Common accidental blockers include:
Non-blocking sockets do not protect the loop from blocking application code.
An event loop is responsive only when every turn is bounded.
Loading simulation...
Small parsing and response-routing steps can run directly on the loop. Long CPU work and operations without a suitable non-blocking interface need another execution resource.
A common design briefly hands such work to a bounded worker pool and returns to the loop. When the worker finishes, it posts a result back to the loop's task queue.
The loop hands the expensive work away and goes straight back to waiting. The computation still costs the same, but it no longer happens on the thread every other client depends on.
The pool must be bounded so that moving work off the loop does not create an unlimited queue. The event loop should remain the owner of connection state; workers return results rather than mutating a connection concurrently.
Multiple event loops can also run on separate OS threads, with connections partitioned among them. Each loop then executes handlers on one core while the process uses several cores overall.
These techniques add parallel execution capacity. The event loop by itself provides I/O concurrency, not unlimited CPU throughput.
Even non-blocking handlers can monopolize the loop if they process an unlimited amount of ready work.
Suppose one socket continuously receives data. A handler that parses every available message before returning can delay all other clients.
A loop can use budgets:
Care is required with edge-triggered readiness. If a handler intentionally stops before reaching EAGAIN, it cannot assume the kernel will produce another edge for data that remains ready. The loop must preserve an explicit runnable state or rearm the endpoint according to its notification protocol.
Fairness is cooperative. The operating-system scheduler cannot preempt one callback and run another callback on the same event-loop thread.
Python's standard selectors module exposes a portable readiness multiplexer. On Linux, the default selector normally uses epoll; other systems choose an appropriate native mechanism.
The following server is a small Reactor. Each connection stores pending output, and writable interest is enabled only while that output exists:
Run it:
Connect from another terminal:
The architecture is visible in the code:
selector.select() is the readiness demultiplexer.accept_ready() handles listener readiness.service_connection() dispatches readable and writable conditions.Connection.output and peer_closed preserve state across loop turns.recv() and send() perform the actual I/O.The server uses level-triggered behavior through the selector abstraction, so reading one chunk per turn is valid. The listener drains accepted connections until BlockingIOError, which is the Python form of reaching EAGAIN.
The example omits protocol parsing, connection limits, output caps, deadlines, and graceful shutdown so the Reactor structure remains clear.
Two measurements reveal whether handlers keep the loop responsive.
Handler duration measures how long each callback runs from dispatch to return. A slow-handler log should include the callback type and connection or request identity.
Loop lag compares when a timer was scheduled to run with when the loop actually began running it:
Loop lag includes time spent in earlier handlers, task queues, and scheduler delay. It is more useful than CPU utilization alone: one blocked loop can have low CPU usage while all of its connections wait.
Measurement code must also be lightweight. Synchronously logging every normal handler can become the stall it is trying to diagnose.
An event loop waits for I/O, completions, timers, and internal tasks, then dispatches short handlers. It represents many waiting operations through explicit request and connection state instead of one blocked thread stack per connection.
A Reactor receives readiness events and invokes handlers that perform non-blocking I/O. A Proactor receives results for operations submitted earlier and invokes completion handlers that consume those results and submit follow-up work.
One event-loop thread executes handlers sequentially. Any blocking call or long computation delays unrelated connections and timers, so each turn must remain bounded. CPU-heavy or blocking-only work can be handed to bounded workers, while the loop retains ownership of connection state.
Real applications can combine readiness, completion, timers, and worker results in one loop. The pattern is determined by application control flow, not merely by the name of the underlying kernel API.
The central mental model is:
A Reactor says “the operation may proceed”; a Proactor says “the operation finished”; the event loop decides which handler runs next.
5 quizzes