11、非阻塞I/O与异步I/O:fcntl设置非阻塞、非阻塞connect、异步I/O(AIO)、I/O模型对比总结

这一章我们来聊聊网络编程里一个绕不开的话题——I/O模型。说实话,很多新手甚至工作两三年的朋友,对阻塞、非阻塞、同步、异步这些概念还是有点模糊。我记得刚入行那会儿,面试官问我「epoll是同步还是异步」,我愣是答错了。嗯,今天咱们就把这块彻底理清楚。

11.1 阻塞与非阻塞:最直观的区别

先说说最基础的。你写一个recv()调用,如果socket上没有数据,程序就停在那等——这就是阻塞。反过来,如果没数据直接返回一个错误码,程序继续往下跑——这就是非阻塞。

说白了,阻塞就是「死等」,非阻塞就是「不等」。我在项目中遇到过一个问题:一个网关程序同时处理几百个连接,如果每个连接都用阻塞模式,那一个连接卡住,整个进程就卡死了。所以高并发场景下,非阻塞是必须的。

11.2 fcntl设置非阻塞

把一个socket变成非阻塞,最常用的方法就是fcntl。我个人习惯在创建socket之后立刻设置,省得后面忘了。

int set_nonblock(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) {
        return -1;
    }
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 使用示例
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (set_nonblock(sock) == -1) {
    perror("set nonblock failed");
    exit(1);
}

小提示:设置非阻塞时,一定要先F_GETFL拿到原来的标志位,再F_SETFL加上O_NONBLOCK。直接覆盖可能会丢掉其他重要的标志位。我曾经见过有人直接写fcntl(fd, F_SETFL, O_NONBLOCK),结果把socket的某些属性搞丢了,排查了半天。

11.3 非阻塞connect

这个知识点挺有意思。你想想看,如果connect()是阻塞的,那连接一个远端服务器可能要等几十秒超时。非阻塞connect就能解决这个问题——它立即返回,然后你通过select/poll/epoll去检查连接是否完成。

int nonblock_connect(const char *ip, int port) {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblock(sock);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    inet_pton(AF_INET, ip, &addr.sin_addr);

    int ret = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
    if (ret == 0) {
        // 连接立即成功(比如本地回环)
        return sock;
    }
    if (errno != EINPROGRESS) {
        // 真正的错误
        close(sock);
        return -1;
    }

    // 连接正在进行中,用 poll 等待
    struct pollfd pfd = {sock, POLLOUT, 0};
    ret = poll(&pfd, 1, 5000);  // 5秒超时
    if (ret <= 0) {
        close(sock);
        return -1;
    }

    // 检查连接是否成功
    int error = 0;
    socklen_t len = sizeof(error);
    getsockopt(sock, SOL_SOCKET, SO_ERROR, &error, &len);
    if (error != 0) {
        close(sock);
        return -1;
    }

    return sock;
}

注意:非阻塞connect返回EINPROGRESS是正常现象,不是错误。很多新手看到这个错误码就慌了,其实它只是告诉你「连接正在路上」。另外,检查连接是否成功要用getsockoptSO_ERROR,别直接用connect的返回值判断。

11.4 异步I/O(AIO)

说到异步I/O,很多人第一反应是Linux的AIO(aio_read/aio_write)。但说实话,我在实际项目中很少直接用POSIX AIO。为什么?因为它的实现方式比较重,而且对socket的支持并不完美。

Linux AIO更适合磁盘I/O,网络I/O还是用epoll更靠谱。不过了解一下也没坏处,万一哪天碰到特殊场景呢?

#include <aio.h>

// 异步读示例(仅供参考,实际网络编程不推荐)
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buffer;
cb.aio_nbytes = BUFFER_SIZE;
cb.aio_offset = 0;  // 对socket无效

if (aio_read(&cb) == -1) {
    perror("aio_read failed");
    return;
}

// 稍后检查完成状态
while (aio_error(&cb) == EINPROGRESS) {
    // 等待或做其他事情
}

ssize_t ret = aio_return(&cb);

核心观点:在Linux网络编程中,真正的「异步I/O」其实是epoll配合非阻塞socket。POSIX AIO更多是理论上的异步,实际工程中用的不多。我个人建议把精力放在epoll上,这才是Linux下高性能网络编程的基石。

11.5 I/O模型对比总结

好了,咱们把常见的I/O模型拉出来遛遛。我画了一张图,帮你理清它们之间的关系。

I/O模型对比 阻塞I/O 调用recv/send时,进程阻塞等待 内核数据准备好之前,啥也不干 优点:编程简单 缺点:一个连接占一个线程/进程 非阻塞I/O 调用立即返回,没数据返回EAGAIN 需要轮询检查数据是否就绪 优点:不阻塞进程 缺点:轮询浪费CPU I/O多路复用 select/poll/epoll 监听多个fd 内核通知哪些fd可读可写 优点:单线程处理大量连接 缺点:编程复杂度较高 异步I/O 发起操作后立即返回,内核完成 完成后通过信号或回调通知 优点:真正不阻塞 缺点:Linux网络场景支持有限 实际工程中:非阻塞socket + epoll(I/O多路复用)是Linux下最主流的高性能方案

这张图把四种模型放在一起对比。你可能会问:「非阻塞和异步到底啥区别?」嗯,关键区别在于「谁来做等待」。非阻塞是你自己轮询,异步是内核做完后通知你。

模型 是否阻塞 数据就绪通知 数据拷贝 典型API
阻塞I/O 无(阻塞等待) 进程自己拷贝 recv/send
非阻塞I/O 无(轮询) 进程自己拷贝 recv + O_NONBLOCK
I/O多路复用 内核通知就绪 进程自己拷贝 epoll_wait + recv
异步I/O 内核通知完成 内核完成拷贝 aio_read / io_uring

我的建议:如果你刚开始学网络编程,先把阻塞I/O和非阻塞I/O搞明白。然后重点攻克epoll(I/O多路复用)。至于异步I/O,了解一下概念就够了,等遇到具体需求再深入。我在项目中用了这么多年,99%的场景都是「非阻塞socket + epoll」搞定。

11.6 避坑指南

最后分享几个我踩过的坑:

  • 非阻塞connect的返回值判断:我曾经以为connect返回-1就是失败,结果忽略了EINPROGRESS。后来排查了好久才发现,原来非阻塞模式下这个错误码是正常的。
  • fcntl设置非阻塞后忘记检查:有一次我设置完非阻塞,后面直接调recv,没处理EAGAIN。结果程序在高并发下疯狂报错。记住:非阻塞模式下,recv返回-1且errnoEAGAINEWOULDBLOCK是正常现象,不是错误。
  • AIO的坑:我试过用POSIX AIO做网络编程,结果发现它对socket的支持很有限,而且性能还不如epoll。后来果断放弃了。如果你真的需要异步I/O,建议看看io_uring,这是Linux 5.1引入的新特性,比传统AIO靠谱得多。

好了,这一章的内容就到这里。非阻塞和异步的概念确实容易混淆,但只要你动手写几个demo,跑一跑,很快就能理解。记住:理论是基础,实践才是检验真理的唯一标准。

小练习:试着写一个程序,用非阻塞connect同时连接多个服务器,然后用poll等待所有连接完成。这个练习能帮你巩固本章的知识点。

公众号:蓝海资料掘金营,微信 deep3321