阻塞与非阻塞IO:从“等”到“问”的思维转变
大家好,我是你们的老朋友。今天咱们来聊聊IO模型里最基础、也最绕不开的两个概念——阻塞和非阻塞。
很多新手写网络程序,上来就是 recv()、send(),跑起来好像也没啥问题。但一旦并发上来,或者网络环境变差,程序就卡死、超时、甚至崩溃。为什么?说白了,就是没搞懂“阻塞”到底在阻塞什么。
我个人习惯把IO模型比作“等人”。阻塞就是你在车站死等,对方不来你哪也去不了;非阻塞就是你每隔几秒发条问一句“到了没”,然后该干嘛干嘛。今天咱们就把这两种模型掰开揉碎,再顺带看看更高级的“多路复用”是怎么一回事。
阻塞IO模型:简单,但代价高昂
阻塞IO,是默认的IO模式。你调用 read() 或 recv() 时,如果内核缓冲区里没有数据,线程就会挂起——也就是“睡过去”,直到数据到达才被唤醒。
核心特征: 一次系统调用 = 一次完整的等待 + 数据拷贝。
流程大致是这样:
- 应用进程调用
recvfrom(),进入内核态。 - 内核等待数据准备好(比如网卡收到包)。
- 数据从内核缓冲区拷贝到用户缓冲区。
- 函数返回,进程继续执行。
听起来很合理对吧?但问题在于:整个进程/线程在等待期间啥也干不了。如果你用阻塞IO处理1000个连接,就得开1000个线程。线程切换的开销、内存的占用,很快就会把服务器压垮。
我曾经接手过一个老项目,每个客户端连接都开一个线程,用阻塞IO读写。高峰期2000个连接,服务器直接OOM。后来一查,光是线程栈就占了2GB内存。这就是阻塞IO最典型的坑——你以为它简单,其实它在偷偷消耗你的系统资源。
非阻塞IO模型:不等待,但需要“轮询”
非阻塞IO,就是告诉内核:“别让我等,如果没有数据,立刻告诉我,我去干别的。”
调用 recv() 时,如果数据没准备好,函数不会阻塞,而是立即返回一个错误码——通常是 EAGAIN 或 EWOULDBLOCK。你的程序可以继续做其他事情,过一会儿再来问一次。
嗯,这里要注意:非阻塞IO并没有减少“等待数据”这个事实,它只是把“内核帮你等”变成了“你自己主动问”。
fcntl 与 socket.setblocking:两种设置方式
怎么把一个socket设为非阻塞?两种主流方式:
1. C语言:fcntl 系统调用
// 获取当前文件描述符标志
int flags = fcntl(sockfd, F_GETFL, 0);
// 设置非阻塞标志
flags |= O_NONBLOCK;
fcntl(sockfd, F_SETFL, flags);
你想想看,fcntl 其实是个万能工具箱。除了设置非阻塞,还能改文件锁、设置异步通知等等。但咱们现在只关心 O_NONBLOCK 这个标志位。
2. Python:setblocking 方法
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False) # 等价于 fcntl 设置 O_NONBLOCK
Python 封装得比较友好,一行搞定。但底层调用的还是 fcntl。
我建议初学者先用Python理解非阻塞的概念,因为代码更直观。但生产环境的高并发服务,C/C++还是主流,因为你能精确控制每一个系统调用的开销。
非阻塞IO的“轮询”困境
非阻塞IO解决了“线程卡死”的问题,但它带来了新问题:你怎么知道什么时候该去问?
最简单的做法是:用一个 while 循环,不断调用 recv(),直到返回成功。这就是所谓的“忙轮询”(busy polling)。
while True:
data = sock.recv(1024)
if data:
process(data)
break
# 没数据,继续循环
# 但这样会占满CPU!
忙轮询的CPU利用率接近100%,因为循环里没有任何等待。你想想看,如果1000个连接都这么搞,CPU直接冒烟。
所以,非阻塞IO必须配合某种“通知机制”来使用——也就是告诉内核:“当这个socket有数据时,你通知我一声。” 这就引出了IO多路复用。
select / poll / epoll:从“问”到“被通知”
多路复用的核心思想是:一个线程可以同时监视多个文件描述符,内核在某个描述符就绪时通知应用程序。
这三兄弟是Linux下最常用的多路复用机制。我按出现顺序给大家捋一捋:
| 机制 | 底层结构 | 最大连接数 | 效率 | 典型场景 |
|---|---|---|---|---|
| select | 位图(fd_set) | 1024(FD_SETSIZE) | O(n),每次都要遍历全部fd | 兼容旧代码,连接数少 |
| poll | 链表(pollfd数组) | 无上限(受内存限制) | O(n),但避免了位图拷贝 | 中等规模连接 |
| epoll | 红黑树 + 就绪链表 | 无上限 | O(1),只返回就绪的fd | 高并发、大流量 |
我在项目中遇到过很多次,有人用select处理几千个连接,结果每次调用都卡好几毫秒。为什么?因为select每次都要把整个fd_set从用户态拷贝到内核态,内核再遍历一遍,找到就绪的fd再拷贝回来。连接数一多,这个拷贝和遍历的开销就非常可观。
而epoll就聪明多了:它用epoll_ctl注册fd,用epoll_wait等待事件。内核只把“就绪”的fd返回给你,不需要遍历全部。说白了,epoll是“有备而来”,select是“临时抱佛脚”。
核心区别一句话: select/poll 是“主动遍历所有fd”,epoll 是“被动接收就绪fd”。
一张图看懂三种模型
下面这张SVG图,把阻塞、非阻塞、多路复用的核心流程画出来了。我建议你仔细看看箭头走向,理解“谁在等”、“谁在问”、“谁在通知”。
实际开发中的选择建议
说了这么多理论,到底怎么选?我根据自己的经验,给几个实用建议:
- 连接数 < 100,且对延迟不敏感:用阻塞IO + 多线程,简单够用。但注意线程池大小要控制好。
- 连接数 100 ~ 1000:用poll,或者用封装好的事件库(如libevent)。poll比select好在没有1024限制。
- 连接数 > 1000,尤其是 > 10000:必须上epoll(Linux)或kqueue(FreeBSD/macOS)。这是高并发服务器的标配。
我个人习惯是:新项目一律用epoll(Linux环境)。因为select和poll的O(n)遍历在连接数上来后就是定时炸弹。而且epoll的ET(边缘触发)模式配合非阻塞IO,能实现最高效的事件处理——这个我们后面章节会细讲。
避坑指南:非阻塞IO的常见陷阱
最后,分享几个我踩过的坑:
- 忘记处理 EAGAIN:非阻塞模式下,
recv()返回 -1 不一定是出错,要检查 errno 是不是EAGAIN或EWOULDBLOCK。我曾经见过有人直接当错误处理,导致连接被无故关闭。 - 忙轮询不 sleep:如果非阻塞IO没有配合多路复用,纯用循环轮询,CPU会跑到100%。至少加个
usleep(1000)让出CPU时间片。 - select 的 fd_set 被修改:select 返回后,fd_set 会被内核修改,只保留就绪的fd。下次调用前必须重新设置。很多人忘了这一点,导致select永远返回0。