阻塞 vs 非阻塞 I/O —— 同一个 read 的两种世界
同样调 read(fd, buf, n),加不加 O_NONBLOCK 行为完全不一样:阻塞模式下没数据就睡到天荒地老;非阻塞模式下立即返回 EAGAIN,剩下的得自己 poll。这篇把阻塞与非阻塞的行为差异、各自适用场景、EAGAIN 处理、跟事件循环的搭配讲清楚。
0. 引言:read 没数据怎么办?
int fd = open("/dev/tty", O_RDONLY); /* 终端 */char buf[100];ssize_t n = read(fd, buf, 100);/* 终端没人输入,read 一直不返回,进程卡死 */
int fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);char buf[100];ssize_t n = read(fd, buf, 100);/* n = -1, errno = EAGAIN —— 立即返回 */
默认是阻塞。加 O_NONBLOCK 就是非阻塞。这是 Linux I/O 编程的根本分水岭。
1. 阻塞 I/O:让内核帮你等
1.1 阻塞 read 的行为
ssize_t n = read(fd, buf, 100);
阻塞期间进程在内核的等待队列里睡,不消耗 CPU。
1.2 阻塞 write 的行为
ssize_t n = write(fd, buf, 100);
1.3 阻塞 accept、connect
int conn = accept(srv, NULL, NULL);connect(s, &addr, sizeof addr);
| | |
|---|
accept | | 三次握手完成,新 fd 出现在 backlog 队列 |
connect | | 收到 SYN-ACK(成功)/ 超时 / RST(失败) |
1.4 阻塞模式的优点
/* 实现一个简单的服务器只需要 */while (1) { int conn = accept(srv, NULL, NULL); handle_request(conn); close(conn);}
代码线性、直观——"做完这一步再做下一步"的思维。适合的场景:
| |
|---|
| curl |
| pre-fork(Apache prefork)、per-thread |
| |
1.5 阻塞模式的局限
一个进程/线程只能等一个 fd。要同时处理 1000 个连接,要么 1000 个线程(开销大),要么改用非阻塞 + 多路复用。
2. 非阻塞 I/O:来不及就立即返回
2.1 怎么开启
两种方式:
/* 方式 1:open 时设 */int fd = open(path, O_RDONLY | O_NONBLOCK);/* 方式 2:fcntl 改 */int flags = fcntl(fd, F_GETFL);fcntl(fd, F_SETFL, flags | O_NONBLOCK);
socket 也一样:
int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); /* 一步到位 */
2.2 非阻塞 read 的行为
ssize_t n = read(fd, buf, 100);
- 无数据:立刻返回 -1,errno =
EAGAIN(== EWOULDBLOCK)
ssize_t n = read(fd, buf, 100);if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { /* 没数据,不是错误,稍后再试 */ } else if (errno == EINTR) { /* 信号打断,重试 */ } else { /* 真的出错了 */ perror("read"); }}
2.3 非阻塞 write 的行为
ssize_t n = write(fd, buf, 1000);
- 缓冲有空间:写一部分(可能短于 1000),返回写入字节数
- 缓冲完全满:返回 -1,errno = EAGAIN
工程上 write 经常短写,非阻塞下要循环 + EAGAIN 检测:
ssize_t total = 0;while (total < n) { ssize_t w = write(fd, buf + total, n - total); if (w < 0) { if (errno == EINTR) continue; if (errno == EAGAIN) { /* 缓冲满了,先停下,等可写事件再继续 */ break; } return -1; } total += w;}return total;
2.4 非阻塞 accept、connect
/* 非阻塞 accept */int conn = accept(srv, NULL, NULL);if (conn < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { /* 暂时没新连接 */}/* 非阻塞 connect 比较特殊 */int rc = connect(s, &addr, sizeof addr);if (rc < 0 && errno == EINPROGRESS) { /* 三次握手还在进行,要 select/epoll 等可写事件 */}
3. 阻塞 vs 非阻塞 行为对比
4. EAGAIN:不是错误,是"稍后再试"
EAGAIN 跟 EWOULDBLOCK 在 Linux 上是同一个值(11),有些 Unix 上不同。代码里两个都判断更稳:
if (errno == EAGAIN || errno == EWOULDBLOCK) { ... }
或者直接用宏:
#ifndef EWOULDBLOCK#define EWOULDBLOCK EAGAIN#endif
4.1 EAGAIN 的处理选项
忙轮询(busy poll):立即重试。几乎从不该这么写,浪费 100% CPU。
while (1) { ssize_t n = read(fd, buf, 100); if (n >= 0) break; if (errno != EAGAIN) { error; break; } /* spin again */}
加 sleep 退避:每次失败 sleep 几 ms。简单粗暴,延迟大。
用多路复用:select / poll / epoll 等"可读"事件再读。唯一推荐的方式。
5. 非阻塞 + 多路复用:现代服务器的标配
非阻塞本身没有意义,必须配合多路复用才发挥价值:
伪代码:
int epfd = epoll_create1(EPOLL_CLOEXEC);/* 注册若干非阻塞 fd 到 epfd */while (1) { struct epoll_event evs[64]; int n = epoll_wait(epfd, evs, 64, -1); for (int i = 0; i < n; i++) { int fd = evs[i].data.fd; if (evs[i].events & EPOLLIN) { /* 可读:循环 read 直到 EAGAIN */ while (1) { ssize_t r = read(fd, buf, sizeof buf); if (r > 0) handle(fd, buf, r); else if (r == 0) { close(fd); break; } else if (errno == EAGAIN) break; else { close(fd); break; } } } }}
关键:每个 fd 都要"读到 EAGAIN 才停",否则下一次 epoll_wait 不一定再触发(取决于 LT/ET 模式,下篇细讲)。
6. 阻塞 vs 非阻塞 选型指南
7. 几个常被搞错的点
7.1 fcntl 改 flags 时要先读再改
/* ⛔ 错:直接覆盖了所有 flags */fcntl(fd, F_SETFL, O_NONBLOCK);/* ✅ 对:保留其他 flags,只加 O_NONBLOCK */int flags = fcntl(fd, F_GETFL);fcntl(fd, F_SETFL, flags | O_NONBLOCK);
7.2 dup 出来的 fd 共享 flags
O_NONBLOCK 是 file 级的(不是 fd 级),所以同一个 file 的所有 fd 行为一致:
int fd1 = open(...);int fd2 = dup(fd1);fcntl(fd1, F_SETFL, O_NONBLOCK);/* fd2 也变非阻塞了 */
7.3 stdin 别随便设非阻塞
stdin 跟 stdout、stderr 经常是同一个 file(终端)。给 stdin 设 O_NONBLOCK 会让 stdout 也变非阻塞,printf 行为可能错乱。
要么 dup 一份再改,要么用 unlocked stdio。
7.4 阻塞模式下也可能短读
ssize_t n = read(fd, buf, 100); /* n 可能是 50,不是 100 */
阻塞 read 等到"有任何数据"就返回,不一定填满 buf。循环 read 是必须的(这一点跟非阻塞一样)。
7.5 connect 非阻塞返回 EINPROGRESS 不是错误
int rc = connect(s, &addr, sizeof addr);if (rc == 0) { /* 立即成功(罕见,本机 unix socket 可能)*/} else if (errno == EINPROGRESS) { /* 三次握手在进行,select/epoll 等可写事件,再用 SO_ERROR 取结果 */ int err; socklen_t len = sizeof err; getsockopt(s, SOL_SOCKET, SO_ERROR, &err, &len); if (err == 0) { /* 连接成功 */ } else { /* 连接失败,err 是真正的错误码 */ }}
7.6 SIGPIPE 在两种模式下都会发
写关闭的 socket / pipe 触发 SIGPIPE,跟阻塞/非阻塞无关。忽略 SIGPIPE 永远是对的:
signal(SIGPIPE, SIG_IGN);
8. 异步 I/O:跟非阻塞的区别
很多人混淆"非阻塞"和"异步"。两者完全不同:
| | 异步 (POSIX AIO / io_uring) |
|---|
| | |
| | |
| | |
| | |
| | |
经典 epoll 风格是 non-blocking I/O + readiness notification,不是真正的异步。Linux 的 io_uring(5.1+)才是真异步。
9. 一个完整对比例子:echo server
9.1 阻塞版本(简单但每连接一线程)
void *handle(void *arg){ int conn = (intptr_t)arg; char buf[1024]; ssize_t n; while ((n = read(conn, buf, sizeof buf)) > 0) { write(conn, buf, n); } close(conn); return NULL;}intmain(void){ int srv = socket(AF_INET, SOCK_STREAM, 0); /* bind + listen ... */ while (1) { int conn = accept(srv, NULL, NULL); /* 阻塞 */ pthread_t tid; pthread_create(&tid, NULL, handle, (void*)(intptr_t)conn); pthread_detach(tid); }}
简单直观。但每连接一个线程,10K 连接 = 10K 线程 = 80MB 栈(默认 8MB / 线程,一些系统起码 8KB),调度开销也大。
9.2 非阻塞 + epoll 版本(C10K)
intmain(void){ int srv = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); /* bind + listen ... */ int epfd = epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev = { .events = EPOLLIN, .data.fd = srv }; epoll_ctl(epfd, EPOLL_CTL_ADD, srv, &ev); while (1) { struct epoll_event evs[64]; int n = epoll_wait(epfd, evs, 64, -1); for (int i = 0; i < n; i++) { int fd = evs[i].data.fd; if (fd == srv) { int conn; while ((conn = accept4(srv, NULL, NULL, SOCK_NONBLOCK)) >= 0) { ev.events = EPOLLIN | EPOLLET; ev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev); } } else { char buf[1024]; ssize_t r; while ((r = read(fd, buf, sizeof buf)) > 0) { /* 简单回写(生产代码要处理写不完的情况)*/ write(fd, buf, r); } if (r == 0 || (r < 0 && errno != EAGAIN)) { close(fd); } } } }}
单线程能扛 10K~100K 连接。代码复杂得多,要管 fd 状态、缓冲、超时等等。
10. 总结
经验法则:
- 简单工具 / 客户端 / 小服务
- 大并发服务
- 磁盘 IO 性能敏感