19、非阻塞I/O:fcntl()设置非阻塞、非阻塞connect、非阻塞accept

各位好,今天我们来聊聊网络编程里一个绕不开的话题——非阻塞I/O。

说实话,我刚开始写网络程序那会儿,用的全是阻塞模式。那时候觉得,代码一行一行往下走,多清晰啊。直到有一次,我写一个聊天服务器,客户端一多,某个连接卡住了,整个服务器都跟着卡死。嗯,从那以后,我就开始认真研究非阻塞了。

为什么需要非阻塞?

阻塞模式有个很要命的问题:它会让你的程序“等”。

  • 阻塞accept:如果没有新连接,程序就停在那里不动了。
  • 阻塞connect:如果对端响应慢,你的程序就卡在connect调用上。
  • 阻塞read/write:数据没到,或者缓冲区满了,线程就挂起了。

你想想看,一个服务器要同时处理成千上万个连接,如果每个连接都这么等,那得开多少线程?线程多了,上下文切换的开销就上来了,性能反而下降。

非阻塞I/O的核心思想就是:“我不管你有没有数据,我先回来,你准备好了再通知我。”

fcntl():设置非阻塞的瑞士军刀

在Linux下,把一个文件描述符变成非阻塞,最常用的方法就是fcntl()。我个人习惯用下面这个函数模板:

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);
}

这段代码做了两件事:

  1. F_GETFL拿到当前的文件状态标志。
  2. F_SETFL加上O_NONBLOCK位。

为什么要先F_GETFLF_SETFL?直接设置不行吗?

嗯,这里有个坑。如果你直接fcntl(fd, F_SETFL, O_NONBLOCK),会把其他标志位给覆盖掉。我曾经在项目里吃过这个亏,把一个socket的某些系统标志给冲掉了,结果行为变得很奇怪。所以,先读再写,是个好习惯

注意:fcntl() 返回 -1 时,要检查 errno。常见的错误有 EBADF(文件描述符无效)和 EACCES(权限不足)。别问我怎么知道的,调试了一下午才找到原因。

非阻塞connect:别让程序卡在握手上

connect() 在阻塞模式下,会一直等到三次握手完成。如果对端网络不通,或者防火墙丢包,你的程序就卡死了。

非阻塞connect的流程是这样的:

int sock = socket(AF_INET, SOCK_STREAM, 0);
set_nonblock(sock);

struct sockaddr_in addr;
// ... 填充 addr ...

int ret = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
if (ret == 0) {
    // 连接立即成功(比如本机回环)
    printf("连接立即成功\n");
} else if (ret == -1 && errno == EINPROGRESS) {
    // 连接正在进行中
    printf("连接正在建立,稍后检查...\n");
    // 用 select/poll/epoll 监听可写事件
} else {
    // 连接失败
    perror("connect");
}

关键点在于:errno == EINPROGRESS。这不是错误,而是告诉你“连接还没完成,但我不等了,你先去干别的”。

之后,你需要用select()poll()epoll来监听这个socket的可写事件。当可写事件触发时,再检查getsockopt()SO_ERROR选项,确认连接是否真的成功了。

我的经验:非阻塞connect在写高并发客户端时特别有用。比如你要同时连接1000台服务器,用阻塞模式得串行等,用非阻塞模式可以一口气全发出去,然后统一检查结果。我曾经用这个技巧把一个批量检测工具的性能提升了10倍。

非阻塞accept:别让新连接堵住大门

accept() 在阻塞模式下,如果没有新连接,就会一直等。这在单线程事件循环里是个大问题——你等accept的时候,其他已连接的socket就没人管了。

非阻塞accept的写法很简单:

int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen ...
set_nonblock(listen_fd);

while (1) {
    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);
    int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
    
    if (conn_fd == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 没有新连接,继续干别的
            break;
        } else {
            perror("accept");
            break;
        }
    }
    
    // 处理新连接
    set_nonblock(conn_fd);  // 新连接也设为非阻塞
    // ... 加入事件循环 ...
}

注意这里的EAGAINEWOULDBLOCK。在Linux上,这两个值是一样的,但为了可移植性,最好两个都检查。

核心要点:非阻塞accept配合事件循环(select/poll/epoll),才能发挥真正的威力。监听socket的可读事件触发时,说明有新连接来了,这时候再去accept,基本不会返回EAGAIN。

知识体系总览

下面这张图,是我自己整理的非阻塞I/O核心逻辑。你看一眼,心里就有谱了:

非阻塞I/O核心逻辑 创建socket fcntl()设置O_NONBLOCK connect/accept/read/write 返回0 / 正常数据 立即成功,继续处理 EAGAIN / EINPROGRESS 加入事件循环,稍后重试 核心原则:不等待,先返回,准备好再通知

避坑指南

我这些年踩过的坑,总结成几条,你记一下:

  • 忘记设置新连接为非阻塞:accept返回的conn_fd,默认是阻塞的。如果你忘了给它也设成非阻塞,后面read/write还是会卡住。我曾经因为这个,排查了整整一个晚上。
  • EINPROGRESS不是错误:很多新手看到connect返回-1,errno是EINPROGRESS,就以为连接失败了。其实不是,这只是告诉你“正在连接中”。
  • 非阻塞不等于高性能:非阻塞只是手段,真正的高性能需要配合事件循环(select/poll/epoll)和合理的业务逻辑。别以为设了非阻塞就万事大吉了。
  • 注意信号干扰:某些信号(比如SIGPIPE)会导致系统调用返回EINTR。非阻塞模式下也要处理这个情况。
一个小技巧:调试非阻塞程序时,可以用strace跟踪系统调用。你能清楚地看到每个socket的状态变化,特别方便定位问题。

总结

非阻塞I/O是网络编程的基石之一。fcntl()设置非阻塞、非阻塞connect、非阻塞accept,这三板斧你掌握了,就能写出不卡死的网络程序。

当然,光有非阻塞还不够。下一层要学的,就是如何高效地管理这些非阻塞socket——select、poll、epoll,那是另一个话题了。

好了,今天就聊到这里。代码写多了,自然就熟练了。加油!


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