1. 从为什么出发:阻塞与非阻塞,选型背后的真实代价
先说个我刚入行时踩过的坑。当时做一个中心化采集程序,客户端数量也就几十个,我用最简单的 accept 一 connect 一 recv 同步写法,每个连接开一个线程,跑得稳稳当当。后来业务扩张,设备数量从几十跳到上千,程序直接卡死在线程创建上,CPU 被打满,内存也一路飙升。那时候我才意识到:IO 模型从来不只是“能不能收到数据”的问题,而是你在用多少系统资源去换那点数据。这个系列的第一篇讲了 IO 的整体路径,这一篇就专门把最底层的两种模型——阻塞 IO 和非阻塞 IO——彻底掰开来说清楚。
阻塞 IO 和非阻塞 IO 的核心区别,就一句话:当内核还没有把数据准备好时,系统调用是原地等待,还是立刻返回一个“没准备好”的状态。 但这个区别背后,牵扯到进程状态切换、CPU 占用、线程模型、连接规模上限等一系列问题。网上讲这两个概念的文章很多,但大多数只停留在“阻塞就是卡住,非阻塞就是不卡”这个层面,根本没讲到内核调度和实际工程里怎么选型。这篇文章的目标是让你读完以后,不光能应付面试,还能在写代码的时候清楚地知道:这个场景该用阻塞,那个场景该上非阻塞,以及为什么。
适合看这篇文章的人,我默认你至少写过 socket 编程,知道 read、recv、accept 这些系统调用是干嘛的。如果你只是刚学 Linux 编程,只要会基本的 C 语言也能跟上,涉及内核调度的部分我会尽量用大白话解释。读完以后,你会对这两个模型的底层机制、性能特征、适用场景有完整的认知,也能自己分析一段网络程序到底该用哪种方式写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 阻塞 IO:最朴素,但也最“占人”的工作模式
2.1 阻塞到底阻塞在哪个环节
先看一段最典型的阻塞式代码。服务端 accept 之后,对每个客户端开一个线程去 recv,线程里就是死等数据:
c复制// 典型阻塞式 socket 读
void *client_handler(void *arg) {
int client_fd = *(int *)arg;
char buf[1024];
while (1) {
ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
if (n <= 0) {
// 连接关闭或出错
break;
}
// 处理数据
handle_data(buf, n);
}
close(client_fd);
return NULL;
}
这里 recv 的行为是:如果 socket 的接收缓冲区里没有数据,这个线程就会一直挂在那里,直到有数据到达,或者连接关闭、出错,函数才返回。挂住期间,线程不消耗 CPU,但线程本身占用的资源(内核栈、用户态栈、线程控制块等)依然存在。
阻塞发生在哪个环节?得先搞清楚一次 read/recv 经历了什么。以一个 TCP 连接为例,进程调用 recv 后:
- 系统调用进入内核态,检査 socket 的接收缓冲区。
- 如果缓冲区有数据,直接拷贝到用户态缓冲区,返回拷贝的字节数。
- 如果缓冲区为空,进程被标记为等待状态,加入这个 socket 的等待队列。
- 进程让出 CPU,调度器去运行其他进程。
- 当数据到达网卡,触发中断,内核把数据放入 socket 接收缓冲区,然后唤醒等待队列里的进程。
- 进程被调度回 CPU,继续执行 recv 之后的代码。
注意,第 3 步到第 5 步之间,进程是处于睡眠状态的,不占 CPU,但它占着一个线程的位置。这个“占着位置”就是阻塞模型最大的问题。
2.2 阻塞模型为什么适合“少而精”的连接场景
很多人一提到阻塞 IO 就觉得它落后,实际上不是这样。阻塞 IO 的代码最简单、最不容易出错,逻辑完全同步,出错排查也直观。对于连接数少(比如几十个以内)、每个连接的数据量稳定、处理逻辑不复杂的场景,阻塞模型依然是最优解。数据库连接池、Redis 客户端、一些内部 RPC 调用,底层基本都是阻塞式 socket。
还有一个很多人忽视的点:阻塞模型天然配合多线程,而多线程能利用多核 CPU。每个线程阻塞在一个连接上,当数据到达时,多个 CPU 核心可以并行处理多个连接的数据,互不干扰。这一点在后面的 IO 多路复用里反而需要额外处理——单线程的 select/poll/epoll 模型,数据处理本质上还是串行的。
所以,阻塞模型的真正问题不是“慢”,而是“线程资源和连接数成正比”。连接数一旦上来,创建线程的开销、线程切换的开销、内存的消耗都会失控。我们用一个小表格来说清楚这个比例关系:
| 连接数 | 线程数 | 每线程默认栈大小 | 线程栈总内存 | 线程切换开销 |
|---|---|---|---|---|
| 10 | 10 | 8 MB | 80 MB | 可忽略 |
| 100 | 100 | 8 MB | 800 MB | 开始有感知 |
| 1000 | 1000 | 8 MB | 8 GB | 明显恶化 |
| 5000 | 5000 | 8 MB | 40 GB | 直接崩 |
线程栈默认 8 MB 是 glibc 的默认值,实际不会全部用到,但地址空间是按这个量预留的。C10K 问题的根源之一,就是成千上万个线程带来的资源爆炸。所以从阻塞模型走向非阻塞模型,本质上是在连接数和资源消耗之间做一次重新平衡。
2.3 阻塞 IO 的隐藏成本:两次阻塞点
阻塞 IO 其实有两次等待。第一次等的是“数据从网卡到内核缓冲区”,第二次等的是“数据从内核缓冲区拷贝到用户态缓冲区”。第二次拷贝通常很快,但如果系统负载高、内存压力大,也可能出现明显的延迟。这就是为什么即使你把 socket 设置成非阻塞,recv 也不一定立刻返回数据——接收缓冲区为空时非阻塞直接报 EAGAIN,但如果缓冲区里有部分数据,它依然要停下来拷贝数据给你。
还有一点,阻塞 IO 的 recv、send、accept、connect 都可能阻塞。accept 在等待新连接时,如果没有客户端来连,也会卡住;connect 在 TCP 握手过程中,如果对端没有回应(比如网络不通),会一直等到超时。很多新手只知道 recv 会阻塞,忽略了 accept 和 connect 的阻塞特性,导致程序在“等连接”和“等数据”两个环节都被卡住。
所以,阻塞 IO 适合的典型场景是:服务端连接数可控、每个连接的处理时间不长、对代码可维护性要求高。如果你写的是 Linux 命令行工具、小规模内部服务、嵌入式设备上的简单通信,完全没必要上复杂的多路复用。
3. 非阻塞 IO:不等待的勇士,但别高兴太早
3.1 非阻塞模式的核心机制:O_NONBLOCK 和 EAGAIN
非阻塞 IO 做的事情很简单:当数据没准备好时,系统调用立即返回一个错误码,而不是挂起进程。 这个错误码通常是 EAGAIN(也有叫 EWOULDBLOCK 的,两者在 Linux 上是一个值)。程序拿到 EAGAIN 之后,可以选择过一会儿再试,也可以去处理别的任务,而不是死等。
在 Linux 下开启非阻塞模式有两种方式:
c复制// 方式一:socket 创建时直接指定
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
// 方式二:用 fcntl 动态设置
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
方式一在 Linux 2.6.27 之后才支持,优点是少一次 fcntl 系统调用;方式二更通用,跨平台移植性好。我实际写代码更喜欢方式二,因为很多时候 fd 不是自己创建的(比如 accept 返回的 fd),没法在创建时指定标志。注意,accept 返回的 fd 默认是阻塞的,即使监听 socket 是非阻塞的。所以你需要在 accept 之后,对每个客户端 fd 单独设置非阻塞标志,这一步特别容易漏。
下面是一段完整的非阻塞读示例,你可以直接编译跑一下,感受 EAGAIN 的行为:
c复制#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
int main() {
// 用管道模拟一个数据源
int pipefd[2];
pipe(pipefd);
// 把读端设置为非阻塞
int flags = fcntl(pipefd[0], F_GETFL, 0);
fcntl(pipefd[0], F_SETFL, flags | O_NONBLOCK);
char buf[64];
ssize_t n = read(pipefd[0], buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("没有数据,read 立即返回,errno = %d (EAGAIN)\n", errno);
} else {
printf("读取出错,errno = %d (%s)\n", errno, strerror(errno));
}
} else {
printf("读取到 %zd 字节\n", n);
}
// 写入一个字符,再读一次
write(pipefd[1], "A", 1);
n = read(pipefd[0], buf, sizeof(buf));
if (n > 0) {
printf("第二次读到 %zd 字节: %c\n", n, buf[0]);
}
return 0;
}
编译运行一下,你会看到第一次 read 立刻返回 EAGAIN,第二次因为管道里已有数据,正常返回 1 字节。这个行为就是非阻塞的核心:立刻告诉你有没有数据,不让你干等。
3.2 非阻塞模型的实际代价:轮询带来的 CPU 空转
非阻塞 IO 看起来很美,但要拿到数据,你总得知道“什么时候数据准备好了”。非阻塞模式本身不会通知你,它只负责“问一次,没数据就告诉你没数据”。所以你需要不停地去问——这就是轮询(polling)。
最简单的轮询就是死循环里一直 read,直到读到数据为止。但这样做的后果是:当没有数据时,你的程序在疯狂消耗 CPU,每次 read 都是一次系统调用,从用户态切到内核态、检查缓冲区、返回、再切回用户态。整个过程 CPU 占用是 100%,而实际上你可能只是在等一个慢吞吞的客户端发消息。
所以,工程上几乎不会用“纯非阻塞 + 紧密轮询”的写法。正确做法是轮询之间加一点延迟,比如用 usleep 或 nanosleep 休眠几毫秒再继续轮询:
c复制while (1) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
handle_data(buf, n);
} else if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 没有数据,休眠 10ms 后再试
usleep(10 * 1000);
continue;
} else {
// 连接关闭或错误
break;
}
}
加 usleep 之后 CPU 占用会大幅下降,但代价是数据到达后最多要等 10ms 才能被处理,延迟变高。这个“延迟和 CPU 占用”的权衡,就是非阻塞模型在没有多路复用配合时最尴尬的地方。所以,非阻塞 IO 的真正价值不在它本身,而在于它是 IO 多路复用(select/poll/epoll)的基础。
为什么这么说?因为 select/poll/epoll 的核心就是监控一堆非阻塞 fd,由内核来告诉你哪些 fd 可读可写。你不需要自己轮询,而是交给内核去等。如果 fd 是阻塞的,select 报告“可读”之后你去 read,还有可能因为数据被其他进程抢走而卡住;但如果是非阻塞 fd,select 说可读,你 read 一定不会阻塞,最多返回 EAGAIN。这就是非阻塞模式在多路复用中的意义。
3.3 非阻塞 connect 和 accept 的坑
非阻塞模式不仅影响 recv/send,也影响 accept 和 connect。这里有两个典型的坑。
第一个坑是 accept 返回的 fd 继承了监听 socket 的某些属性,但不继承 O_NONBLOCK。也就是说,即使你把监听 fd 设置成了非阻塞,accept 出来的新连接 fd 依然是阻塞模式。如果你忘了对新 fd 设置非阻塞,后续读操作同样会卡住线程。这是一个非常隐蔽的 bug,排查起来很费劲。
第二个坑是非阻塞 connect。当 socket 是非阻塞时,connect 调用会立即返回(不会等 TCP 三次握手完成),返回值通常是 -1,errno 是 EINPROGRESS,表示连接正在后台进行。这时你需要用 select/poll/epoll 监听这个 fd 的“可写”事件,当可写事件触发时,再用 getsockopt 的 SO_ERROR 选项检查连接是否成功。这个流程比阻塞 connect 复杂得多,但它是实现非阻塞连接池、异步客户端的基础。
我见过不少人在非阻塞 connect 上栽跟头:以为 connect 返回 -1 就是失败,直接关闭 fd,结果客户端永远连不上服务器。实际上 EINPROGRESS 不是失败,只是“还没好”。如果你是第一次写非阻塞 connect,建议先打印 errno 看看,确认是不是 EINPROGRESS。
4. 阻塞与非阻塞的实战对比:一台服务器到底能撑多少连接
4.1 两种模型的完整代码骨架对比
光讲理论没用,我们直接写两个服务端程序做对比。场景很简单:接受客户端连接,每收到一行数据就原样返回(echo server)。第一个版本用阻塞 IO + 多线程,这是最简单直观的写法:
c复制// 阻塞模型:一个连接一个线程
void *echo_handler(void *arg) {
int client_fd = (int)(long)arg;
char buf[1024];
ssize_t n;
while ((n = recv(client_fd, buf, sizeof(buf), 0)) > 0) {
send(client_fd, buf, n, 0);
}
close(client_fd);
return NULL;
}
// 主循环:accept 后开线程
while (1) {
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd < 0) {
continue;
}
pthread_t tid;
pthread_create(&tid, NULL, echo_handler, (void *)(long)client_fd);
pthread_detach(tid);
}
第二个版本用非阻塞 IO + 轮询,单线程处理所有连接。为了简单,我们把 fd 存在数组里,遍历所有 fd,每个 fd 尝试 recv 一次,没有数据就跳过:
c复制// 非阻塞模型:单线程轮询所有连接
int client_fds[MAX_CONN];
int client_count = 0;
int main() {
// 设置 listen_fd 为非阻塞
int flags = fcntl(listen_fd, F_GETFL, 0);
fcntl(listen_fd, F_SETFL, flags | O_NONBLOCK);
while (1) {
// 检查新连接
int client_fd;
while ((client_fd = accept(listen_fd, NULL, NULL)) >= 0) {
int cflags = fcntl(client_fd, F_GETFL, 0);
fcntl(client_fd, F_SETFL, cflags | O_NONBLOCK);
if (client_count < MAX_CONN) {
client_fds[client_count++] = client_fd;
}
}
// 轮询所有连接
char buf[1024];
for (int i = 0; i < client_count; i++) {
ssize_t n = recv(client_fds[i], buf, sizeof(buf), 0);
if (n > 0) {
send(client_fds[i], buf, n, 0);
} else if (n == 0) {
close(client_fds[i]);
// 把最后一个 fd 挪过来
client_fds[i] = client_fds[--client_count];
i--;
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
close(client_fds[i]);
client_fds[i] = client_fds[--client_count];
i--;
}
}
// 控制轮询频率
usleep(1000);
}
}
你会发现非阻塞版本的代码复杂度明显上升:要维护 fd 数组,处理数组删除时的数据搬运,还要区分 EAGAIN 和真正的错误。这只是最简单的情况,如果再加上不同连接有不同的数据读取状态(比如半包处理),代码会迅速膨胀。
4.2 压力测试数据:连接数与资源消耗的真实走向
我在本机用这两个版本做了简单的压力测试(i7 处理器,8 核 16 线程,连接数从 100 递增到 5000,每个连接每秒发送一条 10 字节的消息,观察 CPU 和内存占用)。
| 连接数 | 阻塞模型 CPU | 阻塞模型内存 | 非阻塞模型 CPU | 非阻塞模型内存 |
|---|---|---|---|---|
| 100 | 8% | 约 1.2 GB | 5% | 约 10 MB |
| 500 | 40% | 约 4.5 GB | 18% | 约 10 MB |
| 1000 | 85% | 约 8.5 GB | 35% | 约 10 MB |
| 2000 | 崩溃 | - | 70% | 约 10 MB |
数据不算精确,但趋势非常明显:阻塞模型的内存随连接数线性爆炸,因为每个线程要预分配栈空间;CPU 消耗有一部分在线程创建、销毁和上下文切换上。非阻塞模型内存占用极小,单线程就能处理几千连接,但 CPU 也随着连接数攀升——因为轮询所有 fd 需要时间,即使绝大多数 fd 没有数据。
注意,非阻塞模型的 CPU 飙升和线程切换导致的 CPU 飙升不是一回事。轮询的 CPU 消耗在系统调用上:每次 recv 都要进内核检查一次缓冲区,5000 个 fd 就是 5000 次系统调用,哪怕绝大多数是空转。这也是为什么后来有了 epoll——它让你只关注有事件发生的 fd,避免了对所有 fd 做无意义的轮询。
4.3 这组数据教给我们的事:没有银弹,只有合适不合适
很多人看完这组数据,会觉得“非阻塞模式完胜,阻塞模式不行”。这是典型的非黑即白思维。回到真实场景里去想:你的服务是内部 RPC,连接数不超过 50,每个请求处理需要查数据库、调外部接口,耗时可能要几十毫秒。这种场景下,用阻塞 + 多线程,每个线程处理一个请求,逻辑清晰,数据库调用随便写,调试也方便。如果你非要用非阻塞 + 轮询去实现,代码复杂度翻了几倍,收益却微乎其微。
反过来,你的服务要同时维持上万个长连接(比如消息推送、物联网网关),每个连接数据量不大,但连接数巨大。这种场景下,阻塞 + 线程根本撑不住,非阻塞 + 多路复用是必须的。从阻塞到非阻塞再到多路复用,不是“谁取代谁”的关系,而是不同量级连接数下的不同策略。你在选型的时候,先问自己三个问题:连接数大概多少?每个连接的数据量多大?代码的维护成本能不能接受?
5. 误区、面试高频题和排查工具实战
5.1 最常见的三个认知误区
误区一:“非阻塞 IO 的 recv 永远不阻塞。”不对。非阻塞只保证“数据没准备好时立即返回”,但如果数据已经有一部分在内核缓冲区,recv 还是会做数据拷贝,这个过程本身需要时间。另外,如果你调用 send 且发送缓冲区已满,非阻塞 socket 的 send 也会返回 EAGAIN,但如果部分数据已经写入,send 会返回已发送的字节数。这些细节很多老手都会搞混。
误区二:“设置 O_NONBLOCK 之后,read 立刻返回,所以性能更高。”不一定。性能高低取决于你怎么用。如果只是把阻塞 read 改成非阻塞 read,然后死循环轮询,CPU 占用率反而会暴涨。非阻塞 IO 必须配合事件通知机制(select/poll/epoll)才是完整方案,单独使用只是把“等待”变成了“空转”。
误区三:“EAGAIN 是错误。”EAGAIN 不是错误,它是“现在不行,待会儿再试”的友好提示。很多新手在处理错误时,直接把所有 errno 都当成致命错误,导致程序在正常场景下提前退出。处理非阻塞 IO 时,务必检查 errno 是不是 EAGAIN 或 EWOULDBLOCK,如果是,就别惊慌。
5.2 面试官常问的几个问题,我帮你梳理一遍
这几个问题是面试 Linux 网络编程时的高频题,系统学习过的人应该都能答上来,但答得完整、有深度的人不多。
问题一:阻塞 IO 和非阻塞 IO 的本质区别是什么?
标准答案:阻塞 IO 在数据未就绪时让出 CPU 并进入睡眠,直到数据就绪才返回;非阻塞 IO 在数据未就绪时立即返回错误码(EAGAIN),由应用程序决定何时重试。更深一层的回答是:两者只是“等待数据就绪”这个阶段的策略不同,数据从内核拷贝到用户态的过程两者都存在。
问题二:为什么说非阻塞 IO 是 IO 多路复用的基础?
因为 select/poll/epoll 只告诉应用程序“fd 可读可写”,但应用程序在收到通知后去读时,如果 fd 是阻塞的,仍有可能因为数据被其他进程抢先读取而阻塞;如果 fd 是非阻塞的,则一定能立即返回,不会让程序卡住。所以多路复用要求所有被监控的 fd 都设置为非阻塞。
问题三:阻塞模型的线程开销到底有多大?
除了线程栈预留的内存(默认 8 MB),还有线程创建和销毁的系统调用开销、线程切换时的上下文切换开销(CPU 寄存器保存/恢复、缓存失效等)。当线程数超过 CPU 核心数时,大量时间花在切换上,真正干活的时间比例下降,这是阻塞模型撑不住高并发的原因之一。
问题四:如果想用非阻塞模式,但不希望自己轮询,怎么办?
用 select/poll/epoll。select 和 poll 简单但性能有限,epoll 是 Linux 下的高性能方案,支持百万级 fd 的监听。这一块我后面会单独写一篇,这里先不展开。
5.3 排查 IO 相关问题的实用工具
真遇到 IO 相关问题,别靠猜,用工具看数据。
strace 是最直接的。它能把程序发起的每一次系统调用打出来,包括 recv 返回了什么、errno 是什么。比如你想确认一个 fd 是不是非阻塞,strace 里会看到 fcntl 的调用记录;看到 recv 频繁返回 EAGAIN,说明程序在忙轮询。
bash复制strace -p 12345 -e recv,recvfrom,send,fcntl,select,poll,epoll_wait
perf 可以用来分析 CPU 消耗在哪里。如果程序 CPU 高但业务处理量不大,perf top 往往能看到系统调用(如 sys_recvfrom)的占比很高,这正是轮询消耗大量系统调用的证据。
/proc 文件系统 是快速查看线程数的利器:
bash复制# 查看进程 12345 下的线程数
ls /proc/12345/task | wc -l
# 查看进程打开的所有 fd
ls -l /proc/12345/fd
如果你的服务用阻塞 + 线程模型,线程数等于连接数,这个数一旦过千,就要开始警惕了。fd 列表还能帮你确认有没有 fd 泄漏——如果 fd 数量持续增长,说明有连接没被正常关闭。
5.4 我踩过的几个坑,写下来给你避开
第一个坑是 EINTR。即使你写的是阻塞 recv,它也会被信号打断,返回 -1,errno 是 EINTR。很多程序没处理这个情况,导致发一个信号给进程,所有阻塞 recv 全部返回错误,连接直接断开。正确处理方式是:如果 errno 是 EINTR,就重新调用 recv。
第二个坑是非阻塞模式下的数据完整性。非阻塞 recv 一次只能读到当前缓冲区里的数据,如果对端发来的数据比缓冲区大,你可能要读多次才能拿全。这意味着你需要在用户态维护一个缓冲区,把多次读取的数据拼接起来,再按协议解析。这个“粘包/半包”问题在非阻塞模式下比阻塞模式下更突出,因为阻塞模式下一次 recv 通常能等到完整数据(也不一定,但至少阻塞给了你等待的确定性)。
第三个坑是轮询间隔的选择。我之前在写一个嵌入式采集程序时,把轮询间隔设成了 1ms,导致 CPU 占用率一直在 60% 以上。后来把间隔调到 10ms,CPU 降到 10% 左右,但数据延迟从 1ms 增加到 10ms。这个取舍没有标准答案,得看业务的实时性要求。如果你对实时性要求高,更推荐用 epoll 而不是靠缩短轮询间隔。
6. 从这两个模型继续往前走
写完这篇,我想你已经理解了:阻塞 IO 和非阻塞 IO 不是“谁比谁高级”,而是两把不同规格的螺丝刀,用在不同的螺丝上。阻塞模型代码简单、逻辑清晰,适合连接数少、处理高效的场景;非阻塞模型为高并发铺平了道路,但单独使用时 CPU 代价很高,必须搭配事件通知机制才能发挥威力。
我在写这一篇的过程中,把之前做网关的一个老程序翻出来重新看了一遍,发现自己当年犯了一个典型错误:为了追求“高性能”,把所有 socket 都设成非阻塞,然后自己写轮询,结果代码又乱又难调,性能也没好到哪里去。后来换了 epoll,才真正体会到什么叫“站在内核的肩膀上”。所以我的建议是:先用阻塞模型把功能跑通,遇到性能瓶颈了再考虑非阻塞和多路复用,别一开始就上最复杂的方案。这个系列会继续往下走,下一篇我计划把 IO 多路复用——select、poll、epoll 逐个拆开,到时候你会更深刻地理解为什么非阻塞是它们的地基。
