3、非阻塞I/O模型:非阻塞模式设置、非阻塞式Server实现、非阻塞式Client实现、忙等待问题
好,咱们今天来聊聊非阻塞 I/O 模型。说实话,这是很多新手从「玩具代码」迈向「生产级网络程序」的第一道坎。
阻塞模型很简单——你调一个 recv(),没数据就挂在那等。但现实世界不是这样的。你想想看,一个服务器要同时管几千个连接,每个都派一个线程去阻塞等着?那系统资源早就被吃光了。所以,非阻塞模式就派上用场了。
3.1 非阻塞模式的核心思想
非阻塞模式,说白了就是「不等待」。你调用 recv() 或 send() 时,如果数据没准备好,函数立刻返回一个错误码,而不是卡在那里。
我个人习惯把非阻塞 I/O 比作「轮询式服务」——你每隔一段时间去问一下:「数据到了吗?」「可以发了吗?」。虽然有点傻,但至少你不会被一个慢客户端拖死。
- 阻塞模式:系统调用等待事件发生,线程/进程休眠
- 非阻塞模式:系统调用立即返回,通过错误码告知「未就绪」
3.2 如何设置非阻塞模式
在 Linux 下,设置非阻塞有两种常见方式。我最早学的时候也搞混过,这里给你理清楚。
方式一:创建时指定
在 socket() 调用时直接带上 SOCK_NONBLOCK 标志:
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);
accept() 返回的,你没法在创建时指定。而且 fcntl 可以在运行中动态切换,调试时特别方便。
3.3 非阻塞式 Server 实现
写一个非阻塞的 Server,核心思路就是:所有 socket 都设成非阻塞,然后在一个循环里轮询它们。
来看一个简化版的例子。这个 Server 监听一个端口,接受新连接,然后在一个大循环里检查所有客户端是否有数据可读。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAX_CLIENTS 1024
#define BUFFER_SIZE 1024
int main() {
int listen_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
int client_fds[MAX_CLIENTS];
int max_fd = 0;
int i;
// 1. 创建监听 socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 2. 设置非阻塞
int flags = fcntl(listen_fd, F_GETFL, 0);
fcntl(listen_fd, F_SETFL, flags | O_NONBLOCK);
// 3. 绑定和监听
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8888);
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
listen(listen_fd, 10);
// 初始化客户端数组
for (i = 0; i < MAX_CLIENTS; i++) {
client_fds[i] = -1;
}
printf("Non-blocking server started on port 8888\n");
// 4. 主循环——轮询
while (1) {
// 尝试接受新连接
client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd >= 0) {
// 新连接来了,也设成非阻塞
flags = fcntl(client_fd, F_GETFL, 0);
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);
// 找个空位存起来
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == -1) {
client_fds[i] = client_fd;
break;
}
}
printf("New client connected, fd=%d\n", client_fd);
}
// 轮询所有客户端
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == -1) continue;
memset(buffer, 0, BUFFER_SIZE);
int n = recv(client_fds[i], buffer, BUFFER_SIZE - 1, 0);
if (n > 0) {
// 有数据,处理
printf("Received from fd %d: %s", client_fds[i], buffer);
send(client_fds[i], "OK\n", 3, 0);
} else if (n == 0) {
// 客户端关闭
printf("Client fd %d disconnected\n", client_fds[i]);
close(client_fds[i]);
client_fds[i] = -1;
} else {
// n < 0,检查错误码
if (errno != EAGAIN && errno != EWOULDBLOCK) {
// 真正的错误
perror("recv error");
close(client_fds[i]);
client_fds[i] = -1;
}
// 如果是 EAGAIN,说明没数据,继续下一轮
}
}
// 这里加个小延时,防止 CPU 跑满
usleep(1000); // 1ms
}
close(listen_fd);
return 0;
}
usleep(1000) 是我故意加的。如果不加,这个 while 循环会以最高速度空转,CPU 直接飙到 100%。我曾经在生产环境见过同事写的非阻塞代码没加延时,一台 64 核的机器 CPU 全部跑满,但实际处理的请求不到 100 个——这就是典型的「忙等待」问题。
3.4 非阻塞式 Client 实现
客户端也可以是非阻塞的。最常见的场景是:你想同时连接多个服务器,或者你在做 GUI 程序,不能因为网络 I/O 把界面卡死。
这里给一个非阻塞 connect 的例子。注意,非阻塞 connect 的返回值处理跟阻塞模式完全不同。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
int main() {
int fd;
struct sockaddr_in addr;
int ret;
fd_set write_fds;
struct timeval tv;
// 1. 创建 socket
fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 设置非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
// 3. 准备地址
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
// 4. 发起连接(非阻塞模式下立即返回)
ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if (ret < 0) {
if (errno == EINPROGRESS) {
// 连接正在进行中,这是正常情况
printf("Connection in progress...\n");
// 用 select 等待连接完成
FD_ZERO(&write_fds);
FD_SET(fd, &write_fds);
tv.tv_sec = 5; // 5秒超时
tv.tv_usec = 0;
ret = select(fd + 1, NULL, &write_fds, NULL, &tv);
if (ret <= 0) {
printf("Connection timeout or error\n");
close(fd);
return -1;
}
// 检查连接是否成功
int error = 0;
socklen_t len = sizeof(error);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &len);
if (error != 0) {
printf("Connection failed: %s\n", strerror(error));
close(fd);
return -1;
}
printf("Connected successfully!\n");
} else {
perror("connect");
close(fd);
return -1;
}
} else {
// 连接立即成功(比如连本地)
printf("Connected immediately!\n");
}
// 5. 收发数据(也设成非阻塞)
// ... 跟 server 端类似
close(fd);
return 0;
}
EINPROGRESS,以为 connect 返回 -1 就是失败,结果所有节点都被标记为「不可用」。后来排查了半天才发现,非阻塞 connect 返回 -1 且 errno 为 EINPROGRESS 时,恰恰表示连接正在建立,你得用 select() 或 poll() 去等它完成。
3.5 忙等待问题(Busy Waiting)
好,这是非阻塞模型里最坑的地方。咱们刚才的 Server 代码里,主循环一直在跑:
while (1) {
accept(...);
for (每个客户端) {
recv(...);
}
usleep(1000); // 不加这行就完蛋
}
如果不加 usleep(),这个循环会以 CPU 能跑多快就跑多快。在 3GHz 的 CPU 上,每秒可能执行上亿次空转。这就是忙等待。
忙等待的问题很明显:
- CPU 占用 100%,但实际没干正事
- 功耗飙升,笔记本风扇狂转
- 响应延迟反而变高,因为 CPU 被空转占满了,真正处理数据的代码反而抢不到时间片
| 特性 | 忙等待(非阻塞 + 无延时) | 阻塞等待 |
|---|---|---|
| CPU 占用 | 100% | 接近 0%(休眠) |
| 响应延迟 | 高(CPU 被空转占满) | 低(有事件才唤醒) |
| 并发能力 | 单线程可处理多连接 | 需要多线程/多进程 |
| 实现复杂度 | 低 | 中 |
那怎么解决忙等待?常见的思路有几种:
- 加延时:像上面那样
usleep(1000),牺牲一点响应速度换 CPU 空闲。但延时设多少是个问题——设大了响应慢,设小了 CPU 还是高。 - 用 I/O 多路复用:这就是下一章要讲的
select、poll、epoll。它们能让你「有事件才处理」,没有事件时内核让进程休眠,彻底解决忙等待。 - 混合模式:先用非阻塞做一轮快速检查,如果没数据就切到阻塞等待。但这种方式容易搞出奇怪的 bug,我不推荐新手尝试。
我个人建议:纯非阻塞 + 轮询的方式,只适合连接数很少(比如几十个)且对延迟不敏感的场景。一旦连接数上千,或者对响应时间有要求,赶紧上 I/O 多路复用。
3.6 本章小结
非阻塞 I/O 模型的核心就三件事:
- 设置非阻塞:
fcntl()加O_NONBLOCK标志 - 处理返回值:
recv()返回 -1 且 errno 为EAGAIN或EWOULDBLOCK时,表示没数据,不是错误 - 避免忙等待:要么加延时,要么用 I/O 多路复用
嗯,这里要注意一点:非阻塞模式本身不是银弹。它解决了「一个线程被一个连接拖死」的问题,但引入了「CPU 空转」的新问题。真正的解决方案,是下一章要讲的 I/O 多路复用——它结合了阻塞模型的「高效等待」和非阻塞模型的「单线程多连接」优点。
下面这张图帮你理清非阻塞 I/O 在整个网络编程知识体系中的位置:
好了,非阻塞 I/O 模型就讲到这里。代码你拿去跑一跑,重点体会一下 EAGAIN 和 EINPROGRESS 这两个错误码的处理——它们是区分「真错误」和「假错误」的关键。