Blocking, Non-Blocking, and I/O Multiplexing
Your terminal must watch several things at once: your keyboard, the PTY master, a signal wake-up
channel, and later a Unix socket and several more PTYs. This chapter covers blocking vs.
non-blocking I/O and the multiplexing mechanisms — select, poll, epoll, kqueue, and
async runtimes — with the six-part treatment.
Concept 1: Blocking vs. Non-Blocking I/O
1. What problem it solves
A blocking read() is the simplest possible interface: ask for data, get data. It fails as soon as
you have two sources. Blocked on the keyboard, you cannot notice the shell's output; blocked on
the shell, you cannot notice a keystroke. Non-blocking I/O turns "wait for data" into "tell me if
there is data," which lets one thread serve many fds.
2. Where it exists
Kernel. O_NONBLOCK is a status flag on the open file description, not on the fd.
BLOCKING NON-BLOCKING
read(fd, buf, n) read(fd, buf, n)
data available? → copy, return data available? → copy, return n
none? → SLEEP none? → return -1, errno = EAGAIN
3. Who owns or interacts with it
Your program sets it. Note the sharing hazard: because the flag lives on the open file description,
setting O_NONBLOCK on an fd affects every duped copy — including copies a child inherited.
Warning: Never set
O_NONBLOCKon the PTY slave before exec. The child (and everything it runs) will getEAGAINfrom what it believes is a blocking terminal. Most programs do not handle that and will spin, error out, or lose input. SetO_NONBLOCKon the master only, in your own process, after the fork.
4. Syscalls and errors
fcntl(fd, F_GETFL) → current flags
fcntl(fd, F_SETFL, flags | O_NONBLOCK) → make it non-blocking
open(..., O_NONBLOCK) → from the start
The errors you must handle, and what each means:
| Error | Meaning | Correct response |
|---|---|---|
EAGAIN / EWOULDBLOCK | No data now (read) or no buffer space now (write). Not an error. | Return to the event loop and wait for readiness |
EINTR | A signal arrived mid-call | Retry the call (or handle it, if SA_RESTART is off) |
EIO on a PTY master read | Linux: the last slave fd closed — this is EOF | Shut down cleanly |
read() == 0 | End of file | Shut down cleanly (macOS PTY master behaves this way) |
EPIPE + SIGPIPE | Writing to a pipe/socket with no reader | Ignore SIGPIPE and handle EPIPE |
Note:
EAGAINandEWOULDBLOCKare the same value on Linux and macOS, but POSIX permits them to differ. In Rust,io::ErrorKind::WouldBlockcovers both — use it rather than comparing rawerrno.
Partial reads and writes
This is the bug that hides for weeks. write() returning fewer bytes than you asked for is not an
error — it is normal, especially on a non-blocking fd with a nearly-full buffer.
#![allow(unused)] fn main() { /// Write ALL of `buf`, handling short writes, EINTR, and EAGAIN. /// Returns how many bytes were written; the caller must retain the remainder /// and retry when the fd becomes writable. fn write_some(fd: i32, buf: &[u8]) -> std::io::Result<usize> { let mut written = 0; while written < buf.len() { let n = unsafe { libc::write(fd, buf[written..].as_ptr() as *const libc::c_void, buf.len() - written) }; if n > 0 { written += n as usize; continue; } let err = std::io::Error::last_os_error(); match err.kind() { std::io::ErrorKind::Interrupted => continue, // EINTR: retry std::io::ErrorKind::WouldBlock => break, // EAGAIN: try again later _ => return Err(err), } } Ok(written) } }
The consequence for your design: every fd you write to needs an output buffer and an interest
in writability, not just readability. A terminal that assumes write always completes will drop
keystrokes the first time you paste 200 KB into a slow program.
┌──────────────────────────────────────────────────────────┐
│ For each fd you write to: │
│ pending: VecDeque<u8> │
│ if !pending.is_empty(): register interest in WRITE │
│ on writable: write_some(); drain what was written │
│ if pending becomes empty: DEregister WRITE interest │
└──────────────────────────────────────────────────────────┘
That last line matters: if you leave write-interest registered on an idle, always-writable fd,
poll returns immediately forever and you burn a core. This is the single most common event-loop
bug in existence.
5. Experiment
# Watch a blocking write stall. Terminal A, inside your Lab 2 runner:
yes
# Now stop reading the master (comment out that poll branch and rerun).
# Terminal B (Linux):
cat /proc/<child-pid>/status | grep State # S (sleeping)
cat /proc/<child-pid>/wchan # a tty write wait
And directly, in Rust or Python:
import os, fcntl, pty, time
pid, fd = pty.fork()
if pid == 0:
os.execvp("bash", ["bash", "--norc"])
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
try:
print(os.read(fd, 1024)) # likely raises BlockingIOError immediately
except BlockingIOError as e:
print("EAGAIN — no data yet:", e)
Predict first: with O_NONBLOCK set and nothing typed, does that read return b"", raise, or
block? What is the difference between "returns 0 bytes" and "raises EAGAIN", and why does it matter
enormously?
6. Failure mode
| Mistake | Symptom |
|---|---|
Treating EAGAIN as an error | "read failed" exits at random |
Confusing read()==0 with EAGAIN | You shut down on the first idle moment |
| Ignoring partial writes | Silent data loss under load; pastes truncated |
| Leaving write-interest registered when the buffer is empty | 100% CPU |
Setting O_NONBLOCK on the slave | The child misbehaves in ways that look like your bug |
Not handling EINTR | Random failures whenever a signal arrives |
Concept 2: Multiplexing — poll, select, epoll, kqueue
1. What problem it solves
"Sleep until any of these fds is ready." Without it you either burn CPU polling or need one thread per fd.
2. Where it exists
Kernel, all of them. They differ in interface and in how they scale with the number of fds.
3. Who owns or interacts with it
Your event loop. Exactly one loop per thread; everything else is a callback into it.
4. The four mechanisms
select(nfds, &readfds, &writefds, &exceptfds, &timeout)
• POSIX, everywhere, since forever
• fd_set is a BITMAP limited to FD_SETSIZE (1024 on Linux) — an fd of 1025 is UB
• O(n) scan; you rebuild the sets on every call
• Use it only if you must be portable to something ancient. Not in this curriculum.
poll(&[pollfd], nfds, timeout_ms)
• POSIX, everywhere, no FD_SETSIZE limit
• You pass an array; the kernel fills in `revents`
• O(n) per call, but n is small for a terminal
• ← THIS is what you use in Lab 3. Simple, portable, sufficient.
epoll (Linux only)
• epoll_create1 / epoll_ctl(ADD|MOD|DEL) / epoll_wait
• The interest set lives in the KERNEL; you do not resend it every call
• O(ready), not O(watched) — scales to tens of thousands of fds
• Level-triggered (default) vs. edge-triggered (EPOLLET): with ET you MUST drain
until EAGAIN or you will never be woken again for that data
kqueue (macOS / BSD)
• kqueue() / kevent()
• Same idea as epoll, plus it can watch signals (EVFILT_SIGNAL), timers (EVFILT_TIMER),
processes (EVFILT_PROC — including child exit!), and files
• EV_EOF flag reports the peer closing
select | poll | epoll | kqueue | |
|---|---|---|---|---|
| Platform | All | All | Linux | macOS/BSD |
| Max fds | FD_SETSIZE (1024) | Unlimited | Unlimited | Unlimited |
| Cost per call | O(n) | O(n) | O(ready) | O(ready) |
| Interest set stored | User side, rebuilt each call | User side, reused | Kernel | Kernel |
| Can watch signals | No | No | Via signalfd | Yes, natively |
| Can watch child exit | No | No | Via pidfd (modern Linux) | Yes, EVFILT_PROC |
| Edge-triggered option | No | No | Yes (EPOLLET) | Yes (EV_CLEAR) |
Tip: For a terminal with 3–10 fds,
pollis not merely adequate — it is correct.epoll's advantage appears in the thousands. Usepollin Section 1, and reach forepoll/kqueue(ormio, which picks for you) in Section 4 when you have one fd per pane plus one per client.
The poll loop, concretely
#![allow(unused)] fn main() { use std::io; const POLL_STDIN: usize = 0; const POLL_MASTER: usize = 1; const POLL_SIGNAL: usize = 2; fn run(stdin: i32, master: i32, sigpipe: i32) -> io::Result<()> { let mut fds = [ libc::pollfd { fd: stdin, events: libc::POLLIN, revents: 0 }, libc::pollfd { fd: master, events: libc::POLLIN, revents: 0 }, libc::pollfd { fd: sigpipe, events: libc::POLLIN, revents: 0 }, ]; loop { // -1 = wait indefinitely. Use a timeout only if you have periodic work. // SAFETY: fds is a valid array of len 3; poll writes revents. let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, -1) }; if n < 0 { let e = io::Error::last_os_error(); if e.kind() == io::ErrorKind::Interrupted { continue; } // EINTR return Err(e); } // POLLIN — readable // POLLHUP — the peer hung up. NOTE: you may get POLLIN|POLLHUP together, with // data still buffered. Read first; only treat HUP as EOF once read // returns 0 or EIO. Treating HUP as immediate EOF loses the last output. // POLLERR — an error condition; read/write will tell you what // POLLNVAL— the fd is not open. A bug in YOUR bookkeeping. Panic in debug. if fds[POLL_SIGNAL].revents & libc::POLLIN != 0 { drain_signals()?; } if fds[POLL_STDIN].revents & (libc::POLLIN | libc::POLLHUP) != 0 { pump_stdin_to_master()?; } if fds[POLL_MASTER].revents & (libc::POLLIN | libc::POLLHUP) != 0 { pump_master_to_stdout()?; } if fds[POLL_MASTER].revents & (libc::POLLERR | libc::POLLNVAL) != 0 { break; } } Ok(()) } }
The comment about POLLHUP is the load-bearing part. On a PTY master, when the child exits you
typically get POLLIN | POLLHUP with data still in the buffer. A loop that treats POLLHUP as
"exit now" drops the child's last output — the intermittently-missing-last-line bug from
the signals chapter.
Level-triggered vs. edge-triggered
LEVEL-TRIGGERED (poll, select, epoll default)
"Tell me whenever there is data."
Data arrives → wake. You read 10 of 100 bytes → next poll wakes again immediately.
Forgiving. Slightly more syscalls. USE THIS.
EDGE-TRIGGERED (EPOLLET, EV_CLEAR)
"Tell me when the state CHANGES."
Data arrives → wake ONCE. You read 10 of 100 bytes → no further wake-up until
MORE data arrives. If none ever does, the 90 bytes sit there forever and your
terminal appears to hang, then unblocks when the user types.
Requires: loop reading until EAGAIN, every time. Fewer syscalls. Easy to get wrong.
Warning: Edge-triggered mode is the standard way to write a hanging terminal. If you use it, the rule is absolute: read until
EAGAIN, always, no early exit. Not "read until you have enough." Not "read one buffer and render." UntilEAGAIN.
5. Experiment
# Watch poll wake up. Linux:
strace -e trace=poll,ppoll,read,write -p <your runner's pid>
# Type a key → poll returns 1, read(0) returns 1 byte, write(master) 1 byte,
# poll returns 1, read(master) returns the echo, write(1).
# FOUR syscalls per keystroke. That is the round trip, made visible.
# macOS:
sudo dtruss -f -p <pid> 2>&1 | grep -E 'kevent|read|write'
This experiment is the single best way to see the echo round trip described in the mental model. Do it.
6. Failure mode
| Mistake | Symptom |
|---|---|
Not handling EINTR from poll | Random exits when a signal arrives |
Treating POLLHUP as immediate EOF | Missing last line of output |
| Registering write-interest permanently | 100% CPU |
Edge-triggered without draining to EAGAIN | The terminal hangs until the next keystroke |
Ignoring POLLNVAL | You are polling a closed fd — a bookkeeping bug that gets worse |
Using select with fds ≥ 1024 | Stack corruption. Never use select. |
| Doing rendering inside the read branch | The child blocks in write under heavy output; "my terminal froze" |
Concept 3: The Rust Abstractions, and What They Wrap
First the Unix API, then the crate — the rule for this section. Here is what each crate is doing underneath, so you can choose deliberately.
| Crate | Wraps | When to introduce | What it costs you |
|---|---|---|---|
libc | Nothing — it is the declarations | Lab 1 | Everything is unsafe and i32 |
nix | poll, waitpid, openpty, tcsetattr, ioctl macros, sigaction | Lab 3, after the raw version | A dependency; slightly opinionated types |
rustix | The same surface, with OwnedFd/BorrowedFd I/O safety; on Linux can bypass libc | Lab 3, as the nix alternative | Same |
signal-hook | sigaction + the self-pipe trick (or signalfd) | Lab 3 | Hides the mechanism you just learned — which is fine, now |
mio | epoll (Linux) / kqueue (BSD) / IOCP (Windows) behind one Poll/Token/Interest API | Section 4 | Registration bookkeeping; no async |
tokio | mio + a task scheduler + futures + timers | Section 4, optional | A runtime, async fn colouring, and a large conceptual surface |
portable-pty | Everything in Lab 2, plus Windows ConPTY | After Lab 2 | The entire lesson, if used early |
The honest recommendation for this curriculum:
Lab 1–2 libc only. You are learning the syscalls.
Lab 3 libc, then port to nix/rustix + signal-hook and DIFF the two.
Section 4 mio (or plain poll — one fd per pane is still a small number).
Async Only if you want to learn async. It buys a terminal very little:
the workload is a handful of fds, not ten thousand connections.
Note: The "diff the two" step in Lab 3 is the exercise, not a formality. Write the
libcversion, get it working, then rewrite withnix, and read the two side by side. Every line that disappeared is a piece of unsafety the crate is handling for you, and you should be able to name which one.
Threads vs. one event loop
You will be tempted to spawn a thread per fd. For a terminal, resist:
| Approach | Pros | Cons |
|---|---|---|
One poll loop | One place to reason about state; no locks; deterministic ordering; trivial to instrument | You must not block in any branch |
| Thread per fd + channels | Blocking reads are simple | Ordering between input and output becomes nondeterministic; every piece of terminal state needs a lock; signal handling is subtler; and reproducing bugs becomes hard |
| Async runtime | Composable; scales | A runtime, and async colouring, for a problem with four fds |
The one place a thread genuinely helps is rendering: a render thread consuming
RenderSnapshots while the event loop keeps draining the PTY. That is a
Section 3 topic, and it works precisely because the
boundary between the two is a value, not shared mutable state.
Validation / Self-check
- Where does
O_NONBLOCKlive — the fd, the open file description, or the object? Why does that matter afterfork? - What is the difference between
read()returning 0 andread()returningEAGAIN? - Why is a short
write()not an error, and what data structure must your design have because of it? - What is the bug that results from leaving write-interest registered on an idle fd?
- Compare
select,poll,epoll, andkqueueon: platform, scaling, where the interest set lives, and whether they can watch signals. - Why does this curriculum use
pollin Section 1 rather thanepoll? - Explain level-triggered vs. edge-triggered, and state the absolute rule for edge-triggered.
- On a PTY master, why must you not treat
POLLHUPas immediate EOF? - Name the four syscalls involved in one round trip of a single keystroke, in order.
- For each of
nix,rustix,signal-hook,mio, andtokio: what OS functionality does it wrap, and at what point in this curriculum is it appropriate? - Why should the render loop not live inside the "master is readable" branch?