3.1 The five I/O models (the canonical comparison)
| Model | Wait for data | Copy data to process | Blocks where? |
|---|---|---|---|
| Blocking I/O | process blocks | process blocks | the whole call (default) |
| Non-blocking I/O | poll: EWOULDBLOCK loop | blocks during copy | nowhere (but burns CPU polling) |
| I/O multiplexing | blocks in select/poll | blocks in recvfrom | select — but on MANY fds at once |
| Signal-driven I/O (SIGIO) | kernel signals readiness | blocks in recvfrom | nowhere while waiting |
| Asynchronous I/O (aio_) | kernel does both, then notifies | — | truly async (POSIX AIO) |
The first four are synchronous (the process itself does the read); only the fifth is asynchronous. The two phases — waiting for data and copying it — are the axis of comparison; draw this 2-phase diagram in exams.
3.2 select — wait on many descriptors at once
int select(int maxfdp1, fd_set *readset, fd_set *writeset,
fd_set *exceptset, struct timeval *timeout);
/* macros: FD_ZERO(&set) FD_SET(fd,&set) FD_CLR(fd,&set) FD_ISSET(fd,&set) */
maxfdp1= highest descriptor + 1 (the classic off-by-one).- timeout: NULL = wait forever; {0,0} = poll; else bounded wait.
- The sets are value-result — overwritten with the ready subset; rebuild them every loop.
- A socket is read-ready when: data is queued (≥ SO_RCVLOWAT), peer sent FIN (read returns 0!), a listening socket has a completed connection, or a pending error exists.
Fixing the Unit-2 echo client. The old str_cli blocked in fgets and missed the server's FIN. The select version waits on stdin and the socket simultaneously:
void str_cli(FILE *fp, int sockfd) {
int stdineof = 0; fd_set rset; char buf[MAXLINE]; int n;
FD_ZERO(&rset);
for (;;) {
if (stdineof == 0) FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
select(max(fileno(fp), sockfd) + 1, &rset, NULL, NULL, NULL);
if (FD_ISSET(sockfd, &rset)) { /* socket readable */
if ((n = read(sockfd, buf, MAXLINE)) == 0) {
if (stdineof == 1) return; /* normal end */
else err_quit("server terminated prematurely");
}
write(fileno(stdout), buf, n);
}
if (FD_ISSET(fileno(fp), &rset)) { /* stdin readable */
if ((n = read(fileno(fp), buf, MAXLINE)) == 0) {
stdineof = 1; /* EOF on input */
shutdown(sockfd, SHUT_WR); /* send FIN... */
FD_CLR(fileno(fp), &rset);
continue; /* ...keep reading! */
}
writen(sockfd, buf, n);
}
}
}
3.3 shutdown vs close — and batch input
int shutdown(int sockfd, int howto); /* SHUT_RD | SHUT_WR | SHUT_RDWR */
close(fd) | shutdown(fd, SHUT_WR) | |
|---|---|---|
| reference counting | yes — FIN only when count hits 0 | ignores it — FIN now |
| direction | both | one direction only (half-close) |
| can still read after? | no | yes — that's the point |
Why the echo client needs SHUT_WR (batch input): pipe a file into the client (client < file). At EOF, a naive client exits — but the pipeline is still full of requests whose replies haven't arrived. shutdown(SHUT_WR) says "no more requests" (FIN) while the client keeps reading until the server's FIN arrives — draining every reply. Half-close exists precisely for this.
3.4 A select-based concurrent server — no fork at all
One process holds the listening socket and every connected socket in an fd_set; readiness drives the work:
/* core idea */
FD_SET(listenfd, &allset);
for (;;) {
rset = allset;
select(maxfd+1, &rset, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &rset)) /* new client → accept, add to allset */
for (each client fd in rset) /* data → read; 0 → close, FD_CLR */
...
}
This is the architecture of classic single-threaded servers — and conceptually of every modern event loop (Node.js, nginx, Redis): one thread, thousands of sockets, readiness notifications. (A caution it inherits: one slow read handler stalls everyone — and a malicious client sending half a line can deny service to the rest; production loops add per-connection buffers and timeouts.)
3.5 poll — select without the bitmask limits
struct pollfd { int fd; short events; short revents; };
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout /* ms */);
- Pass an array — no FD_SETSIZE ceiling, no maxfdp1.
events= what you ask (POLLIN, POLLOUT);revents= what happened (plus error bits POLLERR, POLLHUP, POLLNVAL that need no request) — not value-result, no per-loop rebuild.- timeout in milliseconds: −1 forever, 0 poll.
select vs poll vs beyond: select is the portable classic; poll scales the API; Linux epoll / BSD kqueue make readiness O(1) per event instead of O(n) per call — mention them as the modern endpoints of this lesson's lineage.
3.6 select vs poll vs epoll — the full comparison table
| Criterion | select | poll | epoll / kqueue |
|---|---|---|---|
| fd limit | FD_SETSIZE (typically 1024) | none (array of pollfd) | none |
| interest set passed | every call (sets are clobbered) | every call (but revents separate) | once (epoll_ctl); kernel keeps it |
| cost per call | O(n) scan in kernel and user | O(n) both sides | O(ready) — only ready fds returned |
| timeout granularity | struct timeval (µs) | milliseconds | milliseconds (epoll_wait) |
| portability | everywhere, even Windows | POSIX | Linux-only / BSD-only |
| sweet spot | small fd counts, max portability | medium counts, >1024 fds | thousands of mostly-idle connections (the C10K problem) |
The architectural sentence to close a comparison answer: select/poll are stateless — you re-describe your interest every call and the kernel re-checks every descriptor; epoll is stateful — interest is registered once and the kernel reports only events, which is why its cost tracks activity rather than connection count.
3.7 When exactly is a socket writable? (the forgotten half of readiness)
Everyone learns the read-ready list; the write-ready conditions are the discriminating exam detail. A socket is write-ready when:
- there is room in the send buffer (free space ≥ SO_SNDLOWAT) and the socket is connected — note this says nothing about the network: writability means "the kernel will take your bytes", not "the peer will get them soon";
- the write half has been shut down — write would generate SIGPIPE (readiness includes "ready to fail"!);
- a socket using nonblocking connect has completed the connect (successfully or not — check SO_ERROR);
- a pending error exists (write will return −1 immediately).
Items 2 and 4 generalise to the rule worth memorising: "ready" means "the call will not block" — not "the call will succeed."
3.8 The full select-based echo server (filling in §3.4's sketch)
int maxfd = listenfd, client[FD_SETSIZE], maxi = -1;
fd_set allset, rset;
for (i = 0; i < FD_SETSIZE; i++) client[i] = -1;
FD_ZERO(&allset); FD_SET(listenfd, &allset);
for (;;) {
rset = allset; /* struct copy each loop */
nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &rset)) { /* new client */
connfd = accept(listenfd, (SA *)&cliaddr, &clilen);
for (i = 0; i < FD_SETSIZE; i++)
if (client[i] < 0) { client[i] = connfd; break; }
FD_SET(connfd, &allset);
if (connfd > maxfd) maxfd = connfd;
if (i > maxi) maxi = i;
if (--nready <= 0) continue;
}
for (i = 0; i <= maxi; i++) { /* data on a client? */
if ((sockfd = client[i]) < 0) continue;
if (FD_ISSET(sockfd, &rset)) {
if ((n = read(sockfd, buf, MAXLINE)) == 0) { /* client FIN */
close(sockfd); FD_CLR(sockfd, &allset); client[i] = -1;
} else
writen(sockfd, buf, n);
if (--nready <= 0) break;
}
}
}
The bookkeeping is the lesson: allset holds long-term interest, rset is the per-loop disposable copy, client[] maps slots to descriptors, maxfd/maxi bound the scans, and nready short-circuits once all ready descriptors are handled. Every line answers a "why?" viva question.
3.9 Worked trace: batch input without shutdown — watching the bug
Run tcpclient < commands.txt where the file holds 10 lines. Without half-close: fgets hits EOF after line 10 while (say) replies 1–7 have arrived; a naive client calls close → FIN → the server's replies 8–10 race against the connection teardown and may meet a closed receive window → lost replies, no error shown. With shutdown(sockfd, SHUT_WR): FIN tells the server "no more requests", the client's read loop continues, replies 8–10 arrive, the server's FIN (read returns 0 with stdineof == 1) ends the program cleanly. Narrating this trace — what's in flight when EOF strikes — is the expected answer to "why is shutdown needed for batch input?", and it's also why the pipeline keeps full throughput: requests stream without waiting per-reply.
Exam pointers
- "Explain the five I/O models with diagrams" — draw the two-phase (wait / copy) timeline per model; state that only AIO is asynchronous. This is the unit's most reliable long question.
- "Compare select and poll" — §3.5 + the table above; the value-result-sets vs events/revents distinction is the key discriminator.
- "Distinguish close and shutdown" — the §3.3 table verbatim, then the batch-input story as the why.
- Likely code question: "modify str_cli to handle server termination while blocked on stdin" — the §3.2 select version is the model answer; be able to write it.
Check yourself
- Why must rset be re-copied from allset on every loop iteration in select but not re-built in poll?
- A listening socket becomes read-ready. What does "read" mean for it, and which call do you make?
- select says a socket is writable. Can write still block? Can it still fail? Reconcile with §3.7's rule.
- In the select server above, why track maxi and nready at all — what gets slower without them?
- Which I/O model does
fcntl(fd, F_SETFL, O_NONBLOCK)+ a read loop implement, and what is its CPU cost compared to select?