【Linux】五种IO模型与非阻塞IO

本文主题内容

引言:网络通信中的很多性能问题,本质上并不是数据拷贝本身太慢,而是进程在等待数据到达。理解 IO 模型,就是理解操作系统如何通知进程数据已经就绪,以及数据最终由谁拷贝到用户空间。

一、理解 IO

1.1 一次 IO 的两个阶段

以网络读取为例,一次完整的 IO 通常包含两个阶段:

  1. 等待数据就绪:等待网卡接收数据,并由内核协议栈将数据放入 Socket 接收缓冲区。
  2. 将数据从内核空间拷贝到用户空间:read、recv 等系统调用把数据交给应用程序。

所以,一次 IO 的总时间可以简单理解为:

text 复制代码
IO 时间 = 等待时间 + 数据拷贝时间

对网络服务器来说,等待时间往往比真正的数据拷贝时间更长。不同 IO 模型的主要区别,也集中在等待阶段和通知方式上。

1.2 用户空间与内核空间

应用程序不能直接访问网卡,也不能随意读取内核缓冲区。数据到达主机后,先由内核接收和处理,再通过系统调用拷贝到用户空间。

IO 就绪表示内核已经具备完成某类 IO 操作的条件,并不等于业务数据已经处理完成。

二、阻塞 IO

2.1 阻塞读取

默认情况下,大多数 Socket 都是阻塞的。当进程调用 recv,而接收缓冲区中没有数据时,进程会进入睡眠状态,直到出现以下情况之一:

  • 有数据到达。
  • 对端关闭连接。
  • 发生错误。
  • 系统调用被信号中断。

例:

cpp 复制代码
char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
    //处理收到的数据
}
else if (n == 0)
{
    //对端关闭连接
}
else
{
    //读取失败
}

阻塞 IO 的优点是代码简单,调用返回时通常已经得到结果;缺点是一个执行流在等待期间不能处理其他连接。

2.2 阻塞不等于低效

阻塞只是当前执行流暂时不能继续运行。如果服务器使用多个线程,每个线程阻塞等待一个连接,在连接数量不大时仍然能够正常工作。

不过,当连接数量非常大、活跃连接比例又很低时,为每个连接创建一个线程会带来明显的线程切换和内存开销。

三、非阻塞 IO

3.1 非阻塞读取

将 Socket 设置为非阻塞后,如果数据尚未就绪,recv 不会让进程睡眠,而是立即返回 -1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。

例:

cpp 复制代码
char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
    //处理收到的数据
}
else if (n == 0)
{
    //对端关闭连接
}
else if (errno == EAGAIN || errno == EWOULDBLOCK)
{
    //当前没有数据,稍后再读
}
else if (errno == EINTR)
{
    //被信号中断,可以重试
}
else
{
    //发生真正的读取错误
}

3.2 轮询的问题

如果应用程序不断调用 recv 检查数据是否就绪,就形成了忙轮询:

cpp 复制代码
while (true)
{
    ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
    if (n > 0)
    {
        break;
    }
    if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
    {
        break;
    }
}

这种方式虽然不会阻塞在一次 recv 上,却会持续占用 CPU。实际开发中,非阻塞 IO 通常要与 select、poll、epoll 等事件通知机制配合使用。

四、IO 多路转接

4.1 基本思想

IO 多路转接让一个执行流同时等待多个文件描述符。当其中任意描述符就绪时,select、poll 或 epoll 返回,应用程序再对就绪描述符执行 read、recv、write 等操作。

它的核心不是让单次 IO 更快,而是减少无效等待,让一个执行流能够管理大量连接。

text 复制代码
多个文件描述符 -> select、poll、epoll -> 返回就绪事件 -> 执行真正的 IO

4.2 多路转接仍属于同步 IO

select、poll、epoll 只负责告诉应用程序哪些描述符已经就绪。数据从内核缓冲区拷贝到用户缓冲区,仍然需要应用程序主动调用 recv 或 read 完成。

因此,IO 多路转接属于同步 IO。

五、信号驱动 IO

信号驱动 IO 允许进程先注册信号处理方式。当描述符就绪时,内核向进程发送 SIGIO 信号,进程再执行读取操作。

这种模型避免了持续轮询,但信号处理本身有较多限制:

  • 信号处理函数中只能安全调用异步信号安全函数。
  • 复杂业务逻辑不适合直接放在信号处理函数中。
  • 信号合并、并发状态和错误处理会增加程序复杂度。

所以,网络服务器更常使用 epoll 等多路转接机制。

六、异步 IO

异步 IO 中,应用程序提交 IO 请求后可以继续执行其他任务。内核不仅负责等待数据就绪,还负责把数据拷贝到用户指定的缓冲区,最后再通知应用程序操作已经完成。

text 复制代码
提交异步请求 -> 内核等待并完成数据拷贝 -> 通知应用程序处理结果

这和 epoll 的区别在于:

  • epoll 通知的是描述符已经就绪,应用程序还要主动完成 IO。
  • 异步 IO 通知的是 IO 操作已经完成。

七、阻塞、非阻塞、同步与异步

7.1 阻塞与非阻塞

阻塞和非阻塞描述的是:当调用暂时不能得到结果时,当前执行流是否停下来等待。

  • 阻塞:调用暂时不能完成时,执行流进入等待状态。
  • 非阻塞:调用暂时不能完成时,立即返回一个状态,执行流可以继续处理其他任务。

7.2 同步与异步

同步和异步描述的是:真正的数据拷贝由谁完成,以及完成后如何通知调用者。

  • 同步 IO:应用程序主动调用 read、recv 等函数完成数据拷贝。
  • 异步 IO:内核完成等待和数据拷贝,再通知应用程序。

注意:非阻塞 IO 不等于异步 IO,epoll 也不等于异步 IO。非阻塞 Socket 配合 epoll,依然属于同步 IO。

7.3 五种模型对比

IO 模型 等待数据 数据拷贝 调用者是否可能等待 常见使用方式
阻塞 IO 内核等待 应用程序发起 简单客户端、线程式服务器
非阻塞 IO 应用程序反复检查 应用程序发起 单次调用不等待 通常配合事件通知
IO 多路转接 select、poll、epoll 等待 应用程序发起 等待多个描述符 高并发网络服务器
信号驱动 IO 内核通过信号通知 应用程序发起 提交后可继续执行 SIGIO
异步 IO 内核等待 内核完成 提交后可继续执行 aio、io_uring 等机制

八、使用 fcntl 设置非阻塞

8.1 获取和设置文件状态标志

fcntl 可以获取和修改文件描述符的状态标志:

cpp 复制代码
#include <fcntl.h>

bool SetNonBlock(int fd)
{
    int flags = fcntl(fd, F_GETFL);
    if (flags == -1)
    {
        return false;
    }

    if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1)
    {
        return false;
    }

    return true;
}

设置新标志时要保留原有 flags,再通过按位或加入 O_NONBLOCK。直接把状态设置为 O_NONBLOCK,可能会意外清除其他标志。

8.2 非阻塞读的完整处理

非阻塞模式下,一次 recv 不一定读取到全部数据,所以常见做法是循环读取,直到返回 EAGAIN 或 EWOULDBLOCK。

例:

cpp 复制代码
bool ReadAll(int sockfd, std::string* input)
{
    char buffer[4096];

    while (true)
    {
        ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
        if (n > 0)
        {
            input->append(buffer, n);
            continue;
        }

        if (n == 0)
        {
            return false;
        }

        if (errno == EINTR)
        {
            continue;
        }

        if (errno == EAGAIN || errno == EWOULDBLOCK)
        {
            return true;
        }

        return false;
    }
}

在边缘触发的 epoll 中,必须一直读取到 EAGAIN,才能保证已经到达的数据不会被遗漏。

九、总结

  1. 一次 IO 通常包含等待数据就绪和把数据拷贝到用户空间两个阶段。
  2. 阻塞 IO 代码简单,但一个执行流等待时不能处理其他任务。
  3. 非阻塞 IO 会在数据未就绪时立即返回,单独忙轮询会浪费 CPU。
  4. select、poll、epoll 可以让一个执行流等待多个文件描述符,但它们仍属于同步 IO。
  5. 信号驱动 IO 在就绪时发送信号,异步 IO 则由内核完成等待和数据拷贝。
  6. 阻塞与非阻塞关注调用是否等待,同步与异步关注 IO 由谁完成。
  7. fcntl 可以为文件描述符添加 O_NONBLOCK,实际读取时要正确处理 EINTR、EAGAIN 和 EWOULDBLOCK。
相关推荐
码农阿豪1 小时前
不装 SSH 客户端也能连服务器:用 WebSSH 把终端搬进浏览器
运维·服务器·ssh
啦啦啦啦啦zzzz1 小时前
Appender抽象类的实现c++20
linux·服务器·c++·c++20
小此方1 小时前
Linux网络(七):从 Socket 封装到应用层协议:手写网络版计算器,彻底搞懂 TCP 通信、序列化与反序列化
linux·网络·tcp/ip
captain3761 小时前
网络初 识
运维·服务器·网络
kyle~2 小时前
Linux --- epoll (I/O就绪事件通知机制)
linux·运维·服务器
AlfredZhao2 小时前
进程都杀了,为什么 `netstat` 还能看到端口?
linux
一条泥憨鱼2 小时前
【从0开始学习计算机网络】| 邮件协议入门:SMTP、POP3、IMAP
linux·运维·计算机网络·github
z落落4 小时前
C# WinForm Socket 转串口设备服务器+Modbus CRC16校验+Socket 客户端
服务器·开发语言·c#
Johny_Zhao12 小时前
网络安全等级保护测评实施方案
linux·网络·人工智能·网络安全·信息安全·云计算·等保测评·系统运维·itsm