哈喽,编程搭子们!😜 又到了沉浸式敲代码的快乐时间~把生活调成「代码模式」,带着满满的热爱钻进编程的奇妙世界——今天也要敲出超酷的代码,冲鸭!🚀
在这里插入图片描述

✨ 我的博客主页:喜欢吃燃面
📚 我的专栏(持续更新ing):
《C语言》 |
《C语言之数据结构》 |
《C++》 |
《Linux系统编程》

💖 超感谢你点开这篇博客!真心希望这些内容能帮到正在打怪升级的你~如果有任何想法、疑问,或者想交流学习心得,都欢迎留言/私信,咱们一起在编程路上互相陪伴、共同进步呀!

引言

在计算机系统中,IO(Input/Output,输入/输出)操作是程序与外部世界交互的核心机制。无论是读取文件、接收网络数据,还是与用户进行交互,IO操作无处不在。然而,IO操作的速度通常远低于CPU的计算速度——从内存读取数据需要纳秒级时间,而从磁盘或网络读取数据则需要毫秒甚至秒级时间。这种巨大的速度差异使得IO操作成为系统性能的瓶颈,也催生了各种IO模型的设计与演进。
本文将从IO的本质出发,系统梳理Linux系统中的五种IO模型,深入剖析select、poll、epoll三种多路复用机制的原理与实现,并重点探讨epoll的水平触发(LT)与边缘触发(ET)两种工作模式。理解这些IO模型不仅是编写高性能网络服务器的基础,也是深入理解操作系统内核工作原理的必经之路。

一. IO的本质与高效IO的设计目标

1. 什么是IO

IO即输入/输出,是计算机与外部设备(如磁盘、网卡、键盘、显示器等)之间交换数据的过程。从操作系统的角度看,IO操作的本质是数据在外设与内存之间的搬运。例如,当程序调用read(fd, buffer, size)时,操作系统内核会将数据从文件描述符fd对应的外设(可能是磁盘文件、网络套接字、管道等)读取到用户空间buffer中;调用write(fd, buffer, size)时,则是将数据从用户空间buffer写入到外设。

外设

内核空间

用户空间

系统调用

DMA/中断

DMA/中断

数据拷贝

数据拷贝

用户程序
read/write

用户缓冲区
buffer

内核缓冲区
page cache/socket buffer

磁盘/网卡/键盘

2. IO = 等 + 拷贝

从程序员的视角来看,一次完整的IO操作可以分解为两个核心阶段:
等待阶段(Wait):等待外设准备好数据。对于读操作,这意味着等待数据到达内核缓冲区;对于写操作,这意味着等待内核缓冲区有可用空间。在等待阶段,程序通常无法立即获得数据,必须等待硬件完成数据传输。
拷贝阶段(Copy):将数据从内核空间拷贝到用户空间(读操作),或从用户空间拷贝到内核空间(写操作)。这个阶段涉及CPU参与的数据搬运,需要消耗CPU资源。

因此,IO = 等 + 拷贝。这个简单的公式揭示了IO优化的核心方向:减少等待时间,提高拷贝效率。

3. 什么是高效的IO

所谓高效的IO,就是在单位时间内,IO中"等待"的比重越低,IO效率越高。换句话说,高效的IO设计目标是减少等待时间,提高CPU利用率。当程序在等待IO完成时,如果CPU能够去做其他有意义的工作,而不是空转或阻塞,那么系统的整体效率就会大幅提升。
这就好比钓鱼:如果你一直盯着鱼漂,鱼不上钩就什么都不做,这是低效的;如果你同时照看多个鱼竿,或者鱼上钩时有人通知你,这就是高效的。不同的IO模型,本质上就是解决"如何在等待IO的同时做其他事情"这个问题的不同策略。

IO效率

等待时间

拷贝时间

阻塞等待
低效

非阻塞轮询
中等

事件通知
高效

单次拷贝
基础

零拷贝技术
高效

二. 五种IO模型

POSIX标准定义了五种IO模型,它们代表了从简单到复杂、从低效到高效的IO处理策略演进。

1. 阻塞IO(Blocking IO)

阻塞IO是最简单、最直观的IO模型。当应用程序发起一个IO请求(如read()recvfrom())时,如果数据尚未准备好,程序会一直阻塞等待,直到数据到达并完成拷贝后才返回。
工作流程:
应用程序调用read(),发起IO请求。
内核检查数据是否就绪。如果未就绪,应用程序进入睡眠状态,被挂起到等待队列中。
当数据到达(如网卡接收到数据包),内核唤醒等待的进程。
内核将数据从内核缓冲区拷贝到用户缓冲区。
read()返回,应用程序继续执行。

阻塞IO的优点是编程简单,逻辑清晰;缺点是在等待期间进程被挂起,无法执行其他任务,CPU利用率低。如果一个服务器使用阻塞IO处理多个客户端,就必须为每个客户端创建一个进程或线程资源开销巨大

硬件 内核 应用程序 硬件 内核 应用程序 进程阻塞 进入睡眠 进程唤醒 继续执行 read(fd, buf, size) 数据未就绪 数据到达 数据拷贝到用户空间 返回读取的字节数

2. 非阻塞IO(Non-blocking IO)

非阻塞IO通过设置文件描述符的O_NONBLOCK标志,使得IO操作不会阻塞。当数据未就绪时,read()立即返回一个错误码EAGAINEWOULDBLOCK),而不是阻塞等待。
工作流程:
应用程序调用read()
如果数据未就绪,内核立即返回-1,并设置errnoEAGAIN
应用程序可以立即返回做其他事情,稍后再尝试读取。
当数据就绪后,再次调用read(),内核完成数据拷贝并返回。

非阻塞IO的优点是进程不会因为IO而阻塞,可以继续执行其他任务;缺点是需要频繁轮询检查数据是否就绪,消耗大量CPU资源。这种"忙等待"方式在并发量高时效率极低。

3. IO多路复用(IO Multiplexing)

IO多路复用是高性能服务器的核心机制。它允许一个进程同时监视多个文件描述符,当其中任意一个就绪时,进程得到通知并进行处理。Linux提供了三种多路复用机制:select、poll和epoll。

核心思想:将"等待多个IO"的任务交给内核统一管理,进程只需阻塞在select()/poll()/epoll_wait()这一个系统调用上。当任意一个文件描述符就绪时,进程被唤醒,然后遍历就绪列表处理事件

IO多路复用_单进程

客户端1

select/poll/epoll

客户端2

客户端3

单进程
处理所有客户端

阻塞IO_多进程

客户端1

进程1
阻塞read

客户端2

进程2
阻塞read

客户端3

进程3
阻塞read

4. 信号驱动IO(Signal-driven IO)

信号驱动IO利用Unix信号机制实现异步通知。进程首先为文件描述符设置信号处理函数,当数据就绪时,内核发送SIGIO信号通知进程,进程在信号处理函数中读取数据。
工作流程:
进程调用fcntl()设置O_ASYNC标志,并指定信号处理函数
进程继续执行其他任务,不阻塞等待IO。
当数据就绪时,内核发送SIGIO信号。
信号处理函数被调用,在其中执行read()读取数据。
信号驱动IO的优点是进程无需阻塞或轮询,可以真正并行处理其他任务;缺点是信号处理较为复杂,且信号可能丢失或合并,不适合高并发场景

5. 异步IO(Asynchronous IO)

异步IO是最理想的IO模型。进程发起IO请求后立即返回内核负责整个IO操作(包括等待和拷贝),当IO完成后通过回调或信号通知进程。
工作流程:
进程调用aio_read(),传入用户缓冲区地址和回调函数。
内核立即返回,进程继续执行其他任务。
内核在后台完成数据读取(等待+拷贝)。
IO完成后,内核调用回调函数通知进程,数据已经在用户缓冲区中。

异步IO实现了真正的"非阻塞"——进程在整个IO过程中完全不参与,只需在完成后处理结果。Linux的AIO和io_uring机制提供了异步IO支持,但编程复杂度较高

异步IO

发起IO

立即返回
继续执行

内核完成
等待+拷贝

回调通知
数据已在缓冲区

同步IO

发起IO

等待数据就绪

数据拷贝

返回结果

6. 五种IO模型对比

IO模型 等待方式 拷贝方式 优点 缺点
阻塞IO 阻塞等待 同步拷贝 编程简单 并发能力差
非阻塞IO 轮询检查 同步拷贝 不阻塞 CPU空转
IO多路复用 统一阻塞 同步拷贝 高并发 需要遍历就绪列表
信号驱动IO 信号通知 同步拷贝 不阻塞不轮询 信号处理复杂
异步IO 完全异步 异步拷贝 最高效 编程复杂

三. select机制

1. select的作用与原理

select是Linux最早提供的IO多路复用机制,它允许进程同时监视多个文件描述符的可读、可写和异常事件

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

参数说明:
nfds:三个fd_set中最大的文件描述符值加1。
readfds:监视可读事件的文件描述符集合。
writefds:监视可写事件的文件描述符集合。
exceptfds:监视异常事件的文件描述符集合。
timeout:超时时间。NULL表示永久阻塞;0表示立即返回(非阻塞轮询);指定时间表示最多等待该时间。
select的核心工作流程:
设置阶段:应用程序使用FD_SET()宏将需要监视的文件描述符添加到fd_set集合中。

调用阶段:应用程序调用select(),将fd_set从用户空间拷贝到内核空间。内核遍历所有监视的文件描述符,检查是否有事件发生。
阻塞等待:如果没有任何文件描述符就绪,进程进入睡眠状态,等待事件到来。
唤醒返回:当某个文件描述符就绪(如有数据可读),或超时时间到达,或收到信号时,select返回。内核修改fd_set,只保留就绪的文件描述符。
处理阶段:应用程序遍历fd_set,使用FD_ISSET()检查哪些文件描述符就绪,然后进行处理。

用户程序

FD_SET设置
关心的fd

select系统调用

fd_set拷贝到内核

有fd就绪?

进程阻塞等待

事件到达

内核修改fd_set
保留就绪fd

select返回

遍历fd_set
FD_ISSET检查

处理就绪事件

2. fd_set数据结构

fd_set是一个位图结构每个比特位对应一个文件描述符。例如,如果监视文件描述符1、3、5、7,则fd_set的第1、3、5、7位被设置为1。

typedef struct {
    unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;

Linux提供了四个宏来操作fd_set:
FD_ZERO(fd_set *set)清空集合
FD_SET(int fd, fd_set *set)将fd添加到集合
FD_CLR(int fd, fd_set *set)将fd从集合移除
FD_ISSET(int fd, fd_set *set)检查fd是否在集合中

3. select的缺点

select虽然实现了IO多路复用,但存在几个显著的缺陷:
文件描述符数量限制:fd_set的大小是固定的(通常为1024),这限制了select能够监视的文件描述符数量。在高并发场景下,这个限制成为瓶颈。
重复拷贝开销:每次调用select,都需要将fd_set从用户空间拷贝到内核空间;返回时,内核又需要将修改后的fd_set拷贝回用户空间。这种重复的内存拷贝在fd数量多时开销巨大。
遍历开销:内核需要遍历所有监视的文件描述符来检查就绪状态;用户程序也需要遍历整个fd_set来找到就绪的文件描述符。时间复杂度为O(n)

输入输出参数耦合:select使用同一个fd_set作为输入(告诉内核关心哪些fd)和输出(内核告诉用户哪些fd就绪),每次调用前都需要重新设置fd_set。

select缺点

fd数量限制
通常1024

重复拷贝
用户态↔内核态

线性遍历
O(n)复杂度

输入输出耦合
每次重新设置

四. poll机制

1. poll的改进

poll是select的改进版本,它使用pollfd结构体数组代替位图解决了select的文件描述符数量限制问题

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int   fd;       // 文件描述符
    short events;   // 请求监视的事件(输入)
    short revents;  // 实际发生的事件(输出)
};

参数说明:
fds:指向pollfd结构体数组的指针。
nfds:数组中元素的数量。
timeout:超时时间(毫秒)。-1表示永久阻塞;0表示立即返回。

2. poll的优势

无文件描述符数量限制:poll使用动态数组,理论上可以监视任意数量的文件描述符(受限于系统内存)。
输入输出分离events字段用于输入(告诉内核关心什么事件),revents字段用于输出(内核返回实际发生的事件)。这解决了select输入输出参数耦合的问题

更精细的事件控制:poll支持更多的事件类型,如POLLIN(可读)、POLLOUT(可写)、POLLERR(错误)、POLLHUP(挂起)等。

poll

pollfd数组
动态大小

events输入
revents输出

select

fd_set位图
固定大小1024

输入输出同一个参数

3. poll的局限

尽管poll解决了select的数量限制问题,但它仍然存在select的其他缺点:
重复拷贝:每次调用poll,都需要将整个pollfd数组从用户空间拷贝到内核空间,返回时再拷贝回来。
线性遍历:内核需要遍历整个pollfd数组来检查就绪状态;用户程序也需要遍历数组来找到就绪的fd。时间复杂度仍然是O(n)

因此,poll适合fd数量较多但总量不算特别大的场景;对于真正的海量连接(如C10K问题),poll仍然力不从心

五. epoll机制

1. epoll的诞生背景

epoll是Linux 2.5.44内核引入的IO多路复用机制,被公认为Linux下性能最好的多路复用方法。它几乎具备了之前所有机制的优点,同时避免了它们的缺点
epoll的设计目标是解决select和poll在海量连接场景下的性能瓶颈。它通过事件驱动和内核数据结构优化,将IO多路复用的效率提升到了新的高度。

2. epoll的核心API

epoll提供了三个核心系统调用:
epoll_create:创建一个epoll实例,返回一个文件描述符。

int epoll_create(int size);  // 旧版本
int epoll_create1(int flags); // 新版本

epoll_ctl:向epoll实例中添加、修改或删除监视的文件描述符

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epfd:epoll实例的文件描述符。
op:操作类型EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除)。
fd:要操作的文件描述符。
event:指定关心的事件类型

struct epoll_event {
    uint32_t     events;  // 事件掩码,如EPOLLIN、EPOLLOUT
    epoll_data_t data;    // 用户数据,通常存放fd指针
};

union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
};

epoll_wait:等待事件就绪,返回就绪的事件列表

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

3. epoll的内部原理

epoll的高效性源于其精巧的内核数据结构设计和事件通知机制。
红黑树(RB-Tree):epoll在内核中使用红黑树来存储所有监视的文件描述符。红黑树是一种自平衡二叉搜索树,插入、删除和查找的时间复杂度都是O(log n)。当调用epoll_ctl添加或删除fd时,内核操作红黑树,而不是像select/poll那样每次都传递整个fd集合。

就绪链表(Ready List):内核为每个epoll实例维护一个就绪链表。当某个文件描述符上发生关心的事件时,内核通过回调机制将该fd对应的epoll_event添加到就绪链表中。这个过程是自动的、即时的。
epoll_wait的工作流程:当用户调用epoll_wait时,内核只需检查就绪链表是否为空。如果不为空,直接将链表中的事件拷贝到用户空间;如果为空,进程阻塞等待,直到有事件到来。由于就绪链表只包含真正就绪的fd,内核无需遍历所有监视的fd

epoll内核结构

fd就绪
回调机制

epoll_wait

epoll实例

红黑树
存储所有监视的fd

就绪链表
存储就绪的fd

等待队列
阻塞的进程

用户空间
events数组

4. epoll相比select/poll的优势

无fd数量限制:epoll使用红黑树存储fd,理论上只受系统内存限制,可以轻松支持数十万甚至上百万的并发连接。
无需重复拷贝:调用epoll_ctl添加fd时,内核将fd信息存入红黑树,之后无需再次拷贝。epoll_wait只需将就绪链表中的事件拷贝到用户空间,而这个链表通常很小。
O(1)就绪检测:epoll_wait只需检查就绪链表是否为空,时间复杂度为O(1),而不是select/poll的O(n)。

事件驱动而非轮询:epoll通过内核回调机制自动将就绪fd加入就绪链表,避免了遍历所有fd的开销。

epoll优势

无fd数量限制
红黑树存储

无需重复拷贝
一次添加永久有效

O(1)就绪检测
直接检查就绪链表

事件驱动
回调机制自动通知

六. epoll的水平触发与边缘触发

1. 两种触发模式

epoll提供了两种事件触发模式:水平触发(Level Triggered,LT)和边缘触发(Edge Triggered,ET)。

水平触发(LT):这是epoll的默认工作模式。只要文件描述符上还有数据可读(或还有空间可写),epoll就会持续通知。换句话说,只要满足条件,每次调用epoll_wait都会返回该fd。
边缘触发(ET):只有在文件描述符状态发生变化时(如从不可读变为可读),epoll才会通知一次。如果应用程序没有一次性将所有数据读取完毕,除非有新数据到达导致状态再次变化,否则epoll不会再次通知。

ET模式_边缘触发

数据到达

epoll通知一次

必须一次性读完

还有数据?

不再通知
直到新数据到达

完成

LT模式_水平触发

数据到达

epoll通知

读取部分数据

还有数据?

不再通知

2. LT模式详解

LT模式的行为与select/poll类似,是"安全"但相对低效的模式:
优点:编程简单,不易丢数据如果应用程序一次没有读取完所有数据,下次调用epoll_wait时还会收到通知,可以继续读取。
缺点:可能产生大量重复通知。如果应用程序读取了部分数据但还有剩余,每次epoll_wait都会返回该fd,造成不必要的系统调用开销。

LT模式适合对可靠性要求高、不想处理复杂边界情况的场景。

3. ET模式详解

ET模式是epoll的高效模式,也是推荐的高性能服务器使用模式:
优点:
减少通知次数:每个事件只通知一次,大大减少了epoll_wait的返回次数和应用程序的处理次数。
提高吞吐量:由于通知次数减少,系统调用开销降低,整体吞吐量提升。
缺点与注意事项:
必须一次性读完数据:由于只通知一次,应用程序必须在一个循环中将所有数据读取完毕,直到read()返回EAGAIN为止。

必须设置非阻塞IO:ET模式下,文件描述符必须设置为非阻塞(O_NONBLOCK)。因为如果数据没有读完,再次调用阻塞的read()导致进程永远阻塞——epoll不会再通知该fd有数据可读。

// ET模式下的正确读取方式
while (true) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        // 处理数据
    } else if (n == -1 && errno == EAGAIN) {
        // 数据读取完毕,退出循环
        break;
    } else if (n == 0) {
        // 对端关闭连接
        close(fd);
        break;
    } else {
        // 错误处理
        break;
    }
}

4. LT vs ET对比

特性 LT(水平触发) ET(边缘触发)
触发条件 条件满足就触发 状态变化时触发
通知次数 多次,直到条件不满足 仅一次
编程难度 简单 复杂,需循环读取
是否需非阻塞 否 必须
性能 较好 更高
数据丢失风险 低 高(如果处理不当)
适用场景 通用场景 高性能服务器

ET_边缘触发

仅通知一次

新数据到达

接收缓冲区
从无到有

epoll_wait返回

循环读取
直到EAGAIN

缓冲区空

LT_水平触发

持续通知

接收缓冲区
有数据

epoll_wait返回

读取部分

5. 为什么ET模式能提高性能

ET模式之所以能提高性能,核心原因在于减少了epoll的通知次数和系统调用次数。在LT模式下,如果应用程序每次只读取部分数据,epoll会反复通知,造成大量的上下文切换。而在ET模式下,epoll只通知一次,应用程序在一个循环中尽可能多地读取数据,减少了用户态与内核态之间的切换

此外,ET模式配合非阻塞IO,可以确保应用程序不会被单个fd阻塞,从而能够及时处理其他fd上的事件,提高了整体的响应能力和吞吐量。

七. 总结

本文从IO的本质出发,系统梳理了Linux系统中的IO模型演进和多路复用机制:
IO的本质是"等+拷贝",高效的IO设计目标是减少等待时间、提高CPU利用率。五种IO模型——阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO——代表了不同的等待策略,从简单阻塞到完全异步,复杂度和效率逐步提升。
select作为最早的IO多路复用机制,实现了用一个进程监视多个fd的突破,但受限于fd数量、重复拷贝和线性遍历的问题。
poll改进了select的fd数量限制和输入输出耦合问题,但仍未解决重复拷贝和线性遍历的瓶颈。
epoll通过红黑树、就绪链表和事件回调机制,彻底解决了select/poll的性能问题,成为Linux下高性能网络服务器的标准选择。其O(1)的就绪检测和无fd数量限制的特性,使其能够轻松应对C10K甚至C1000K级别的并发。

LT与ET模式为epoll提供了灵活的事件通知策略。LT模式简单安全,适合大多数场景;ET模式高效但编程复杂,适合追求极致性能的服务器应用。理解两种模式的差异和适用场景,是编写高质量epoll程序的关键。

掌握这些IO模型和多路复用机制,不仅是编写高性能网络服务器的基础,也是深入理解操作系统内核设计思想的窗口。从阻塞到非阻塞,从轮询到事件驱动,IO模型的演进体现了计算机系统设计中"用空间换时间"、"用复杂度换效率"的永恒主题。


Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐