当前位置:首页>Linux>linux I/O机制介绍(二)五种I/O模型

linux I/O机制介绍(二)五种I/O模型

  • 2026-10-11 08:35:38
linux I/O机制介绍(二)五种I/O模型
大家好,我是小志。我们的口号是:学习、成长、应对未来风险。 越不会,越要学,直面困难。

Linux 的 I/O 机制是一套复杂而精密的系统,旨在高效地管理数据在应用程序、内存和外部设备(如磁盘、网卡)之间的流动。它的核心目标是在保证系统稳定和安全的前提下,尽可能地提升数据传输效率。

理解 Linux I/O 模型,关键在于掌握一个核心概念:一次 I/O 操作包含两个截然不同的阶段。

  1. 等待数据准备 (Waiting for Data):数据从网络设备(如网卡)或磁盘到达内核缓冲区的过程。

  2. 数据拷贝 (Copying Data):数据从内核缓冲区复制到用户进程缓冲区的过程。

Linux 系统定义了五种 I/O 模型,它们的根本区别就在于用户进程在这两个阶段中的行为方式不同。

宏观层面:五种I/O模型

这五种模型描述了应用程序与内核交互以完成 I/O 操作的不同方式。

  1. 阻塞 I/O (Blocking I/O)

    • 工作方式:应用程序发起 read 调用后,如果数据未就绪,进程会一直阻塞,直到内核将数据准备好并拷贝到用户空间后才返回。

    • 特点:最简单,但一个线程只能处理一个 I/O 流,效率低下。

  2. 非阻塞 I/O (Non-blocking I/O)

    • 工作方式:应用程序发起 read 调用后,如果数据未就绪,内核会立即返回一个错误码(如 EAGAIN)。应用程序需要通过轮询的方式不断尝试读取。

    • 特点:进程不会被阻塞,但频繁的轮询会浪费大量 CPU 资源。

  3. I/O 多路复用 (I/O Multiplexing)

    • 工作方式:应用程序通过 select、poll 或 epoll 等系统调用,同时监控多个文件描述符 (fd)。当其中任何一个 fd 就绪(可读/可写)时,内核会通知应用程序,应用程序再对就绪的 fd 进行实际的 read/write 操作。

    • 特点:用一个线程就能高效管理大量并发连接,是 Nginx、Redis 等高性能服务器的核心模型。epoll 是目前 Linux 下最高效的实现。

  4. 信号驱动 I/O (Signal-driven I/O)

    • 工作方式:应用程序通过 sigaction 系统调用注册一个信号处理函数,然后继续执行其他任务。当数据就绪时,内核会向进程发送 SIGIO 信号,触发信号处理函数来完成数据的读取。

    • 特点:避免了轮询,但信号处理逻辑复杂,且容易受到其他信号的干扰,应用不广泛。

  5. 异步 I/O (Asynchronous I/O, AIO)

    • 工作方式:应用程序发起 aio_read 等异步调用后,立即返回并继续执行。内核会负责完成“数据准备”和“数据拷贝”的全部工作,完成后通过信号或回调函数通知应用程序。

    • 特点:这是真正的异步 I/O,进程在整个 I/O 过程中完全不被阻塞。Linux 5.1 内核引入的 io_uring 是目前性能极高的异步 I/O 实现。

通俗一点理解,我们用“钓鱼”来类比这五种模型: 

  • 阻塞 I/O:你盯着鱼竿,鱼不上钩你啥也不干(干等)。

  • 非阻塞 I/O:你每隔几秒看一眼鱼竿,没鱼就先去干别的,过会儿再来看(轮询)。

  • I/O 多路复用:你放了一排鱼竿,哪个动了你去搞哪个(多管齐下)。

  • 信号驱动 I/O:鱼竿上装了铃铛,鱼上钩铃铛响,你再去收杆(回调通知)。

  • 异步 I/O:你雇了个渔夫,他负责钓鱼、收杆、烤鱼,最后只把熟鱼端给你(全托管)。

🟠 关键点理解:同步/异步 vs 阻塞/非阻塞

这是两个不同维度的概念,经常被混淆:

维度关注点描述
阻塞/非阻塞调用是否立即返回描述进程在等待 I/O 就绪时的状态。
同步/异步数据拷贝由谁完成描述 I/O 操作(尤其是数据从内核到用户的拷贝)的执行方式。

重要结论:epoll 虽然能高效地通知数据就绪,但应用程序收到通知后,仍需自己调用 read 来将数据从内核拷贝到用户空间。因此,epoll 本质上是同步 I/O。

阻塞I/O

本质:进程发起I/O请求后,全程等待,数据准备和拷贝阶段都被阻塞,直到数据从内核空间完全拷贝到用户空间后才返回。这是Linux默认的I/O模型,所有socket默认都是阻塞方式。 

流程图:

流程解读:

  1. 应用进程发起 read() 系统调用。

  2. 进入内核态后,内核首先检查数据是否已经准备好(在内核缓冲区)。

  3. 如果数据没有准备好,整个进程会立即进入睡眠状态(被阻塞),并交出 CPU 的使用权。此时,进程什么也做不了,只能等待。

  4. 当数据通过网络到达内核缓冲区后,内核会唤醒之前被阻塞的进程。

  5. 进程被唤醒后,并不会立刻返回,而是继续阻塞,等待内核将数据从内核缓冲区拷贝到它自己的用户空间内存中。

  6. 只有当数据拷贝完成后,read() 系统调用才会返回,进程才真正恢复运行,可以处理刚刚读取的数据。

在数据就绪前,进程处于“休眠”状态,CPU 不会分配时间片给它,直到整个 I/O 过程(等待+拷贝)全部完成

代码举例:

#include <unistd.h>#include <stdio.h>#include <stdlib.h>#include <string.h>intmain(void){    char buffer[1024];    ssize_t size;    printf("程序启动。\n");    // 1. 模拟做一些其他事情,休眠 5 秒    printf("正在等待 5 秒,期间你可以准备输入...\n");    sleep(5);     // 2. 【阻塞点】    // STDIN_FILENO 代表标准输入(键盘)    // 当代码执行到这里,如果此时你没有输入任何字符并按回车,    // 进程就会挂起(休眠),CPU 占用率变为 0,直到有数据到来。    printf("5秒到了,现在请输入一段文字并按回车:\n");    size = read(STDIN_FILENO, buffer, sizeof(buffer));    if (size < 0) {        perror("读取错误");        exit(1);    }    // 3. 只有当上面 read 返回后,代码才能继续往下走    printf(">>> 收到你的输入:");    write(STDOUT_FILENO, buffer, size);    return 0;}

适用场景

  • 连接数很少(<100)且每个连接活跃度高:例如数据库连接池中的长连接,每个连接持续读写。

  • 顺序处理型任务:批量文件复制、日志分析工具、压缩解压脚本。

  • 多进程/多线程架构:每个进程/线程处理一个连接,利用 CPU 多核(如 Apache prefork、传统 CGI)。

  • 对延迟不敏感且开发效率优先:内部工具、运维脚本、教学示例。

不适用场景:高并发(C10K 问题)、连接空闲占比高(浪费线程资源)。

非阻塞 I/O(Non-blocking I/O)

原理:设置文件描述符为 O_NONBLOCK 后,调用 read/write 时,若数据未就绪,内核立即返回错误 EAGAIN/EWOULDBLOCK,进程不阻塞。用户需要主动轮询(反复调用),直到数据就绪,然后第二阶段(拷贝)仍会阻塞。

流程图:

流程解读:

  • 设置非阻塞标志:通过 fcntl(fd, F_SETFL, O_NONBLOCK) 将文件描述符设为非阻塞模式。

  • 轮询循环:应用进程反复调用 read(),每次调用立即返回(不会阻塞)。

    • 数据未就绪:内核返回 -1,errno 为 EAGAIN 或 EWOULDBLOCK。进程可以执行其他任务(例如检查其他 fd),然后稍后再次调用。

    • 数据已就绪:内核开始第二阶段,将数据拷贝到用户空间。注意:拷贝阶段进程仍然阻塞(但因为数据已就绪,通常拷贝很快)。

  • 返回数据:拷贝完成后返回读取的字节数。

  • 关键点:第一阶段(等待数据)不阻塞,但第二阶段依然阻塞。进程需要主动轮询,效率低,通常不单独使用。

代码举例:

int flags = fcntl(sock, F_GETFL, 0);fcntl(sock, F_SETFL, flags | O_NONBLOCK);char buf[1024];while (1) {    ssize_t n = read(sock, buf, sizeof(buf));    if (n == -1)     {        if (errno == EAGAIN || errno == EWOULDBLOCK)         {            // 数据未就绪,可做其他事情,然后继续轮询            usleep(1000);            continue;        }         else         {            perror("read");            break;        }    }     else if (n == 0)     {        break;  // 连接关闭    }     else     {        // 数据处理        break;    }}

适用场景

  • 需要高并发和响应性的应用程序

  • 交互式客户端程序(如GUI应用,保持界面响应)

  • 对实时性有一定要求但不希望被阻塞的场景

  • 但需要警惕:CPU轮询会带来不必要的资源浪费

I/O 多路复用(I/O Multiplexing)

原理:通过 select/poll/epoll 一次监控多个文件描述符,当其中任意一个或多个就绪(可读/可写/异常)时,系统调用返回。然后用户进程再调用 read/write 进行实际 I/O(仍可能阻塞,但通常 fd 已就绪,会立即完成)。

本质:把“等待数据准备”的工作交给内核统一管理,用户进程在 epoll_wait 上阻塞,而不是在每个 fd 上分别阻塞。

多路复用的本质是用一个系统调用来替代多次独立的阻塞调用,从而用单线程高效管理大量并发连接。

select/poll vs epoll 对比

特性SELECTPOLLEPOLL
数据结构BitsMap位图动态数组(链表)红黑树 + 就绪链表
复杂度O(n)O(n)O(1)
最大连接数1024(受限)无限制(受系统限制)无限制
工作方式水平触发(LT)水平触发(LT)LT + ET(边缘触发)
内核拷贝每次调用都要拷贝每次调用都要拷贝只需拷贝变更的描述符
就绪通知全量扫描,全部返回全量扫描,全部返回只返回就绪的事件

select/poll需要两次遍历(内核遍历、用户态遍历)和两次拷贝(用户→内核、内核→用户),随着并发数增加,性能呈指数级下降,难以应对C10K问题。而epoll使用事件驱动(回调函数) 机制,当文件描述符就绪时自动通知应用程序,无需轮询所有描述符,实现了O(1)的时间复杂度。

流程图:

流程解读:

  • 注册监控:应用进程提前将需要监控的 fd 添加到 epoll 实例中(epoll_ctl 添加)。

  • 阻塞等待:调用 epoll_wait(),进程进入阻塞状态,内核监听所有注册的 fd。

  • 事件通知:当任意一个或多个 fd 数据就绪(可读/可写),内核将就绪的 fd 列表返回给用户。

  • 实际 I/O:应用进程根据返回的 fd,调用 read() 或 write()。由于该 fd 已知就绪,read() 通常立即完成数据拷贝(第二阶段仍阻塞,但耗时极短)。

  • 关键点:一个线程可以同时监控大量连接,但 read() 本身仍是同步操作。多路复用的本质是将“等待多个 I/O 事件”的工作交给内核,避免了每个 fd 单独阻塞。

代码举例

int epfd = epoll_create1(0);struct epoll_event ev, events[MAX_EVENTS];ev.events = EPOLLIN;ev.data.fd = listen_fd;epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);while (1) {    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);  // 阻塞    for (int i = 0; i < nfds; ++i) {        int fd = events[i].data.fd;        if (fd == listen_fd) {            int conn = accept(fd, ...);            ev.events = EPOLLIN;            ev.data.fd = conn;            epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);        } else {            char buf[1024];            ssize_t n = read(fd, buf, sizeof(buf));  // 通常立即返回            // 处理...        }    }}

适用场景

  • 高并发网络服务器(如Web服务器、聊天服务器)

  • 需要同时处理大量网络连接的应用

  • 解决C10K/C10M问题的核心方案

  • 实际应用代表:Nginx、Redis、Netty等

信号驱动I/O(Signal-driven I/O)

本质:进程向内核注册一个信号处理函数,然后立即返回不阻塞。当内核数据就绪时,发送一个SIGIO信号给进程,进程在信号处理函数中调用I/O操作读取数据。

这种方式相当于“被动通知”而非“主动轮询”,相比非阻塞I/O减少了不必要的CPU检查。

流程图

流程图解读:

  • 准备阶段:应用进程调用 signal(SIGIO, handler) 安装信号处理函数,然后通过 fcntl 设置 O_ASYNC 标志,并调用 fcntl(fd, F_SETOWN, getpid()) 将 fd 的所有权设置为当前进程。

  • 进程继续运行:设置完成后,应用进程可以执行其他任务(例如进入主循环),内核负责监听 fd。

  • 数据就绪:当内核检测到数据就绪,立即向进程发送 SIGIO 信号。

  • 信号处理:进程中断当前执行(如果是主循环,则暂停),转入信号处理函数。在信号处理函数中调用 read(),此时数据已就绪,内核将数据拷贝到用户空间(第二阶段阻塞,但通常无感知)。

  • 关键点:第一阶段完全异步(信号通知),但第二阶段仍需在信号处理函数中调用同步的 read。信号驱动模型不适合 TCP 流式套接字(每个数据包都会产生信号,导致信号风暴),且信号处理函数受限(不能调用不可重入函数)。现代系统中已被 epoll 和 io_uring 取代。

代码示例

#include <signal.h>#include <fcntl.h>#include <unistd.h>int sockfd;char buf[1024];voidsigio_handler(int signo){    if (signo == SIGIO) {        // 在信号处理函数中读取数据(数据已经就绪)        ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0, NULL, NULL);        if (n > 0) {            printf("接收到 %zd 字节数据\n", n);        }    }}intmain(){    sockfd = socket(AF_INET, SOCK_STREAM, 0);    // 1. 安装SIGIO信号处理函数    struct sigaction sa;    sa.sa_handler = sigio_handler;    sa.sa_flags = 0;    sigaction(SIGIO, &sa, NULL);    // 2. 设置socket属主为当前进程    fcntl(sockfd, F_SETOWN, getpid());    // 3. 启用信号驱动I/O模式    int flags = fcntl(sockfd, F_GETFL);    fcntl(sockfd, F_SETFL, flags | O_ASYNC);    while (1) {        // 进程可以做其他工作,数据就绪时会收到信号        pause();    }    return 0;}

适用场景

  • 主要适用于UDP套接字,在TCP套接字上几乎不适用(TCP的SIGIO信号过于频繁)

  • 对实时性要求较高的场景

  • 希望避免轮询但不需要大规模连接管理的场景

  • 需要兼顾简单性和实时性的中间方案

异步I/O(Asynchronous I/O)

本质:进程发起I/O操作后立即返回,完全不阻塞。内核负责完成整个I/O过程(包括等待数据准备和拷贝数据到用户空间),完成后通过回调或信号通知应用程序,应用程序可以直接使用用户缓冲区中的数据。

Linux 异步I/O的实现演变

方案特点局限性
POSIX AIO(用户态)GLIBC中提供,通过多线程模拟异步本质还是调用内核同步接口,效率和可扩展性差
Linux Native AIO(libaio)内核提供的真正异步接口仅支持O_DIRECT模式,无法利用页缓存;有数据对齐要求
io_uring(2019年至今)新一代异步I/O框架需要较新内核(5.1+),但已逐步成为主流

io_uring的核心原理

io_uring采用用户态与内核态共享内存的方式通信,摒弃了传统系统调用,避免了上下文切换。它主要创建了三块共享内存区域:

  • 提交队列(Submission Queue, SQ) :环形队列,用于存放待执行的I/O操作

  • 完成队列(Completion Queue, CQ) :环形队列,用于存放I/O操作完成后的结果

  • 提交队列项数组(SQE Array) :具体的I/O操作描述

流程图

为什么io_uring更高效?用户态对共享内存的读写不需要系统调用,不会发生上下文切换,从而大幅降低开销。相比于传统AIO每个I/O需要拷贝104字节(64+8字节提交,32字节结果),io_uring在大量小I/O场景下性能提升显著。 

代码举例

#include <liburing.h>#include <fcntl.h>#include <unistd.h>#include <stdio.h>#define QUEUE_DEPTH 32intmain(){    struct io_uring ring;    char buf[4096];    // 初始化io_uring,队列深度为32    io_uring_queue_init(QUEUE_DEPTH, &ring, 0);    int fd = open("/tmp/test.txt", O_RDONLY);    // 获取一个提交队列项    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);    // 准备异步读操作(立即返回,不阻塞)    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);    // 提交I/O请求    io_uring_submit(&ring);    // 进程可以在这里做其他工作...    // do_other_work();    // 等待I/O完成    struct io_uring_cqe *cqe;    io_uring_wait_cqe(&ring, &cqe);    if (cqe->res > 0) {        printf("异步读取完成,读取了 %d 字节\n", cqe->res);    }    // 标记完成队列项已处理    io_uring_cqe_seen(&ring, cqe);    io_uring_queue_exit(&ring);    close(fd);    return 0;}

适用场景

  • 高性能、高并发的网络应用开发

  • 大规模Web服务器、数据库系统

  • 磁盘I/O密集型应用(如日志处理、文件服务器)

  • 对延迟敏感的高吞吐量场景

  • 需要同时进行大量独立I/O操作的场景

五种I/O模型总结对比

模型阶段一是否阻塞阶段二是否阻塞是否为同步IO复杂度典型应用
阻塞I/O✅ 阻塞✅ 阻塞同步低简单客户端、低并发
非阻塞I/O❌ 不阻塞✅ 阻塞同步中交互式应用、需保持响应
I/O多路复用✅ 阻塞(select/epoll)✅ 阻塞同步中高Nginx、Redis、Netty
信号驱动I/O❌ 不阻塞✅ 阻塞同步中UDP套接字、实时通知
异步I/O❌ 不阻塞❌ 不阻塞异步高高性能Web服务器、数据库

模型选择指南

场景推荐模型理由
简单脚本、单任务程序阻塞I/O编程简单,代码可读性好
GUI/交互式应用非阻塞I/O保持界面响应,避免卡顿
高并发Web服务器epoll(多路复用)事件驱动,O(1)复杂度,海量连接
分布式中间件epoll + 异步I/O混合平衡复杂度和性能
数据库、文件系统io_uring(异步I/O)真正非阻塞,磁盘IO性能最优
实时系统、低延迟场景信号驱动I/O + 异步I/O被动通知,延迟可控

选型建议:没有最好的模型,只有最合适的模型。阻塞I/O简单但并发能力弱,多路复用(epoll)是高并发网络服务器的核心方案,而io_uring则是未来异步I/O的主流方向。需要根据业务场景的并发量、响应性要求和可维护性需求综合权衡选择。

最新文章

随机文章