I/O and the I/O Models

The difference between a server that handles a hundred connections and one that handles a hundred thousand on the same hardware usually comes down to one choice: how it does I/O. Blocking, non-blocking, and asynchronous I/O aren't interchangeable styles — they're fundamentally different models with different scaling limits, and understanding them explains async/await, event loops, and why the network stack works the way it does.

The process post noted processes spend much time Blocked on I/O; this post covers I/O itself — how programs read and write files, networks, and devices, and the crucial I/O models (blocking, non-blocking, asynchronous) that determine how well a program scales. I/O is where a lot of real-world performance lives (programs are often I/O-bound, not CPU-bound), and the model you choose is one of the most consequential system-design decisions. This connects to the networking, concurrency, and LLM-serving series.

Files, descriptors, and the I/O abstraction

The OS abstracts all I/O — files, network sockets, pipes, devices — behind a uniform interface. The central abstraction is the file descriptor (fd): a small integer handle representing an open I/O resource, whatever it is:

The OS also buffers I/O — data is often staged in kernel buffers (and library buffers) rather than each byte going straight to the device — which improves efficiency (batching) but means “written” doesn’t always mean “on disk” (why fsync exists, from the database-internals WAL post). With the abstraction (fds, read/write syscalls, buffering) in mind, the crucial question is: what does your program do while an I/O operation is in progress? That’s the I/O model.

Blocking I/O: simple but limited

The default, simplest model is blocking I/O: when you call read() and the data isn’t ready (e.g. waiting on a network response or disk), your thread blocks — it stops and waits (goes to the Blocked state, from the process post) until the I/O completes, then continues:

Blocking read:
   read(fd) → (no data yet) → THREAD BLOCKS, waiting... → data arrives → returns
   → the thread does nothing else while waiting

Blocking I/O is easy to program (call read, get data, continue — straightforward sequential code) but has a scaling problem: one thread can only wait on one thing at a time. To handle many concurrent I/O operations (e.g. many network connections) with blocking I/O, you need many threads — typically one thread per connection, each blocking on its connection. And that runs into the thread-scaling limits from the scheduling post:

This is the classic scaling wall: blocking I/O with thread-per-connection doesn’t scale to very high concurrency (the “C10K problem” — handling 10,000+ concurrent connections). It’s simple and fine for modest concurrency, but it’s why high-concurrency servers need a different model.

Non-blocking and asynchronous I/O

To handle massive concurrency, you need a model where one thread manages many I/O operations without blocking on each. Two related approaches:

Event loop (one thread, many connections):
   epoll_wait() → OS returns the fds that are READY → handle each ready fd → loop
   → one thread multiplexes thousands of connections, blocking on none

The key idea in both: decouple “many concurrent I/O operations” from “many threads.” Instead of one blocked thread per operation, one thread (or a small pool) multiplexes many operations via non-blocking I/O + event notification. This is how high-concurrency servers (nginx, Node.js, Go’s runtime, async Python/Rust) handle enormous numbers of connections on few threads — they use event loops / async I/O, not thread-per-connection. It scales because it removes the per-connection thread cost (memory + context switches, from the scheduling post) — a mostly-idle-waiting connection costs almost nothing (just an fd the OS watches), not a whole thread.

Choosing an I/O model

The model is a real design decision with clear trade-offs:

The through-line, and why this matters: most servers are I/O-bound (limited by waiting on I/O, not CPU), so the I/O model is often the dominant scaling factor — the difference between handling hundreds vs hundreds of thousands of connections on the same hardware. Understanding blocking vs non-blocking/async explains why async/await and event loops exist, why “don’t block the event loop” is a rule, why Go and Node scale to huge concurrency, and how to choose for your own systems. This connects directly to the LLM-serving series (async, batching, high-concurrency serving) and the networking series (handling many connections).

I/O, understood

The takeaway: the OS abstracts all I/O behind file descriptors and read/write syscalls (crossing the user/kernel boundary, buffered for efficiency), and the crucial choice is the I/O model — what your program does while I/O is in progress. Blocking I/O is simple but forces thread-per-operation, which doesn’t scale to high concurrency (the C10K wall). Non-blocking I/O with event loops (epoll/kqueue) and asynchronous I/O (async/await) decouple concurrency from threads, letting one thread multiplex many operations — which is how high-concurrency servers scale. Choose blocking for simplicity/modest concurrency, async for high concurrency (or use runtimes like Go/async that give both). Since most servers are I/O-bound, the I/O model is often the scaling decision. The final post ties the series together: system calls and why OS knowledge makes you a better engineer.

Key takeaways

Further reading

Sources & References