多路转接IO模型select、poll、epoll

1. 五种IO模型

1.1 阻塞IO

阻塞 IO:这是最常见的 I/O 模型。在内核将数据准备好之前,系统调用会一直等待。所有的套接字,默认 都是阻塞方式。

特点

 - 实现简单,易于理解

 - 效率较低,在 I/O 操作期间进程资源被闲置

 - 例如:Socket 的 recv ()、read () 等函数默认都是阻塞的

1.2 非阻塞IO

非阻塞 IO:如果内核还未将数据准备好,系统调用不会阻塞,而是立即返回结果,返回EWOULDBLOCK 错误码。

非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。

特点

 - 不会阻塞进程,可在等待 I/O 时执行其他任务

 - 需要轮询检查,会消耗 CPU 资源

 - 适用于操作频繁且处理时间短的场景

1.3 信号驱动IO

信号驱动 IO:应用程序通过系统调用注册一个信号处理函数,当 I/O 操作准备就绪时,内核会发送信号 SIGIO 通知应用程序进行实际的 I/O 操作。

特点

 - 无需轮询,减少 CPU 消耗应用程序

 - 在收到信号前可以正常执行其他任务

 - 信号处理机制可能比较复杂

1.4 多路转接IO

IO 多路转接:虽然从流程图上看起来和阻塞 IO 类似。实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。

特点

 - 可以同时处理多个 I/O 操作

 - 常用的实现有 select、poll、epoll(Linux)和 kqueue(BSD)

 - 适用于需要处理大量并发连接的场景(如服务器)

1.5 异步IO

异步 IO:应用程序发起 I/O 操作后立即返回,继续执行其他任务。由内核在数据拷贝完成时,通知应用程序(信号驱动是告诉应用程序何时可以开始拷贝数据)。

特点:

 - 完全异步,应用程序几乎不需要等待 I/O 操作

 - 效率最高,但实现复杂度也最高

 - 与非阻塞 IO 的区别:异步 IO 是操作完成后通知,而非阻塞 IO 是操作准备好后通知

任何 IO 过程中,都包含两个步骤。第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是让等待的时间尽量少

2. 高级 IO 重要概念

2.1 同步通信 vs 异步通信

同步和异步关注的是消息通信机制

同步通信:
 - 调用发出后,必须等待结果返回才能继续执行
 - 例如:打电话问快递员是否已送达,必须等对方回应才能挂电话
 - 对应 IO 模型:阻塞 IO、非阻塞 IO、多路转接 IO、信号驱动 IO

异步通信:
 - 调用发出后立即返回,无需等待结果
 - 结果通过通知、回调等方式后续处理
 - 例如:发微信问快递员是否已送达,发送后可以做其他事,等对方回复即可
 - 对应 IO 模型:异步 IO

注意:这里的 "同步" 与多线程中的 "同步" 完全不同。线程同步是指多个线程间的执行顺序协调(如用锁保证临界资源访问顺序),而通信同步是指消息传递的方式。

看到 "同步" 这个词,一定要先搞清楚大背景是什么,是同步通信异步通信的同步,还是同步与互斥的同步。

2.2 阻塞 vs 非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。

 - 阻塞调用是指调用结果返回之前,当前线程会被挂起。被调用线程只有在得到结果之后才会返回。

 - 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程,线程可以继续执行其他任务。

2.3  四者组合关系

可以形成四种组合:

同步阻塞:最常见的传统 IO 方式,调用后一直等待(如阻塞 recv ())

同步非阻塞:调用后不断轮询检查结果(如非阻塞 IO 的轮询)

异步阻塞:实际中很少见,可理解为发出异步请求后阻塞等待通知

异步非阻塞:效率最高的方式,发出请求后完全不阻塞,通过回调处理结果。

妖怪蒸唐僧(形象类比):

同步阻塞:妖怪生火后一直守在锅边,啥也不干,直到唐僧蒸熟

同步非阻塞:妖怪生火后去打游戏,但每隔几分钟就去看看唐僧熟了没

异步非阻塞:妖怪设置好蒸锅的定时提醒,然后去打游戏,时间到了提醒器会响,再回来处理。

非阻塞 IO,纪录锁,系统 V 流机制,I/O 多路转接(也叫 I/O 多路复用),readv 和writev 函数以及存储映射 IO(mmap),这些统称为高级 IO。

3. 非阻塞IO

3.1 ftcnl函数原型和基本用法

fcntl(file control)是 Unix/Linux 系统中一个非常重要的系统调用,用于对已打开的文件描述符进行各种控制操作,是实现高级 IO 模型(如非阻塞 IO)的核心工具。

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

参数说明:
fd:需要操作的文件描述符(如通过 open、socket 获得的描述符)。
cmd:操作命令(决定要执行的具体功能)。
可变参数 ...:根据 cmd 的不同,可能需要传入额外参数(通常是 int 或 struct flock* 类型)。
返回值:
成功:根据 cmd 不同返回不同值(如文件状态标记、0 等)。
失败:返回 -1,并设置 errno 表示错误原因。

3.2 fcntl 函数 5 种功能

1. 复制文件描述符(F_DUPFD、F_DUPFD_CLOEXEC)
用于复制一个已有的文件描述符,类似 dup 和 dup2 函数。

F_DUPFD:复制 fd,返回一个大于等于第三个参数(arg)的新描述符,新描述符与原描述符指向同一文件,且共享文件状态。

int new_fd = fcntl(old_fd, F_DUPFD, 0); // 复制old_fd,新描述符从0开始找可用值

F_DUPFD_CLOEXEC:功能与 F_DUPFD 类似,但新描述符会设置 FD_CLOEXEC 标志(进程执行 exec 时自动关闭该描述符),避免资源泄漏。

2. 获取 / 设置文件描述符标记(F_GETFD、F_SETFD)
文件描述符标记是描述符本身的属性(而非文件的属性),最常用的是 FD_CLOEXEC 标志。

F_GETFD:获取当前文件描述符的标记,返回一个 int 值。
F_SETFD:设置文件描述符的标记,第三个参数为新标记值(通常是 FD_CLOEXEC 或 0)。

// 设置FD_CLOEXEC标志(exec时自动关闭)
int flags = fcntl(fd, F_GETFD);
fcntl(fd, F_SETFD, flags | FD_CLOEXEC);

3. 获取 / 设置文件状态标记(F_GETFL、F_SETFL)
文件状态标记是文件的属性决定了 IO 操作的行为(如阻塞 / 非阻塞、读写模式等),是 fcntl 最常用的功能。

O_RDONLY/O_WRONLY/O_RDWR:读写模式(打开文件时设置,fcntl 不能修改)。
O_NONBLOCK:非阻塞模式(核心标志,设置后 IO 操作不阻塞)。
O_APPEND:追加模式(写入时数据自动加到文件末尾)。
O_ASYNC:异步 IO 模式(数据就绪时触发信号)

// 1. 获取当前状态标记
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
    perror("fcntl F_GETFL failed");
}

// 2. 修改标记(如添加非阻塞和追加模式)
flags |= O_NONBLOCK | O_APPEND;

// 3. 设置新标记
if (fcntl(fd, F_SETFL, flags) == -1) {
    perror("fcntl F_SETFL failed");
}

4. 异步 IO 所有权控制(F_GETOWN、F_SETOWN)
用于设置异步 IO 的接收进程 / 线程,当文件描述符可读写时,内核会向指定进程发送 SIGIO 信号。

F_GETOWN:获取当前接收异步信号的进程 ID 或线程 ID。
F_SETOWN:设置接收异步信号的进程 ID(正数)或线程 ID(负数)。

// 设置O_ASYNC标志,启用异步通知
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC);

// 设置当前进程接收SIGIO信号
fcntl(fd, F_SETOWN, getpid());

5. 文件记录锁(F_GETLK、F_SETLK、F_SETLKW)
用于对文件的部分内容(记录)加锁,实现多进程间的同步。

struct flock {
    short l_type;   // 锁类型:F_RDLCK(读锁)、F_WRLCK(写锁)、F_UNLCK(解锁)
    short l_whence; // 偏移基准:SEEK_SET、SEEK_CUR、SEEK_END
    off_t l_start;  // 锁的起始偏移
    off_t l_len;    // 锁的长度(0表示到文件末尾)
    pid_t l_pid;    // 持有冲突锁的进程ID(F_GETLK时使用)
};


struct flock lock;
lock.l_type = F_WRLCK;    // 写锁
lock.l_whence = SEEK_SET; // 从文件开头开始
lock.l_start = 0;         // 起始位置0
lock.l_len = 100;         // 锁定前100字节

// 加锁(若冲突则阻塞等待)
if (fcntl(fd, F_SETLKW, &lock) == -1) {
    perror("fcntl lock failed");
}

下面使用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞。

void SetNoBlock(int fd) {
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) {
        perror("fcntl");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

使用 F_GETFL 将当前的文件描述符的属性取出来(这是一个位图)。

然后再使用 F_SETFL 将文件描述符设置回去。设置回去的同时, 加上一个 O_NONBLOCK 参数。

轮询方式读取标准输入
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void SetNoBlock(int fd) {
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) {
        perror("fcntl");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main() {
    SetNoBlock(0);
    while (1) {
        char buf[1024] = {0};
        ssize_t read_size = read(0, buf, sizeof(buf) - 1);
        if (read_size < 0) 
        {
            perror("read");
            sleep(1);
            continue;
        }
        printf("input:%s\n", buf);
    }
    return 0;
}

4. 多路转接select

select 是一种 I/O 多路复用机制,其核心作用是:允许一个进程同时监控多个文件描述符(如套接字、普通文件、管道等),并在其中一个或多个文件描述符处于就绪状态(即可读、可写或发生异常)时,通知进程进行处理。

select 的设计解决了传统阻塞 I/O 模型的局限性:在单进程 / 单线程中,若同时处理多个 I/O 操作(如同时与多个客户端通信的服务器),传统方式需要阻塞等待某一个 I/O 完成,导致其他 I/O 无法及时响应。

select 通过以下方式实现多路复用:

 - 进程将需要监控的文件描述符集合(可读、可写、异常三类)传递给 select;
 - select 阻塞等待,直到集合中至少有一个文件描述符进入就绪状态或超时返回
 - 进程通过检查文件描述符集合,确定哪些 I/O 可以操作,从而高效处理多个任务。

4.1 函数原型与参数解析

(1)函数原型

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, 
           fd_set *exceptfds, struct timeval *timeout);

 (2)参数解析

1. 参数 nfds 是需要监视的最大的文件描述符的值+1。fd_set 本质是位图,内核通过该值确定需要遍历的位范围(从 0 到 nfds-1)。例如,若监控的 fd 为 3、5,则 nfds 需设为 6(5+1)。

2. rdset,wrset,exset 分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合。

​​​​​若不需要监控某类事件,可设为 NULL(如只监控读事件时,writefds 和 exceptfds 设为 NULL)。这些集合是 “输入输出型参数”:调用时传入待监控的 fd 集合,返回时内核会修改它们,仅保留触发事件的 fd。

3. 参数 timeout 为结构 timeval,用于设置 select 的等待超时时间,类型为 struct timeval

struct timeval {
    long tv_sec;  // 秒
    long tv_usec; // 微秒(1秒=1000000微秒)
};

取值 NULL:select 会一直阻塞,直到至少一个 fd 触发事件。
取值 (0, 0):立即返回,仅检查当前 fd 状态(非阻塞轮询)。
特定时间:若超时前无事件,返回 0;否则返回触发事件的 fd 数量。

(3)函数返回值

 - 执行成功则返回文件描述词状态已改变的个数

 - 如果返回 0 代表在描述词状态改变前已超过 timeout 时间,没有返回

 - 当有错误发生时则返回-1,错误原因存于 errno,此时参数 readfds,writefds,exceptfds 和 timeout 的值变成不可预测。

(4)错误值

 - EBADF 文件描述词为无效的或该文件已关闭

 - EINTR 此调用被信号所中断

 - EINVAL 参数 n 为负值。

 - ENOMEM 核心内存不足

4.2 fd_set 操作与原理

fd_set 是一个位图结构(本质是整数数组),每一位对应一个文件描述符,用于标记该 fd 是否被监控。系统提供了 4 个宏操作该结构:

void FD_CLR(int fd, fd_set *set); // 用来清除描述词组 set 中相关fd 的位

int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组 set 中相关fd 的位是否为真

void FD_SET(int fd, fd_set *set); // 用来设置描述词组 set 中相关fd 的位

void FD_ZERO(fd_set *set); // 用来清除描述词组 set 的全部位

fd_set 结构体是输入输出型参数,其含义在调用 select 前后有所不同:

输入前:fd_set 是 “监控清单”,告诉内核要关注哪些 fd 的哪些事件。
输入后:fd_set 是 “就绪清单”,内核返回哪些 fd 实际发生了事件。

示例:fd_set 状态变化

取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd。则 1 字节长的 fd_set 最大可以对应 8 个 fd。

(1)执行 fd_set set;FD_ZERO(&set);则 set 用位表示是 0000,0000。

(2)若 fd=5,执行 FD_SET(fd,&set);后 set 变为 0001,0000(第 5 位置为 1)

(3)若再加入 fd=2,fd=1,则 set 变为 0001,0011

(4)执行 select(6,&set,0,0,0)阻塞等待

(5)若 fd=1,fd=2 上都发生可读事件,则 select 返回,此时 set 变为0000,0011。注意:没有事件发生的 fd=5 被清空。

4.3 select就绪条件

读就绪:

 - socket 内核中,接收缓冲区中的字节数>=低水位标记(SO_RCVLOWAT),此时可以无阻塞的读该文件描述符,并且返回值大于 0

 - socket TCP 通信中,对端关闭连接,此时对该 socket 读,则返回 0

 - 监听的 socket 上有新的连接请求

 - socket 上有未处理的错误,读操作返回 -1 并设置 errno

写就绪

 - socket 内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小)>=低水位标记 SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于 0

 - socket 的写操作被关闭(close 或者 shutdown)。对一个写操作被关闭的 socket 进行写操作,会触发 SIGPIPE 信号

 - socket 使用非阻塞 connect 连接成功或失败之后

 - socket 发生错误,写操作返回 -1 并设置 errno

异常就绪(exceptfds 触发)

 - socket 收到 TCP 带外数据(紧急数据,通过 MSG_OOB 标志发送)

4.4 select的特点和缺点

select 的特点

 - 可监控的文件描述符个数取决于 sizeof(fd_set)的值。如果服务器上 sizeof(fd_set)=512,每 bit 表示一个文件描述符,则该服务器上支持的最大文件描述符是 512*8=4096。

 - 将 fd 加入 select 监控集的同时,还要再使用一个数据结构 array 保存放到 select 监控集中的 fd,

     - 一是用于再 select 返回后,array 作为源数据和 fd_set 进行 FD_ISSET 判断。

     - 二是 select 返回后会把以前加入的但并无事件发生的 fd 清空,则每次开始 select 前都要重新从 array 取得 fd 逐一加入(FD_ZERO 最先),扫描 array 的同时取得 fd 最大值 maxfd,用于 select 的第一个参数。

select 缺点

 - 每次调用 select,都需要手动设置 fd 集合,从接口使用角度来说也非常不便。

 - 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很 多时会很大

 - 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很 多时也很大

 - select 支持的文件描述符数量太小。

5. 多路转接poll和epoll

5.1.poll

(1)函数原型

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd;         /* 要监控的文件描述符 */
    short events;   /* 注册的事件(输入:要监控哪些事件) */
    short revents;  /* 返回的事件(输出:实际发生了哪些事件) */
};

(2)参数说明

 - fds 是一个 poll 函数监听的结构列表。每一个元素中,包含了三部分内容:文件描述符,监听的事件集合,返回的事件集合。若 fd 为 -1,则 events 字段被忽略, revents始终为 0。

 - nfds 表示 fds 数组的长度

 - timeout 表示超时时间,单位为毫秒(ms),取值规则:

    - -1:永久阻塞,直到有事件发生才返回。

    - 0:非阻塞模式,立即返回当前状态(无论是否有事件)。

    - 正数:最多等待指定毫秒数,超时后返回 0。

events 和 revents 的取值:

(3)返回结果:

返回值小于 0,表示出错;

返回值等于 0,表示 poll 函数等待超时;

返回值大于 0,表示 poll 由于监听的文件描述符就绪而返回。

poll 就绪条件 同 select

(4)poll 的优点

 - 不同于 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 指针实现。

 - pollfd 结构包含了要监视的 event 和发生的 event,不再使用 select“参数-值”传递的方式。接口使用比 select 更方便。

 - poll 并没有最大数量限制 (但是数量过大后性能也是会下降)。

(5)poll 的缺点

poll 中监听的文件描述符数目增多时:

 - 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符.

 - 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中.

 - 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降.

示例:

int main()
{
    struct pollfd poll_fd;
    poll_fd.fd = 0;
    poll_fd.events = POLLIN;
    for (;;)
    {
        int ret = poll(&poll_fd, 1, 1000);
        if (ret < 0)
        {
            perror("poll");
            continue;
        }
        if (ret == 0)
        {
            printf("poll timeout\n");
            continue;
        }
        if (poll_fd.revents == POLLIN)
        {
            char buf[1024] = {0};
            read(0, buf, sizeof(buf) - 1);
            printf("stdin:%s", buf);
        }
    }
}

5.2 epoll

epoll 是 Linux 内核为处理大批量文件描述符(File Descriptor,FD)而设计的多路 I/O 就绪通知机制,于 Linux 2.5.44 内核中引入,被公认为 Linux 2.6 及以上版本中性能最优的多路 I/O 模型。它针对传统的 select/poll 机制的缺陷进行了根本性改进,尤其适合高并发场景(如网络服务器)。

    5.2.1 核心系统调用

    epoll 的使用依赖三个核心系统调用,分别负责创建句柄注册事件等待事件就绪

    (1)epoll_create:创建 epoll 句柄

    int epoll_create(int size);
    
    作用:创建一个 epoll 实例(句柄),用于管理后续注册的事件。
    参数:size 在 Linux 2.6.8 及以上版本中被忽略(早期用于提示内核预分配资源)。
    返回值:成功返回 epoll 句柄(非负整数),失败返回 -1 并设置 errno。

    注意:使用完毕后必须通过 close() 关闭句柄,释放内核资源。

      (2) epoll_ctl:事件注册 / 修改 / 删除

    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    
    作用:向 epoll 句柄注册、修改或删除需要监控的文件描述符及其事件。
    
    返回值:
    成功时:返回 0(整数零),表示事件注册、修改或删除操作已顺利完成。
    失败时:返回 -1,同时会设置全局变量 errno 来指示具体的错误原因。

    参数:

    epfd:epoll_create 返回的 epoll 句柄。

    op:操作类型,可选值:

    EPOLL_CTL_ADD:注册新的 FD 到 epoll 中。
    EPOLL_CTL_MOD:修改已注册 FD 的监控事件。
    EPOLL_CTL_DEL:从 epoll 中删除某个 FD(此时 event 可为 NULL)。

    fd:需要监控的文件描述符(如 socket、管道等)。
    event:指向 struct epoll_event 结构体的指针,描述需要监控的事件类型。
    struct epoll_event 结构:

    struct epoll_event {
        uint32_t events;  // 监控的事件类型(宏的集合)
        epoll_data_t data; // 用户数据(可存储 FD 或自定义指针)
    };

    两个 sockfd 的核心区别

    epoll_event.data.fd:用户自定义的 “关联数据”	
    事件触发后,通过 epoll_wait 返回的 epoll_event,快速识别 “哪个 fd 发生了事件”,
    还可灵活存储其他信息(如 fd 对应的上下文、状态等)。
    
    epoll_ctl 的第三个参数:epoll 操作的 “目标 fd 标识”	
    明确告知 epoll 实例:当前要执行 “添加 / 修改 / 删除” 监控的对象是哪个文件描述符,
    是 epoll 识别操作目标的唯一依据。

    其中 events 支持的事件类型:

    EPOLLIN:FD 可读(包括对端正常关闭)。
    EPOLLOUT:FD 可写。
    EPOLLPRI:FD 有紧急数据可读(如带外数据)。
    EPOLLERR:FD 发生错误(无需主动注册,内核会自动通知)。
    EPOLLHUP:FD 被挂断(如对端关闭,无需主动注册)。
    EPOLLET:边缘触发(Edge Triggered,ET)模式(默认是水平触发)。
    EPOLLONESHOT:仅监控一次事件,触发后需重新注册才能再次监控。

    (3)epoll_wait:等待事件就绪

    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    
    作用:等待 epoll 句柄中监控的 FD 发生就绪事件,并收集这些事件。
    
    返回值:
    成功:返回就绪事件的数量(0 表示超时)。
    失败:返回 -1 并设置 errno(如被信号中断)。

    参数:
    epfd:epoll 句柄。

    events:用户预先分配的 struct epoll_event 数组,用于存储就绪事件。

    maxevents:events 数组的最大长度(不能超过 epoll_create 时的 size,尽管 size 已被忽略,但需保证数组足够大)。

    timeout:超时时间(毫秒):

    -1:永久阻塞,直到有事件就绪。
    0:立即返回,无论是否有事件。
    正数:等待指定毫秒数后返回。

    5.2.2 两个关键数据结构和回调机制

    epoll 的高效性源于其内核设计,核心依赖两个关键数据结构和回调机制:

    (1)内核核心结构 eventpoll

    当调用 epoll_create 时,内核会创建一个 eventpoll 结构体,用于管理监控的事件:

    struct eventpoll {
        struct rb_root rbr;  // 红黑树根节点,存储所有注册的事件(epitem)
        struct list_head rdlist;  // 双链表,存储就绪的事件(待返回给用户)
        // 其他成员(如等待队列、锁等)
    };

    (2)事件节点 epitem

    每个注册到 epoll 的 FD 对应一个 epitem 结构体,用于关联事件信息:

    struct epitem {
        struct rb_node rbn;  // 红黑树节点(挂到 rbr 上)
        struct list_head rdllink;  // 双链表节点(事件就绪时挂到 rdlist 上)
        struct epoll_filefd ffd;  // 关联的 FD 信息
        struct eventpoll *ep;  // 指向所属的 eventpoll
        struct epoll_event event;  // 监控的事件类型(用户注册)
    };

    (3)工作流程

    1. 注册事件:通过 epoll_ctl 注册 FD 时,内核会创建 epitem 结构体,将其插入 eventpoll 的红黑树(rbr)中(红黑树保证高效的插入 / 删除 / 查找,时间复杂度 O (log n))。

    2. 事件触发回调:注册的 FD 会与设备驱动(如网卡)绑定回调函数 ep_poll_callback。当 FD 就绪(如可读 / 可写)时,驱动会调用该回调,将对应的 epitem 加入 eventpoll 的就绪双链表(rdlist)。

    3. 等待与返回:调用 epoll_wait 时,内核只需检查 rdlist 是否为空:

    不为空,将 rdlist 中的事件复制到用户态的 events 数组返回就绪事件数量(时间复杂度 O (1))。

    若为空,进程进入阻塞(或超时后返回 0)。


    (4)总结

     - 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件。

     - 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是 logn,其中 n 为树的高度)。

     - 而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法。

     - 这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdlist 双链表中。

     - 在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体

     - 当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的rdlist 双链表中是否有 epitem 元素即可

    epoll 的优点(和 select 的缺点对应)

     - 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离。

     - 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝)。

    - 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪。这个操作时间复杂度 O(1),即使文件描述符数目很多, 效率也不会受到影响。

     - 没有数量限制: 文件描述符数目无上限

    5.2.3 epoll与poll、select的对比

    维度selectpollepoll
    fd 数量限制有(默认 1024,由内核编译参数决定)无(仅受系统最大文件描述符限制)无(仅受系统最大文件描述符限制)
    事件存储结构fd_set(固定大小的位集合)struct pollfd 数组(动态大小)红黑树(管理注册的 fd)+ 双向链表(就绪事件)
    事件注册方式每次调用需重新设置关注的事件(输入输出参数混合)每次调用需传入整个 pollfd 数组(输入输出参数混合)提前通过 epoll_ctl 注册 / 修改 / 删除(输入输出参数分离)
    数据拷贝成本每次调用需将 fd_set 从用户态拷贝到内核态每次调用需将 pollfd 数组从用户态拷贝到内核态仅在 epoll_ctl 注册时拷贝(一次拷贝,后续复用)
    就绪事件获取遍历所有监控的 fd(O (n))遍历所有监控的 fd(O (n))直接获取就绪链表(O (1))
    触发模式仅水平触发(LT)仅水平触发(LT)支持水平触发(LT)和边缘触发(ET)
    适用场景连接数少且固定的场景(如简单的客户端)连接数中等的场景(比 select 灵活但效率有限)高并发、连接数多的场景(如服务器)

    6. epoll工作方式

    epoll 有 2 种工作方式---水平触发(LT)和边缘触发(ET)。

    6.1.水平触发LT

    默认模式:epoll 默认工作在 LT 模式,无需额外设置标志。

    核心特点:当文件描述符(如 TCP socket)上的事件就绪(如可读)时,epoll_wait 会反复通知该事件,直到缓冲区中的数据被完全处理。

    场景示例:
    若 socket 接收缓冲区有 2KB 数据,第一次调用 epoll_wait 会返回 “可读”;若只读取了 1KB(剩余 1KB),第二次调用 epoll_wait 仍会立刻返回 “可读”,直到 2KB 数据全部读完,epoll_wait 才不会再因该事件触发。

    支持的 I/O 方式:既支持阻塞读写,也支持非阻塞读写。

    6.2.ET边缘触发

    启用方式:将 socket 添加到 epoll 描述符时,需指定 EPOLLET 标志。

    核心特点:当文件描述符上的事件就绪时,epoll_wait 只会通知一次,无论数据是否被完全处理。若未及时处理所有数据,剩余数据不会再次触发事件,直到下次有新数据到来

    场景示例:
    同样是 socket 接收 2KB 数据,第一次 epoll_wait 返回 “可读”;若只读取 1KB(剩余 1KB),第二次调用 epoll_wait 不会返回(因事件仅通知一次),剩余 1KB 需等待客户端发送新数据时才会被再次触发。

    优势与适用场景:
    性能更高(epoll_wait 返回次数少),Nginx 等高性能服务器默认使用 ET 模式。

    限制:仅支持非阻塞读写,详细见6.3。

    使用 ET 能够减少 epoll 触发的次数。但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完。相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比 LT 更高效一些。但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的。

    另一方面,ET 的代码复杂程度更高了。

    6.3.理解 ET 模式和非阻塞文件描述符

    使用 ET 模式的 epoll,需要将文件描述设置为非阻塞。这个不是接口上的要求,而是 "工程实践" 上的要求。

    假设这样的场景:服务器接收到一个 10k 的请求,会向客户端返回一个应答数据。如果客户端收不到应答,不会发送第二个 10k 请求。

    如果服务端写的代码是阻塞式的 read,并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的 9k 数据就会待在缓冲区中

    此时由于 epoll 是 ET 模式,并不会认为文件描述符读就绪,epoll_wait 就不会再次返回,剩下的 9k 数据会一直在缓冲区中。

    直到下一次客户端再给服务器写数据,epoll_wait 才能返回。

    但是问题来了,服务器只读到 1k 个数据,要 10k 读完才会给客户端返回响应数据。

    而客户端要读到服务器的响应,才会发送下一个请求,客户端发送了下一个请求,epoll_wait 才会返回,服务器才能去读缓冲区中剩余的数据。
    最终导致形成死锁(服务器等剩余数据,客户端等响应)。

    所以,为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完),于是就可以使用 非阻塞轮询的方式来读缓冲区,保证一定能把完整的请求都读出来。

    如果是 LT 则没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪。

    6.4.epoll 的使用场景

    epoll 的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。

    对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。

    例如,典型的一个需要处理上万个客户端的服务器,,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。

    如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种 IO 模型。

    评论
    成就一亿技术人!
    拼手气红包6.0元
    还能输入1000个字符
     
    红包 添加红包
    表情包 插入表情
     条评论被折叠 查看
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值