I/O 模型全解析:从阻塞 I/O 到 Epoll

I/O 模型

I/O 模型 是为了解决这个矛盾而诞生的一套方案或策略,它定义了应用程序如何发起 I/O 请求、以及如何获知 I/O 操作完成的机制。

常见的 I/O 模型

阻塞 I/O ( Blocking I/O )

原理:最基础的 I/O 模型。当应用程序发起 I/O 操作(如 recvfrom 读取网络数据)时,会一直阻塞等待,直到内核完成数据准备并将数据从内核态拷贝到用户态后,才返回结果并继续执行。

特点:

  • 实现简单,逻辑直观;

  • 一个线程只能处理一个 I/O 操作,在高并发场景下需要创建大量线程,导致资源消耗大(内存、上下文切换);阻塞期

  • 阻塞期间线程无法做其他事情,效率低。

示例:传统的单线程 Socket 服务器,一次只能处理一个客户端连接,处理完才能接受新连接。

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

原理:应用程序将文件描述符(如 Socket)设置为非阻塞模式后,发起 I/O 操作时会立即返回。如果内核尚未准备好数据,返回错误码(如EAGAINEWOULDBLOCK);如果数据已准备好,则立即完成拷贝并返回结果。

特点:

  • 不会阻塞线程,发起 I/O 后可立即执行其他任务;

  • 需要应用程序主动轮询(反复调用 I/O 函数)检查数据是否就绪,轮询过程会消耗 CPU 资源;

  • 适用于 I/O 操作耗时短、需要快速响应的场景。

示例:通过循环调用recv并检查返回值,直到数据就绪。

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

原理:允许单个线程同时监控多个文件描述符(如多个 Socket),通过内核等待多个 I/O 操作中的任意一个就绪,再通知应用程序处理。常见实现有 select、poll、 epoll(Linux)、kqueue(BSD)。

特点:

  • 单线程可处理多个 I/O 流,减少线程创建开销;

  • 内核负责监控 I/O 就绪状态,避免应用程序盲目轮询;

  • 本质上仍会阻塞在select/poll/epoll_wait调用,但阻塞期间不消耗 CPU;

  • 适用于高并发场景(如服务器同时处理大量客户端连接)。

示例:使用 epoll 监控多个客户端 Socket,当某 Socket 有数据可读时,epoll_wait 返回并处理该连接。

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

原理:应用程序通过系统调用(如sigaction)注册一个信号处理函数,然后发起 I/O 操作后立即返回,继续执行其他任务。当内核准备好数据后,会发送一个信号(如SIGIO)通知应用程序,应用程序在信号处理函数中完成数据拷贝。

特点:

  • 等待数据阶段不阻塞,提高了 CPU 利用率;

  • 信号处理逻辑复杂,容易遗漏或误处理信号;

  • 实际应用中较少使用,不如 I/O 多路复用普及。

示例:注册 Socket 的SIGIO信号处理函数,当数据到达时由内核触发信号,在信号处理函数中调用 recv 读取数据。

异步 I/O(Asynchronous I/O)

原理:应用程序发起 I/O 操作后立即返回,内核会在后台完成数据准备和数据拷贝的全过程,当整个 I/O 操作完成后,内核通过信号或回调通知应用程序。

特点:

  • 全程不阻塞,应用程序无需参与 I/O 过程,效率最高;

  • 实现复杂,依赖操作系统支持(如 Linux 的aio系列函数、Windows 的 IOCP);

  • 适用于对性能要求极高的场景(如高性能数据库、分布式存储)。

区别于非阻塞 I/O:非阻塞 I/O 需要应用程序主动检查数据是否就绪并完成拷贝,而异步 I/O 由内核完成所有操作,仅在最终完成时通知应用程序。

I/O 多路复用

通过一个进程来维护多个 Socket,也就是 I/O 多路复用,是一种常见的并发编程技术,它允许单个线程或进程同时监视多个输入 / 输出(I/O)流(例如网络连接、文件描述符)。当任何一个 I/O 流准备好进行读写操作时,该机制会通知应用程序,从而使得应用程序可以在不为每个连接创建单独线程或进程的情况下,高效地处理多个并发连接。

Socket 通信

Socket 是网络通信的基础,通过套接字(socke)实现不同主机或同一主机不同进程间的通信。TCP Socket 通信的基本流程如下:

  1. 服务器端:创建 socket → 绑定地址端口 (bind) → 监听连接 (listen) → 接受连接 (accept) → 读写数据 (recv/send) → 关闭连接

  2. 客户端:创建 socket → 连接服务器 (connect) → 读写数据 → 关闭连接

问题:传统的 accept() , recv() , send() 都时阻塞的。如果一个服务器用单个线程处理多个客户端连接,它只能处理完一个再处理下一个,效率极低。为每个连接创建一个新线程(进程)又会在连接数巨大时耗尽资源。

解决方案:I/O 多路复用。让一个线程能同时监视多个套接字(文件描述符,fd),当其中某些 fd 就绪(可读、可写、有异常)时,再对其进行操作。

select

原理:通过维护三个文件描述符集合(分别对应 “可读”“可写”“异常” 事件),应用程序需将关注的文件描述符添加到集合中,然后调用 select 函数让内核监控这些集合。内核遍历所有集合中的文件描述符,检查是否有事件就绪,若有则标记并返回就绪数量。应用程序需再次遍历集合,找出就绪的文件描述符并处理。

关键特点:

  • 文件描述符限制:受 FD_SETSIZE 限制(默认 1024),无法监控更多文件描述符;

  • 效率问题:每次调用 select 需将整个集合从用户态拷贝到内核态,内核遍历所有文件描述符(时间复杂度 O (n)),返回后用户态还需再次遍历集合;

  • 状态重置:每次调用后集合会被内核修改,下次调用前需重新初始化集合。

// 初始化fd_set
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds); // 添加监听socket
int max_fd = listen_fd;

while(1) {
    fd_set tmp_fds = read_fds; // 每次调用前必须重置
    int ready = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);

    if (FD_ISSET(listen_fd, &tmp_fds)) {
        // 处理新连接
        int conn_fd = accept(listen_fd, ...);
        FD_SET(conn_fd, &read_fds); // 添加新fd到监控集合
        if (conn_fd > max_fd) max_fd = conn_fd;
    }

    for (int fd = listen_fd + 1; fd <= max_fd; fd++) {
        if (FD_ISSET(fd, &tmp_fds)) {
            // 处理可读的客户端连接
            recv(fd, ...);
        }
    }
}

poll

原理:使用 struct pollfd 结构体数组替代 select 的文件描述符集合,每个结构体包含文件描述符(fd)、关注的事件(events)和实际发生的事件(revents)。内核遍历数组检查事件,返回就绪数量,应用程序通过遍历数组和检查 revents  找到就绪的文件描述符。

关键特点:

  • 突破数量限制:采用动态数组,无固定大小限制(仅受系统最大文件描述符限制);

  • 避免状态重置: events 由用户设置且保持不变,revents 由内核填充,无需每次重置;

  • 效率瓶颈:仍需遍历所有文件描述符(时间复杂度 O (n)),且每次调用需将整个数组从用户态拷贝到内核态,高并发时开销较大。

// 初始化pollfd数组
struct pollfd fds[MAX_FDS];
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
int nfds = 1;

while(1) {
    int ready = poll(fds, nfds, -1);

    if (fds[0].revents & POLLIN) {
        // 处理新连接
        int conn_fd = accept(listen_fd, ...);
        fds[nfds].fd = conn_fd;
        fds[nfds].events = POLLIN;
        nfds++;
    }

    for (int i = 1; i < nfds; i++) {
        if (fds[i].revents & POLLIN) {
            // 处理可读的客户端连接
            recv(fds[i].fd, ...);
        }
    }
}

epoll ( Linux 特有)

原理:通过三个系统调用实现高效监控:

  • epoll_create : 创建 epoll 实例,内核维护一个红黑树(存储监控的文件描述符及事件)和一个就绪队列(存储就绪的文件描述符);

  • epoll_ctl : 向 epoll 实例添加、修改或删除监控的文件描述符及事件(操作红黑树,时间复杂度 O (log n));

  • epoll_wait : 阻塞等待就绪事件,直接返回就绪队列中的文件描述符(时间复杂度 O (1))。

关键特点:

  • 事件驱动:内核通过回调函数将就绪的文件描述符加入就绪队列,epoll_wait 无需遍历所有文件描述符;

  • 低拷贝开销:仅在添加 / 删除文件描述符时进行一次拷贝,避免每次调用的批量拷贝;

  • 灵活触发模式:支持水平触发(LT,默认)和边缘触发(ET)。LT 模式下,只要文件描述符就绪,epoll_wait 就会持续通知;ET 模式下,仅在状态从 “未就绪” 变为 “就绪” 时通知一次,需一次性处理完数据;

  • 无数量限制:仅受系统内存限制,适合高并发场景(如十万级连接)。

// 创建epoll实例
int epoll_fd = epoll_create1(0);
// 添加监听socket
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

struct epoll_event events[MAX_EVENTS];

while(1) {
    // 等待事件发生,返回的是就绪的事件数组,无需遍历!
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == listen_fd) {
            // 处理新连接
            int conn_fd = accept(listen_fd, ...);
            ev.events = EPOLLIN;
            ev.data.fd = conn_fd;
            epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
        } else {
            // 处理可读的客户端连接
            recv(events[i].data.fd, ...);
        }
    }
}

select , poll ,epoll 三种机制对比

特性selectpollepoll (Linux)
最大 FD 限制有(FD_SETSIZE,通常 1024)无(受系统限制)无(受系统限制)
事件存储位图pollfd 数组红黑树 + 就绪队列
触发方式水平触发水平触发水平触发 (LT)/ 边缘触发 (ET)
时间复杂度O (n)(遍历所有 FD)O (n)(遍历所有 FD)O (1)(直接返回就绪列表)
用户态 - 内核态拷贝每次调用拷贝整个集合每次调用拷贝整个数组仅添加 / 删除时拷贝
适用场景少量连接中等连接数高并发(万级以上连接)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值