Unit 3 · OS & concurrency · Lesson 5 of 11
I/O models, readiness and event loops
Step 1: Learn the ideas
- Blocking I/O and thread-per-connection servers
- Nonblocking descriptors and EAGAIN
- Readiness APIs: select, poll, epoll, kqueue
- Level-triggered versus edge-triggered notification
- Event loops, callbacks and async/await
- Partial reads and writes, framing and output buffering
- Backpressure and bounded buffers
- Timers and connection timeouts
- Connection lifetimes: EOF, half-close, resets and shutdown
- Thread pools for blocking and CPU-heavy work
- Completion-based I/O: io_uring and IOCP
Why it matters
Every network service waits for many clients at once, and the waiting strategy shapes its memory use, latency and failure modes. The practical versions of this topic are common: an async service that calls model APIs becomes unresponsive because one handler parses a large document synchronously; a server corrupts data because it assumed one read() returns one message; a proxy runs out of memory because it reads faster than it can write. Backend, networking and infrastructure interviews ask you to design or debug such loops.
Core ideas
Blocking threads. The simplest server dedicates a thread to each connection and lets read() block. It is easy to write and debug and works well for moderate connection counts. Costs grow with many mostly idle connections: a stack per thread, scheduler work and context switches. That limit is the classic "C10K" motivation for event-driven designs.
Nonblocking descriptors plus readiness. With O_NONBLOCK, read() and write() return EAGAIN instead of sleeping, and a readiness API tells you which descriptors can make progress. select uses fixed-size bitmaps and rescans every descriptor on each call; poll takes an array but still scans it; Linux epoll keeps the interest set in the kernel and returns only ready descriptors; BSD and macOS kqueue does the same and also delivers timers, signals and process events.
Level versus edge triggering. Level-triggered notification keeps reporting a descriptor while data remains. Edge-triggered notification reports changes, so the handler must drain the socket until EAGAIN or it may never be woken again. Edge triggering reduces repeated wakeups but punishes partial handling.
Readiness is not completion. Readiness says an operation would not block now; you still perform the copy yourself. Regular files are always considered ready (epoll refuses them outright), yet reading them can block on the disk. Event-loop runtimes therefore run file operations on a thread pool, as libuv does under Node.js, or use completion-based I/O. Linux io_uring shares a submission ring and a completion ring with the kernel: you queue operations, the kernel performs them and posts results, and many operations can be submitted with one system call.
The event loop. Wait for events, run the callbacks or resume the coroutines that were waiting, run expired timers, repeat; async/await is syntax over the same state machine. One thread owns the loop's state, so it needs no locks, but any callback that blocks or computes for long delays every other connection on that loop. Offload CPU-heavy work and blocking libraries to worker threads or processes, and monitor loop lag, the delay between when a timer should fire and when it runs. Python's asyncio debug mode, for example, reports slow callbacks.
Byte streams and partial operations. TCP delivers bytes, not messages: one read() can return half a message or three of them, so the application frames messages with a length prefix or delimiter. A nonblocking write() may accept only part of the buffer; keep the remainder in a per-connection output buffer and ask for writability notifications only while that buffer is non-empty.
Backpressure and lifetimes. If a peer reads slowly, output buffers grow; stop reading from the producing side when a buffer passes a bound instead of buffering without limit. Connections end in several ways: read() returning 0 means the peer finished sending (it may still be reading); a reset surfaces as ECONNRESET or EPIPE, plus SIGPIPE unless suppressed. Servers also need idle and request timeouts, a graceful shutdown that stops accepting and drains in-flight work, and a plan for running out of file descriptors.
Common misconceptions
- "Nonblocking and asynchronous are the same." Nonblocking calls return immediately with
EAGAIN; asynchronous (completion) I/O starts an operation and tells you when it has finished. - "epoll makes file reads nonblocking." epoll does not accept regular files; disk reads block unless offloaded or issued through io_uring.
- "One read returns one message." TCP is a byte stream; framing is your job.
- "A single-threaded event loop cannot have races." State can change across every
await; check-then-act spanning an await point is a logical race. - "Async is always faster than threads." Async wins with many mostly idle connections; for modest counts, threads with blocking I/O are often simpler and just as fast.
- "Edge-triggered is just a faster level-triggered." It changes the contract: forget to drain and the connection stalls.
How to approach it
Build the same echo server three ways: thread per connection, a poll or epoll loop, and your language's async runtime. Test it with a client that sends one byte at a time and with one that never reads its replies, and confirm that framing and backpressure hold. For interviews, be ready to sketch the loop, point to where a blocking call would hurt, and say how you would detect it in production. Senior discussions add multi-core designs (one loop per core, distributing accepts) and behavior under overload.
Suggested order
- 1Read OSTEP Ch. 33 (Event-based Concurrency) for the event loop, select(), the blocking-call problem and state management.
- 2Read epoll(7) closely, especially level- versus edge-triggered behavior and its suggested usage, then skim poll(2) for comparison.
- 3Read the libuv design overview to see a production loop’s phases and why file I/O runs on a thread pool.
- 4Read the Node.js guide on not blocking the event loop and map each recommendation onto the runtime you use (asyncio, Tokio or Node).
- 5Read the LWN io_uring article for awareness of completion-based I/O.
- 6Do the OSTEP Ch. 33 homework (a select()-based server), then build the echo-server variants described in the practice guidance.
Step 2: Check off what you can do
Tap each one you can genuinely do or explain. Each earns 10 XP.
Step 3: Go deeper
Operating Systems: Three Easy PiecesStart here
University of Wisconsin–Madison (free online book)Section: Ch. 33: Event-based Concurrency
Primary conceptual introduction: event loops, select(), why blocking calls break the model and the cost of manual state management.
Open(opens in a new tab)Link responded, 5 Oct 2026The Linux man-pages project (man7.org)Start here
man7.org (Linux man-pages project)Section: epoll(7)
Authoritative epoll semantics, including edge-triggered pitfalls and recommended usage.
Open(opens in a new tab)Link responded, 5 Oct 2026libuv: Design overview
libuv documentationA real event loop’s structure and its thread pool for file I/O.
Open(opens in a new tab)Link responded, 5 Oct 2026Don't Block the Event Loop (or the Worker Pool)
Node.js documentation (Learn)Practical rules for keeping a loop responsive: partitioning, offloading and worker pools.
Open(opens in a new tab)Link responded, 5 Oct 2026Ringing in a new asynchronous I/O API
LWN.netAwareness-level introduction to completion-based I/O with shared submission and completion rings.
Open(opens in a new tab)Link responded, 5 Oct 2026
Step 4: Practice
After the OSTEP homework, build an echo server three ways (thread per connection, a poll or epoll loop, your language’s async runtime). Test with a client that sends one byte at a time, one that sends many messages in a single write, and one that connects and never reads; the loop must frame correctly and stop reading when an output buffer passes a limit. Then add a handler that sleeps or computes for 200 ms and measure how it delays other clients; fix it by offloading. On Linux, inspect the process with ss -tnp and ls /proc/<pid>/fd to see its connections and descriptors. The concurrent web server project is a longer, thread-pool alternative.
- OSTEP Ch. 33 homework: a select()-based serverOpen(opens in a new tab)Not attemptedOSTEP homework
- OSTEP project: concurrent web serverOpen(opens in a new tab)Not attemptedOSTEP projects
Record attempts on each problem’s page (+15 XP, +10 more when solved on your own).
Step 5: Test yourself
Answer each one out loud or on paper before you finish the lesson.
- Question 1What does it mean when read() on a nonblocking socket returns 0, returns -1 with EAGAIN, or returns a short count?
- Question 2Why does select() scale poorly with many descriptors?
- Question 3When should a loop register interest in writability, and when should it stop?
- Question 4How would you detect that something is blocking your event loop in production?
- Question 5Why is check-then-act across an await point a race even in a single-threaded runtime?
- Question 6What does io_uring change compared with epoll?
You’re done when you can
- Write a nonblocking echo server with poll or epoll that passes byte-by-byte and pipelined-message tests.
- Explain level- versus edge-triggered notification and the draining rule in under two minutes.
- Explain why regular-file reads are offloaded to thread pools in event-loop runtimes.
- Show by measurement how one blocking handler delays unrelated connections, and fix it.
- Describe how a server should behave on EOF, on a reset, at an idle timeout and during graceful shutdown.
Finish the lesson
Unlocks next: Sockets, buffering and backpressure


