linux系统编程(八):阻塞与非阻塞IO对比

📅 2026/8/12 18:04:47
linux系统编程(八):阻塞与非阻塞IO对比
阻塞 vs 非阻塞 I/O —— 同一个 read 的两种世界同样调read(fd, buf, n)加不加O_NONBLOCK行为完全不一样阻塞模式下没数据就睡到天荒地老非阻塞模式下立即返回EAGAIN剩下的得自己 poll。这篇把阻塞与非阻塞的行为差异、各自适用场景、EAGAIN处理、跟事件循环的搭配讲清楚。0. 引言read 没数据怎么办intfdopen(/dev/tty,O_RDONLY);/* 终端 */charbuf[100];ssize_tnread(fd,buf,100);/* 终端没人输入read 一直不返回进程卡死 */intfdopen(/dev/tty,O_RDONLY|O_NONBLOCK);charbuf[100];ssize_tnread(fd,buf,100);/* n -1, errno EAGAIN —— 立即返回 */默认是阻塞。加O_NONBLOCK就是非阻塞。这是 Linux I/O 编程的根本分水岭。1. 阻塞 I/O让内核帮你等1.1 阻塞 read 的行为ssize_tnread(fd,buf,100);情况行为返回值有数据立刻返回实际字节数≤ 100无数据 有数据来进程睡眠→ 醒来读到实际字节数无数据 对端关闭进程睡眠 → 醒来0EOF无数据 出错信号、磁盘错进程睡眠 → 醒来-1errno给原因阻塞期间进程在内核的等待队列里睡不消耗 CPU。1.2 阻塞 write 的行为ssize_tnwrite(fd,buf,100);情况行为缓冲足够写完返回可能短写要循环写缓冲满pipe / socket进程睡眠等到有空间再继续写1.3 阻塞 accept、connectintconnaccept(srv,NULL,NULL);connect(s,addr,sizeofaddr);调用阻塞条件醒来时机accept没有新连接到达三次握手完成新 fd 出现在 backlog 队列connect三次握手未完成收到 SYN-ACK成功/ 超时 / RST失败1.4 阻塞模式的优点/* 实现一个简单的服务器只需要 */while(1){intconnaccept(srv,NULL,NULL);handle_request(conn);close(conn);}代码线性、直观——做完这一步再做下一步的思维。适合的场景场景典型例子单连接客户端curl风格的 CLI 工具每连接一进程/线程的服务pre-forkApache prefork、per-thread简单脚本和工具任何不追求并发的小程序1.5 阻塞模式的局限一个进程/线程只能等一个 fd。要同时处理 1000 个连接要么 1000 个线程开销大要么改用非阻塞 多路复用。2. 非阻塞 I/O来不及就立即返回2.1 怎么开启两种方式/* 方式 1open 时设 */intfdopen(path,O_RDONLY|O_NONBLOCK);/* 方式 2fcntl 改 */intflagsfcntl(fd,F_GETFL);fcntl(fd,F_SETFL,flags|O_NONBLOCK);socket 也一样intssocket(AF_INET,SOCK_STREAM|SOCK_NONBLOCK,0);/* 一步到位 */2.2 非阻塞 read 的行为ssize_tnread(fd,buf,100);有数据立刻返回≤ 100 字节无数据立刻返回 -1errno EAGAINEWOULDBLOCK对端关闭返回 0出错返回 -1errno 不是 EAGAINssize_tnread(fd,buf,100);if(n0){if(errnoEAGAIN||errnoEWOULDBLOCK){/* 没数据不是错误稍后再试 */}elseif(errnoEINTR){/* 信号打断重试 */}else{/* 真的出错了 */perror(read);}}2.3 非阻塞 write 的行为ssize_tnwrite(fd,buf,1000);缓冲有空间写一部分可能短于 1000返回写入字节数缓冲完全满返回 -1errno EAGAIN不会阻塞等空间工程上 write 经常短写非阻塞下要循环 EAGAIN 检测ssize_ttotal0;while(totaln){ssize_twwrite(fd,buftotal,n-total);if(w0){if(errnoEINTR)continue;if(errnoEAGAIN){/* 缓冲满了先停下等可写事件再继续 */break;}return-1;}totalw;}returntotal;2.4 非阻塞 accept、connect/* 非阻塞 accept */intconnaccept(srv,NULL,NULL);if(conn0(errnoEAGAIN||errnoEWOULDBLOCK)){/* 暂时没新连接 */}/* 非阻塞 connect 比较特殊 */intrcconnect(s,addr,sizeofaddr);if(rc0errnoEINPROGRESS){/* 三次握手还在进行要 select/epoll 等可写事件 */}3. 阻塞 vs 非阻塞 行为对比行为阻塞非阻塞read 没数据进程睡眠等返回 -1, errnoEAGAINwrite 缓冲满进程睡眠等返回 -1 或部分写入accept 没连接睡眠等返回 -1, errnoEAGAINconnect 三次握手等到完成立刻返回errnoEINPROGRESSCPU 占用等的时候不占一样不占前提是配合 poll/epoll代码风格线性、易读事件驱动、状态机一线程能服务的 fd 数1每个 fd 一个线程海量配合多路复用4. EAGAIN不是错误是稍后再试EAGAIN跟EWOULDBLOCK在 Linux 上是同一个值11有些 Unix 上不同。代码里两个都判断更稳if(errnoEAGAIN||errnoEWOULDBLOCK){...}或者直接用宏#ifndefEWOULDBLOCK#defineEWOULDBLOCKEAGAIN#endif4.1 EAGAIN 的处理选项忙轮询busy poll立即重试。几乎从不该这么写浪费 100% CPU。while(1){ssize_tnread(fd,buf,100);if(n0)break;if(errno!EAGAIN){error;break;}/* spin again */}加 sleep 退避每次失败 sleep 几 ms。简单粗暴延迟大。用多路复用select/poll/epoll等可读事件再读。唯一推荐的方式。5. 非阻塞 多路复用现代服务器的标配非阻塞本身没有意义必须配合多路复用才发挥价值伪代码intepfdepoll_create1(EPOLL_CLOEXEC);/* 注册若干非阻塞 fd 到 epfd */while(1){structepoll_eventevs[64];intnepoll_wait(epfd,evs,64,-1);for(inti0;in;i){intfdevs[i].data.fd;if(evs[i].eventsEPOLLIN){/* 可读循环 read 直到 EAGAIN */while(1){ssize_trread(fd,buf,sizeofbuf);if(r0)handle(fd,buf,r);elseif(r0){close(fd);break;}elseif(errnoEAGAIN)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 */intflagsfcntl(fd,F_GETFL);fcntl(fd,F_SETFL,flags|O_NONBLOCK);7.2 dup 出来的 fd 共享 flagsO_NONBLOCK是 file 级的不是 fd 级所以同一个 file 的所有 fd 行为一致intfd1open(...);intfd2dup(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_tnread(fd,buf,100);/* n 可能是 50不是 100 */阻塞 read 等到有任何数据就返回不一定填满 buf。循环 read 是必须的这一点跟非阻塞一样。7.5 connect 非阻塞返回 EINPROGRESS 不是错误intrcconnect(s,addr,sizeofaddr);if(rc0){/* 立即成功罕见本机 unix socket 可能*/}elseif(errnoEINPROGRESS){/* 三次握手在进行select/epoll 等可写事件再用 SO_ERROR 取结果 */interr;socklen_tlensizeoferr;getsockopt(s,SOL_SOCKET,SO_ERROR,err,len);if(err0){/* 连接成功 */}else{/* 连接失败err 是真正的错误码 */}}7.6 SIGPIPE 在两种模式下都会发写关闭的 socket / pipe 触发 SIGPIPE跟阻塞/非阻塞无关。忽略 SIGPIPE 永远是对的signal(SIGPIPE,SIG_IGN);8. 异步 I/O跟非阻塞的区别很多人混淆非阻塞和异步。两者完全不同维度非阻塞异步 (POSIX AIO / io_uring)调用立即返回成功或 EAGAIN立即返回请求已提交数据搬运你自己 read/write内核帮你做完成通知配合 epoll 你自己 read完成事件通知信号/cqe模型“Reactor”“Proactor”适用网络 I/O 主流磁盘 I/Oio_uring 也用于网络经典 epoll 风格是non-blocking I/O readiness notification不是真正的异步。Linux 的io_uring5.1才是真异步。9. 一个完整对比例子echo server9.1 阻塞版本简单但每连接一线程void*handle(void*arg){intconn(intptr_t)arg;charbuf[1024];ssize_tn;while((nread(conn,buf,sizeofbuf))0){write(conn,buf,n);}close(conn);returnNULL;}intmain(void){intsrvsocket(AF_INET,SOCK_STREAM,0);/* bind listen ... */while(1){intconnaccept(srv,NULL,NULL);/* 阻塞 */pthread_ttid;pthread_create(tid,NULL,handle,(void*)(intptr_t)conn);pthread_detach(tid);}}简单直观。但每连接一个线程10K 连接 10K 线程 80MB 栈默认 8MB / 线程一些系统起码 8KB调度开销也大。9.2 非阻塞 epoll 版本C10Kintmain(void){intsrvsocket(AF_INET,SOCK_STREAM|SOCK_NONBLOCK,0);/* bind listen ... */intepfdepoll_create1(EPOLL_CLOEXEC);structepoll_eventev{.eventsEPOLLIN,.data.fdsrv};epoll_ctl(epfd,EPOLL_CTL_ADD,srv,ev);while(1){structepoll_eventevs[64];intnepoll_wait(epfd,evs,64,-1);for(inti0;in;i){intfdevs[i].data.fd;if(fdsrv){intconn;while((connaccept4(srv,NULL,NULL,SOCK_NONBLOCK))0){ev.eventsEPOLLIN|EPOLLET;ev.data.fdconn;epoll_ctl(epfd,EPOLL_CTL_ADD,conn,ev);}}else{charbuf[1024];ssize_tr;while((rread(fd,buf,sizeofbuf))0){/* 简单回写生产代码要处理写不完的情况*/write(fd,buf,r);}if(r0||(r0errno!EAGAIN)){close(fd);}}}}}单线程能扛 10K~100K 连接。代码复杂得多要管 fd 状态、缓冲、超时等等。10. 总结选项阻塞 I/O非阻塞 I/O默认✅ 不设 O_NONBLOCK显式 O_NONBLOCK编程模型同步线性事件驱动 / 状态机等待方式内核让进程睡用户层用 epoll适合连接数小到中大到极大代码复杂度低高学习曲线平陡经验法则简单工具 / 客户端 / 小服务阻塞 多线程写得快、跑得稳大并发服务非阻塞 epollC10K 必备磁盘 IO 性能敏感考虑 io_uring 真正异步