編程(八):阻塞與非阻塞IO對比)
阻塞 vs 非阻塞 I/O —— 同一個 read 的兩種世界同樣調read(fd, buf, n)加不加O_NONBLOCK行為完全不一樣阻塞模式下沒數據就睡到天荒地老非阻塞模式下立即返回EAGAIN剩下的得自己 poll。這篇把阻塞與非阻塞的行為差異、各自適用場景、EAGAIN處理、跟事件循環(huán)的搭配講清楚。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);情況行為返回值有數據立刻返回實際字節(jié)數≤ 100無數據 有數據來進程睡眠→ 醒來讀到實際字節(jié)數無數據 對端關閉進程睡眠 → 醒來0EOF無數據 出錯信號、磁盤錯進程睡眠 → 醒來-1errno給原因阻塞期間進程在內核的等待隊列里睡不消耗 CPU。1.2 阻塞 write 的行為ssize_tnwrite(fd,buf,100);情況行為緩沖足夠寫完返回可能短寫要循環(huán)寫緩沖滿pipe / socket進程睡眠等到有空間再繼續(xù)寫1.3 阻塞 accept、connectintconnaccept(srv,NULL,NULL);connect(s,addr,sizeofaddr);調用阻塞條件醒來時機accept沒有新連接到達三次握手完成新 fd 出現(xiàn)在 backlog 隊列connect三次握手未完成收到 SYN-ACK成功/ 超時 / RST失敗1.4 阻塞模式的優(yōu)點/* 實現(xiàn)一個簡單的服務器只需要 */while(1){intconnaccept(srv,NULL,NULL);handle_request(conn);close(conn);}代碼線性、直觀——做完這一步再做下一步的思維。適合的場景場景典型例子單連接客戶端curl風格的 CLI 工具每連接一進程/線程的服務pre-forkApache prefork、per-thread簡單腳本和工具任何不追求并發(fā)的小程序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 字節(jié)無數據立刻返回 -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返回寫入字節(jié)數緩沖完全滿返回 -1errno EAGAIN不會阻塞等空間工程上 write 經常短寫非阻塞下要循環(huán) EAGAIN 檢測ssize_ttotal0;while(totaln){ssize_twwrite(fd,buftotal,n-total);if(w0){if(errnoEINTR)continue;if(errnoEAGAIN){/* 緩沖滿了先停下等可寫事件再繼續(xù) */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代碼風格線性、易讀事件驅動、狀態(tài)機一線程能服務的 fd 數1每個 fd 一個線程海量配合多路復用4. EAGAIN不是錯誤是稍后再試EAGAIN跟EWOULDBLOCK在 Linux 上是同一個值11有些 Unix 上不同。代碼里兩個都判斷更穩(wěn)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. 非阻塞 多路復用現(xiàn)代服務器的標配非阻塞本身沒有意義必須配合多路復用才發(fā)揮價值偽代碼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){/* 可讀循環(huán) 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 不一定再觸發(fā)取決于 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。循環(huán) 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 在兩種模式下都會發(fā)寫關閉的 socket / pipe 觸發(fā) SIGPIPE跟阻塞/非阻塞無關。忽略 SIGPIPE 永遠是對的signal(SIGPIPE,SIG_IGN);8. 異步 I/O跟非阻塞的區(qū)別很多人混淆非阻塞和異步。兩者完全不同維度非阻塞異步 (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 / 線程一些系統(tǒng)起碼 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 狀態(tài)、緩沖、超時等等。10. 總結選項阻塞 I/O非阻塞 I/O默認? 不設 O_NONBLOCK顯式 O_NONBLOCK編程模型同步線性事件驅動 / 狀態(tài)機等待方式內核讓進程睡用戶層用 epoll適合連接數小到中大到極大代碼復雜度低高學習曲線平陡經驗法則簡單工具 / 客戶端 / 小服務阻塞 多線程寫得快、跑得穩(wěn)大并發(fā)服務非阻塞 epollC10K 必備磁盤 IO 性能敏感考慮 io_uring 真正異步