你在写网络服务时,有没有碰到过这种场景:客户端明明没发数据,服务端进程却像睡着了一样,CPU 占用 0%,top 里状态显示为 S(sleeping)。可一旦你把这个逻辑改成非阻塞 IO,程序又开始像打了鸡血一样死循环转圈,CPU 瞬间飙到 100%,业务吞吐量却一点没涨。这两个极端,恰恰就是阻塞 IO 和非阻塞 IO 最直观的体感差异。
这篇是这个 Linux IO 模型系列的第二篇,上一篇我们把整个 IO 模型的地图扫了一遍,从阻塞、非阻塞讲到了多路复用、信号驱动和异步 IO。今天只聚焦最开始的两个:阻塞型 IO 和它的反义词——非阻塞 IO。这个话题看着基础,但很多人对它的理解其实停留在“阻塞就是卡住不返回,非阻塞就是立刻返回”这个层面。至于内核里到底发生了什么、什么时候该用阻塞、非阻塞之后为什么引出了多路复用、真正写代码时 EAGAIN 和 EINTR 要怎么区分,这些才是今天要聊透的东西。
文章会从 read()/recv() 的系统调用行为讲起,然后给出可以直接复现的 Python/C 代码,再往内核里看一眼等待队列和接收队列是怎么协作的,最后把实战中最容易踩的坑和排查思路一并交底。适合刚接触 Linux 网络编程的初学者,也适合那些写了好几年业务代码、但想搞清楚底层原理的后端开发。
1. 先理清概念:阻塞与非阻塞到底阻塞了什么
1.1 从 read() 系统调用看阻塞的本质
要理解阻塞 IO,最简单的切入点就是 read()。如果 fd 是一个 socket,当你调用 read() 去读数据时,用户态的进程会通过系统调用陷入内核。Linux 会先检查这个 socket 的接收缓冲区里有没有数据:
- 有数据:直接把数据拷贝到用户态缓冲区,返回拷贝的字节数。
- 没有数据:进入阻塞逻辑,当前进程被放到这个 socket 的等待队列上,然后让出 CPU,进入睡眠状态。
这个“睡眠”不是空转,不是忙等,是操作系统主动把 CPU 让给其他就绪任务。用大白话说,就像你去食堂打饭,发现窗口还没出菜,你干脆找个位置坐下等着,不做任何事,等师傅喊你再去端。这就是阻塞 IO 的全部含义:数据不就绪,调用不返回,进程让出 CPU,等内核来唤醒。
很多人第一次接触这个概念时会觉得“阻塞不好”,其实从 CPU 利用率来讲,阻塞 IO 非常高效。因为它不浪费 CPU 时间片,进程该睡就睡。代价是“一个线程/进程在同一时刻只能等一个 fd”,如果每个客户端连接都占一个线程,并发一大,线程数量就会爆炸,这才是阻塞模型最大的痛点。
写 C 代码来看更直观:
c复制#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(9000);
bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(listen_fd, 32);
int conn_fd = accept(listen_fd, NULL, NULL);
char buf[1024];
ssize_t n = read(conn_fd, buf, sizeof(buf)); // 这里就是阻塞点
if (n > 0) {
write(STDOUT_FILENO, buf, n);
}
close(conn_fd);
close(listen_fd);
return 0;
}
注意代码里那一行 read(conn_fd, buf, sizeof(buf)),如果你用 nc 127.0.0.1 9000 连上去但不发数据,这个进程就会一直睡在 read 这里。阻塞点就在这一行系统调用上。
1.2 非阻塞 IO 是怎么打开的
非阻塞 IO 的本质是:当数据不具备“立即操作”的条件时,系统调用不进入睡眠,而是直接返回一个错误码。我们常说这个 fd 被设置成了“非阻塞模式”,在 Linux 下有几种常见做法:
open()时传入O_NONBLOCK标志,例如打开设备文件、管道时。- 对已经打开的 fd 调用
fcntl(fd, F_SETFL, flags | O_NONBLOCK)。 - 创建 socket 时在
socket()第 2 个参数里带SOCK_NONBLOCK,例如socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)。 - 服务端
accept()时用accept4(),同样可以带SOCK_NONBLOCK标志。
有一点必须提前说明,也是新手最容易三观尽碎的:如果你对一个普通文件的 fd 设置 O_NONBLOCK,绝大多数情况下并不会让 read() 返回 EAGAIN。因为普通文件(磁盘上的文件)在内核眼里永远是“可读可写”的,数据不存在“还没准备好”的状态。真正从 O_NONBLOCK 中受益的是 socket、管道、设备文件这类“数据到达时间不确定”的对象。
如果只想快速验证,用 Python 写更简单。socket.setblocking(False) 一句话就能把 socket 切到非阻塞模式:
python复制import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setblocking(False)
s.connect_ex(('127.0.0.1', 9000))
try:
data = s.recv(1024)
print(data)
except BlockingIOError as e:
print(f"没有数据,返回了 {e.errno}") # 常见 11: EAGAIN
1.3 一次 read() 的返回值到底几种含义
不管阻塞还是非阻塞,read() 在 socket 上的返回值只有几类。不过日常开发里,很多人只会处理“返回 n > 0 是读到数据”,然后下意识忽略负数和 0,这个习惯早晚要出事。把返回值含义列清楚,后面排查问题会省很多事:
| 返回值 | 含义 | 处理建议 |
|---|---|---|
| n > 0 | 成功读到 n 字节数据 | 正常消费数据即可,注意 n 可能小于请求的长度 |
| n == 0 | 对端关闭了连接,不会再读到数据 | 关闭 fd,清理连接资源 |
| -1, errno == EAGAIN / EWOULDBLOCK | 非阻塞模式下,当前没有数据可读 | 不要当错误处理,继续等或去干别的 |
| -1, errno == EINTR | 系统调用被信号中断,没读到数据 | 一般应立即重试 read() |
| -1, errno == ECONNRESET | TCP 连接被对端重置(RST) | 关闭连接,按异常链路处理 |
| -1, 其他 errno | 真实 IO 错误 | 打日志,关闭 fd |
这里要提醒一下:在 Linux 上 EAGAIN 和 EWOULDBLOCK 的值是相同的(都是 11),所以在实际编程时这两种写法等价,但建议统一用 EAGAIN,代码可读性更好。
对 socket 而言,read() 和 recv() 在没有特殊 flag 时行为一致;同理 write() 和 send() 也一样。这篇文章后面讲 read() 的地方,对你调 recv() 一样适用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 亲手跑一遍:两种 IO 的直观对比
2.1 环境准备
光看不练是学不会 IO 模型的,强烈建议自己动手把下面几个实验跑一遍。环境要求极低,手头任意 Linux 发行版即可,虚拟机、云服务器、容器都行,甚至 WSL 也可以,只要能跑 Python 3 就行。不需要专门去下一个最小系统镜像,也不必刻意准备干净的物理机,这些都是多出来的动作。
为什么不用装任何第三方依赖?因为网络编程最底层的就是 socket,Python 标准库自带了。我会提供两个服务端脚本、两个客户端脚本,你复制粘贴到本地,开两个终端就能观察。
2.2 实验一:阻塞 IO 服务端表现
先写一个最简单的阻塞版回显服务端:
python复制# server_block.py
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 9000))
server.listen(32)
print("阻塞式服务端已启动: 127.0.0.1:9000")
while True:
conn, addr = server.accept() # 阻塞点 1:等待新连接
print(f"客户端 {addr} 已连接")
while True:
data = conn.recv(1024) # 阻塞点 2:等待数据
if not data:
print(f"客户端 {addr} 断开")
break
conn.sendall(data) # 回显
# 注意:这段代码一次只能服务一个客户端
启动它之后,用 nc 127.0.0.1 9000 连上去。如果 nc 没装,用 apt install netcat-openbsd 或者用下面这个 Python 客户端:
python复制# client.py
import socket
import time
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 9000))
while True:
msg = input("输入要发送的内容(空行退出): ")
if not msg:
break
s.sendall(msg.encode())
resp = s.recv(1024)
print("收到:", resp.decode())
s.close()
此时在另一个终端执行 ps -ef | grep server_block 找到服务端进程 PID,然后 top -p <PID> 观察:
- 进程状态:
S(sleeping),因为阻塞在 accept() 或 recv() 上。 - CPU 占用:接近于 0,因为进程没有空转,直接把 CPU 让出去了。
- RSS 内存:基本不增长。
这个实验告诉我们一个反直觉的事实:阻塞 IO 在“单连接空转”时几乎不消耗任何资源。它的问题只在并发量上来之后才暴露:如果你需要同时支持 1000 个客户端,就得有 1000 个线程或进程。
顺手看一下系统调用最可靠。停止这个服务端,用 strace 重新启动:
bash复制strace -f -e trace=network,read,write ./server_block.py 2>&1 | grep -E "accept|recvfrom|read"
你会看到当没有连接时,系统调用停在 accept(4, ... 这一行不返回;有连接而没数据时,停在 recvfrom(5, ...。这个“停”就是内核把进程挂到等待队列上的外部表现。
2.3 实验二:非阻塞 IO 服务端表现
再来一个非阻塞版服务端,注意,这个版本故意写得“天真”,只为了演示非阻塞模式下的真实体感:
python复制# server_nonblock.py
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 9001))
server.listen(32)
server.setblocking(False) # 关键:设置非阻塞
print("非阻塞式服务端已启动: 127.0.0.1:9001")
conns = []
while True:
try:
conn, addr = server.accept()
conn.setblocking(False)
conns.append(conn)
print(f"客户端 {addr} 已连接, 当前连接数 {len(conns)}")
except BlockingIOError:
# 没有新连接,正常,继续走下面的逻辑
pass
for conn in conns[:]:
try:
data = conn.recv(1024)
if not data:
conn.close()
conns.remove(conn)
print("一个客户端断开")
else:
conn.sendall(data)
except BlockingIOError:
# 这个连接暂时没数据,跳过
pass
这个版本你跑起来会看到非常典型的现象:
- 服务端 CPU 占用会飙升,可能到 100%,尤其当没有任何客户端连接时。因为
accept()立即返回BlockingIOError,紧接着 for 循环又一次次recv()返回BlockingIOError,整个 while 在疯狂空转。 - 虽然 CPU 忙得不行,但业务吞吐量为 0。这就是“忙等”的代价。
- 进程状态从
S变成了R(running),因为它在不停执行用户态代码。
为了证明这不是代码问题,而是非阻塞模式本身的特点,你可以在 except BlockingIOError 分支里加一个 time.sleep(0.05),再观察 CPU。加了 sleep 之后 CPU 会降下去,但那本质上已经不是纯 IO 逻辑了,是拿时间片换了 CPU 利用率。只有引入了多路复用(select/poll/epoll)之后,才能真正做到“有事件才唤醒”,这也是下一篇文章的核心。
2.4 两种模式的差异对比
把上面两组实验整理成一张表,方便写代码时快速回顾:
| 观察维度 | 阻塞 IO | 非阻塞 IO |
|---|---|---|
| 数据未就绪时 read() | 调用挂起,进程进入睡眠 | 立即返回,errno 置为 EAGAIN |
| CPU 占用(空闲时) | 极低(睡眠) | 可能很高(忙等) |
| 进程状态 | S(sleeping) | R(running) |
| 一个任务可管理的连接数 | 通常 1 个 | 理论上多个,但不配合多路复用很吃力 |
| 编程复杂度 | 低 | 中,必须处理 EAGAIN |
| 典型场景 | 简单工具、脚本、串口程序 | 配合 epoll 的高性能服务端前置条件 |
这里有个误区必须纠正:非阻塞 IO 本身的直接价值,不是省 CPU,而是让“等待数据”不再占用线程/进程资源。只有把它和事件循环、多路复用放在一起,才能发挥真正价值。单纯把 fd 设成非阻塞然后 while 轮询,反而是最差的写法。
3. 内核视角:阻塞与非阻塞的底层机制
3.1 接收队列与等待队列是怎么协作的
想在面试或者实际问题里把阻塞 IO 讲透,光靠“卡住/不卡住”是不够的,内核里发生的两件事才关键:数据如何从网卡到 socket 接收队列,以及进程怎么被挂起和唤醒。
当一个 TCP 数据包到达网卡后,经过中断处理、软中断协议栈,最终进入对应 socket 的接收队列。这个队列里的元素是 sk_buff,也就是内核数据包结构。对用户态来说,你调 read() 读数据,本质就是把 sk_buff 里的数据拷贝到用户缓冲区。
阻塞 IO 的关键在第三步:当进程调用 read() 发现接收队列为空时,内核不会直接返回,而是执行类似下面这样的逻辑:
c复制// 伪代码,实际实现更复杂
static int sleep_on_socket(struct sock *sk) {
DEFINE_WAIT(wait);
add_wait_queue(sk->sk_waitq, &wait); // 进程挂到等待队列
while (skb_queue_empty(&sk->sk_receive_queue)) {
prepare_to_wait(sk->sk_waitq, &wait, TASK_INTERRUPTIBLE);
if (signal_pending(current))
break; // 有信号再退出
schedule(); // 让出 CPU,进入睡眠
}
remove_wait_queue(sk->sk_waitq, &wait);
}
重点理解两点:
add_wait_queue+prepare_to_wait是“注册”,告诉内核:当这个 socket 有数据时,请唤醒我。schedule()是真正的让出 CPU 操作。进程在这里被标记为TASK_INTERRUPTIBLE,并放入等待队列。之后如果有数据包到达,TCP 处理路径会调用sk_data_ready()之类的回调,进而wake_up_interruptible(sk->sk_waitq),把这个进程唤醒。被唤醒后,它会重新循环检查接收队列是否为空,有数据则拷贝返回,没数据且没有信号,就继续睡。
所以阻塞 IO 并不是“把线程卡死在这里不动”,而是“把线程从 CPU 运行队列挪到了 socket 等待队列”,CPU 的时间片被释放给别人。这也是为什么带阻塞 IO 的服务端在没有请求时,看起来像死掉一样安静,但整个系统依然流畅运行。
3.2 非阻塞 IO 为什么能立刻返回
非阻塞 IO 的逻辑比阻塞版本简单得多。内核在 read() 的某个分支会判断 file->f_flags 是否带 O_NONBLOCK,如果带了,那么当接收队列为空时,不再进入等待队列,直接返回 -EAGAIN。说直白一点:不注册,不等待,不给通知,看一眼没有就撤。
这个设计的代价和收益都很明显:
收益是调用方线程不会被挂住,可以继续执行其他任务。非阻塞的 read() 完全可以出现在一个事件循环中被反复调用,这也是所有高性能网络框架的基础能力。
代价是如果调用方没有别的 fd 要等、没有其他任务要做,它就只能一次次空转调用 read(),每一次调用都是一次用户态到内核态的切换,成本比普通函数调用高一个数量级。假设你每秒轮询一个非阻塞 fd 一万次,那就是一万次系统调用,虽然每次都很快,但积少成多,CPU 就这么烧没了。
绕了一大圈,你可能会想到:如果有一组 fd 都没有数据,能不能让内核统一帮我们等,哪个有数据就告诉哪个?这就是 select/poll/epoll 做的事。所以阻塞 IO 是“我给你等”,非阻塞 IO 是“我自己等、但不睡觉”,多路复用是“我让内核帮我一组一组地等”。下一篇会专门展开讲,但你现在理解非阻塞的忙等痛点,再去看 epoll,会顺很多。
3.3 容易被忽略的细节:O_NONBLOCK 的作用范围不是 fd
这是一个在面试中经常出现、在代码里也很容易埋雷的点。O_NONBLOCK 这个状态标志,严格来说不属于“文件描述符表项”,而是属于“打开文件描述(open file description)”。
进程的 fd 表里,每个 fd 指向一个 file 结构体;而 O_NONBLOCK 保存在这个 file 结构体里。这就带来了一个影响很大的连锁效应:
- 当你调用
fcntl(fd, F_SETFL, flags | O_NONBLOCK)修改状态后,同一个进程内通过dup()、dup2()复制出来的 fd,或者 fork 出的子进程继承的 fd,指向的是同一个 file 结构体,因此会共享非阻塞状态。 - 也就是说,子进程在不知情的情况下继承了父进程设置的 O_NONBLOCK,可能导致子进程里的 read() 行为从“阻塞”悄然变成“返回 EAGAIN”,这也是一类难以排查的诡异问题。
一个常见的坑是:父进程先创建 socket、设置 O_NONBLOCK,再 fork 出多个子进程处理连接。子进程明明没主动改模式,却发现自己所有 read() 都不阻塞了。反过来说,如果只在子进程里设置 O_NONBLOCK,父进程看到的同样也是非阻塞,因为大家共享同一个 file 结构体。
要避免这类问题,最好的习惯是:每个进程在自己需要的位置显式设置/清理 O_NONBLOCK,不要依赖继承;如果是自己 fork 的服务,尽量在 fork 之后立刻重新检查 fd 状态。
4. 实战避坑与问题排查经验
4.1 不会选型:阻塞和非阻塞在真实项目中的定位
这几年高性能网络编程讲得太多,不少新手产生了一种错觉:只要把 fd 设成非阻塞,程序就“高性能”了。实际上并非如此。选型的时候要结合业务特性和并发模型,我的经验大致可以按下面这个表去判断:
| 业务场景 | 推荐模式 | 说明 |
|---|---|---|
| 写一个一次性脚本/命令行工具 | 阻塞 IO | 简单直接,不死磕性能 |
| 串口/管道/设备文件读取 | 阻塞 IO | 数据到达不频繁,等待时睡眠省资源 |
| 少量连接(几十)的客户端 SDK | 非阻塞 + 轮询 | 可以避免多线程,但要注意退避 |
| 大量连接(成千上万)服务端 | 非阻塞 + epoll 事件循环 | 唯一能撑住规模的方式 |
| 调用外部接口/数据库的同步业务 | 阻塞 IO | 但建议放到线程池里,避免阻塞主线程 |
在大多数中后台业务里,阻塞 IO + 线程池依然是实用选择。每个线程处理一个请求,代码直白、调试方便、心智负担小。只有当连接数远超线程数时才需要考虑非阻塞 + 多路复用。早期的 Apache 主要就是每连接一个进程,处理几百并发就吃力;而 Nginx 用非阻塞 + epoll,可以单进程扛住上万连接。这不是阻塞 IO 本身不行,而是模型的应用区间不同。
4.2 三大返回错误:EINTR、EAGAIN、EINPROGRESS 怎么区分
非阻塞编程里,让人头疼的不是数据读写,而是几个 return 值和 errno。这里我按实际频繁程度排个序:
EAGAIN / EWOULDBLOCK
非阻塞 fd 上没有数据可读,或发送缓冲区已满无法写入时,read()/write() 会返回 -1,errno 设置成 EAGAIN。这是“暂时做不了,以后说不定能做”,不是致命错误。典型错误写法是把它当异常打 error 日志,日志刷屏之后服务直接降速。正确做法是正常跳过,等下一次事件通知。
EINTR
系统调用被信号中断。比如你给进程发了 SIGTERM、SIGUSR1,或者后台有定时信号触达,阻塞中的系统调用可能被打断返回 -1,errno 为 EINTR。处理方式也很简单:在业务允许的范围内重试。像这样:
c复制// 处理 EINTR 的安全 read 写法
ssize_t n;
do {
n = read(fd, buf, len);
} while (n < 0 && errno == EINTR);
注意:如果 read() 在被信号打断前已经读取了部分数据,返回值 n 是正数,不会设置 EINTR,所以你在处理 n > 0 的路径里不用纠结。
EINPROGRESS
多半出现在非阻塞 connect() 上。TCP 三次握手需要时间,普通 connect() 会阻塞到完成;非阻塞 connect() 会立刻返回 -1,errno 为 EINPROGRESS,表示“连接正在尝试中”。这个不能当成错误,而应该用 select/poll 监听该 fd 的可写事件,然后调用 getsockopt(SO_ERROR) 确认最终结果:
c复制int err = 0;
socklen_t len = sizeof(err);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len);
if (err == 0) {
// 连接成功
} else {
// 连接失败,err 是对应的错误码
}
这个点也是网络编程面试的高频题,区别 EAGAIN 和 EINPROGRESS 时抓住一句话就行:EAGAIN 是在“数据没就绪”的场景,EINPROGRESS 是在“操作已经开始、需要等待完成”的场景。
4.3 用工具快速判断进程到底阻塞在哪
不管是自己写的服务,还是在排查线上问题,下面三个工具组合基本能覆盖大部分场景。
top / ps
top -p <pid> 看进程状态。状态为 S 表示阻塞在睡眠中,R 表示正在运行。如果你发现一个网络服务进程 R 状态且 CPU 占用高,但业务 QPS 没上涨,大概率是某项轮询逻辑在空转,常见的就是非阻塞 fd 上的忙等。
strace
strace -p <pid> -c 能统计进程在一段时间内各系统调用的次数和耗时。如果看到大量 recvfrom(... = -1 EAGAIN),说明事件循环没有退避,一直在无效轮询;如果看到进程长期停在 accept() 或 read() 上,那说明它是正常的阻塞等待,不是故障。
我曾经排查过一个代理服务 CPU 占用 100% 的问题。用 strace 一看,进程在循环调用 recvmsg,每次返回 EAGAIN 后立刻重试,中间没有任何 sleep 或者事件等待。原因就是代码里把 fd 设成非阻塞后,又没用 epoll 来等事件,而是写了 while (true) 硬转。这种情况加个 select 或者最少加个 sleep,CPU 立刻降下来。
/proc/
这两个文件能打印内核栈和当前等待的内核函数名。例如:
bash复制cat /proc/1234/wchan
# 输出类似于 sk_wait_data 或 tcp_recvmsg
看到 sk_wait_data,说明进程正阻塞在 socket 接收队列的等待队列上。这个信息比 top 的 S 状态精确得多,能直接告诉你进程睡在哪个内核函数里。
再分享一个项目实战案例:某服务端程序在高峰期出现大面积请求超时,但进程 CPU 不高,top 里都是 S 状态。我第一反应是“都在正常阻塞等数据”,后来 strace 后发现大量 write 阻塞在发送缓冲区上,因为客户端消费能力不够,TCP 滑动窗口变小,导致服务端 write() 无法写入。这种场景下,阻塞 IO 的病根往往不在“读”,而在“写”——接收缓冲区满了会阻塞 read,发送缓冲区满了会阻塞 write,两者都要考虑。
最后想说的
阻塞和非阻塞这对概念,我每年都会在不同场合重新讲一遍,因为它虽然基础,却直接决定了后面理解多路复用、异步 IO 的地基牢不牢。回头看这篇文章,核心其实就三句话:阻塞 IO 让进程睡眠等待、不占 CPU;非阻塞 IO 让调用立即返回、但处理 EAGAIN 是必修课;两者在内核里的差别只在于要不要进入等待队列。
根据我个人的学习经验,这篇文章里的实验代码,你至少要亲手跑完前两个服务端脚本,体会一下 S 状态和 R 状态的差异,再去看 epoll 的文档,会有种豁然开朗的感觉。下一篇会接着聊多路复用,也就是 select、poll、epoll 到底解决了什么问题、它们内部又是怎么实现的。到时候你会发现,今天纠结的非阻塞忙等和阻塞占线程这两大痛点,正是多路复用登上舞台的全部理由。
