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);
我的习惯: 我一般用第二种方式。因为很多时候 socket 是 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;
}
避坑指南: 我曾经在写一个分布式系统的健康检查模块时,用非阻塞 connect 去探测多个节点的存活状态。一开始没处理 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 被空转占满了,真正处理数据的代码反而抢不到时间片
忙等待 vs 阻塞等待:
特性 忙等待(非阻塞 + 无延时) 阻塞等待
CPU 占用 100% 接近 0%(休眠)
响应延迟 高(CPU 被空转占满) 低(有事件才唤醒)
并发能力 单线程可处理多连接 需要多线程/多进程
实现复杂度 低 中

那怎么解决忙等待?常见的思路有几种:

  1. 加延时:像上面那样 usleep(1000),牺牲一点响应速度换 CPU 空闲。但延时设多少是个问题——设大了响应慢,设小了 CPU 还是高。
  2. 用 I/O 多路复用:这就是下一章要讲的 select、poll、epoll。它们能让你「有事件才处理」,没有事件时内核让进程休眠,彻底解决忙等待。
  3. 混合模式:先用非阻塞做一轮快速检查,如果没数据就切到阻塞等待。但这种方式容易搞出奇怪的 bug,我不推荐新手尝试。

我个人建议:纯非阻塞 + 轮询的方式,只适合连接数很少(比如几十个)且对延迟不敏感的场景。一旦连接数上千,或者对响应时间有要求,赶紧上 I/O 多路复用。

3.6 本章小结

非阻塞 I/O 模型的核心就三件事:

  • 设置非阻塞:fcntl() 加 O_NONBLOCK 标志
  • 处理返回值:recv() 返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK 时,表示没数据,不是错误
  • 避免忙等待:要么加延时,要么用 I/O 多路复用

嗯,这里要注意一点:非阻塞模式本身不是银弹。它解决了「一个线程被一个连接拖死」的问题,但引入了「CPU 空转」的新问题。真正的解决方案,是下一章要讲的 I/O 多路复用——它结合了阻塞模型的「高效等待」和非阻塞模型的「单线程多连接」优点。

下面这张图帮你理清非阻塞 I/O 在整个网络编程知识体系中的位置:

非阻塞 I/O 模型知识体系 非阻塞 I/O 模型 设置非阻塞模式 socket() + SOCK_NONBLOCK fcntl() + O_NONBLOCK 核心实现要点 recv() 返回 EAGAIN 处理 connect() + EINPROGRESS accept() 非阻塞处理 忙等待问题 CPU 100% 空转 解决方案:加延时 终极方案:I/O 多路复用 非阻塞 I/O 适合连接数少、对延迟不敏感的场景 连接数多时建议使用 I/O 多路复用(select/poll/epoll)

好了,非阻塞 I/O 模型就讲到这里。代码你拿去跑一跑,重点体会一下 EAGAIN 和 EINPROGRESS 这两个错误码的处理——它们是区分「真错误」和「假错误」的关键。