%% # 纲要 > 主干纲要、Hint/线索/路标 # Q&A #### 已明确 #### 待明确 > 当下仍存有的疑惑 **❓<font color="#c0504d"> 有什么问题?</font>** 一些关键词: - 同步 I/O、异步 I/O; - 阻塞式 IO、非阻塞式 IO; - IO 多路复用: - **one loop per thread 的含义** - 每个 IO 线程有一个 event loop(也称 Reactor),用于处理读写和定时事件。 - I/O 多路复用,是否要求**每个 I/O 函数**一定是 "**非阻塞**" 的? - Reactor 模式: - **事件循环**、**事件驱动**; %% # UNIX/Linux 下五种可用的 I/O 模型 - 阻塞式 I/O - 非阻塞式 I/O - **I/O 多路复用**(select、poll、epoll、kqueue),也即 "**事件驱动式 I/O**" - **信号驱动式 I/O** - **异步 I/O**(POSIX 的 AIO 库) > [!NOTE] 五种 I/O 的处理过程比较 > > 图片参见[^1] > ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-DE01ACCE1EDC5C1C1AD4A6A6612B9E90.png|783]] > <br><br><br> # 阻塞、非阻塞、同步、异步 I/O 一个 I/O 操作可能存在两个 "等待" 阶段[^2]: 1. 「**等待内核数据准备就绪**」; 2. 「**等待数据在内核缓冲区与用户空间之间完成拷贝**」。 根据 "**数据就绪时,是否阻塞==等待 I/O 操作完成==**"(过程二)划分为 「**同步 I/O**」 与 「**异步 I/O**」。 在同步 I/O 中,根据 **是否等待 "==内核数据就绪=="**(过程一) 进一步划分为 「**阻塞 I/O**」 与 「**非阻塞 I/O**」: - 「**同步 I/O**」:当**内核数据就绪时**,将等待 I/O 操作完成并返回结果——**阻塞等待数据在 "内核缓冲区" 与 "用户空间" 之间完成拷贝**; - 「**阻塞 I/O**」: - 发起 I/O 操作后进程 or 线程**将被==挂起==**,**阻塞等待直至 I/O 操作 "==完成==" 并返回结果**。 - 「**非阻塞 I/O**」: - 发起 I/O 操作后,若 "**==内核数据未就绪==**" 则立即返回,**标记发生错误**;若内核数据已就绪,则**将执行 I/O 过程,阻塞等待 I/O 操作完成**。 - 「**异步 I/O**」:发起 I/O 调用后**立即返回而不等待**,**I/O 操作交==由内核执行==**,当 I/O 执行完成后由内核 **==通知==** 进程。 - 特点: - **非阻塞**(两个过程均不等待) - **交由==内核==执行实际 I/O 操作** - **==通知==机制**(I/O 操作执行完成后,进程会收到**内核的通知**) <br><br> ## 阻塞 I/O 与非阻塞 I/O 以 TCP 连接下的 `read()` 、`write()` 为例: | | `read(sockfd, buf, n)` | `write(sockfd, buf, n)` | | --------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | | 操作 | 内核将数据从 "**内核 TCP 接收缓冲区**" 拷贝到 "**应用进程缓冲区**"。 | 内核将数据从 "**应用进程缓冲区**" 拷贝到 "**内核 TCP 发送缓冲区**"。 | | **阻塞模式** | 阻塞直至 "有数据 & 数据拷贝到用户空间缓冲区" 后 | 阻塞直至 **指定字节数据全部写入内核缓冲区**。 | | **非阻塞模式** | - 内核缓冲区有数据,则**读取数据到用户空间缓冲区后**返回成功读取的字节数 <br>- 内核缓冲区无数据,则**立即返回-1**,`errno` 为 `EAGAIN` 或 `EWOULDBLOCK` | - 内核缓冲区未满,则**尽可能写入数据到内核缓冲区**,返回成功写入的字节数 <br>- 内核缓冲区已满,则**立即返回-1**,`errno` 为 `EAGAIN` 或 `EWOULDBLOCK | > [!info] `errno` 为 `EAGAIN` 或 `EWOULDBLOCK` 的含义 > > - `EAGAIN`:资源暂不可用,**请重试**。 "Error Again". > - `EWOULDBLOCK`:操作**会导致阻塞**。"Error Would Block". > > 两个错误码在**现代 OS** (例如 Linux)中是完全相同的,通常定义有 `#define EWOULDBLOCK EAGAIN`。 > 在**非阻塞 I/O** 中,都表明**当前内核缓冲区**无数据可读 or 不可写入。 > > 参见 `man 3 errno` 如下: > ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-B68963143A727CCB120446CD4CBFBCC3.png|770]] ##### 阻塞式 I/O - 调用 `write(sockfd, buf, n): - 将**阻塞**直至 **"应用进程缓冲区" 中==指定字节的数据==** 被拷贝到 TCP 缓冲区。 - 当 **应用层数据量** > **TCP 发送缓冲区剩余空间** 时,则内核将**分多次拷贝**,**阻塞直至所有数据拷贝完成**。 - 即当 **发送缓冲区==已满==** 时,将**阻塞等待缓冲区可用**。 - 若返回值不等于 `n`,表明**调用出错**(例如磁盘已满或被信号中断) - 调用 `read(sockfd, buf, n)`: - 将**阻塞**直至从 **"当前内核缓冲区" 中拷贝 ==`min(当前内核缓冲区中EOF之前的字节数, n)` 个字节==** 到 "**应用进程缓冲区**"。 - 内核缓冲区无数据时,**阻塞等待数据**。 - 只要内核缓冲区中**有数据可读**,就会**读取==当前已有数据==并返回**,即使**缓冲区中数据少于 `n`**,也不会继续等待。 - 若只读取到 EOF 返回 0; ##### 非阻塞式 I/O - 调用 `write(sockfd, buf, n)` - 若 **TCP 发送缓冲区==未满==**(剩余空间可能小于 n)则 **尽可能写入数据**,返回 **成功写入的字节数**(`(0, n]`)。 - 若 **TCP 发送缓冲区==已满**==(无法写入任何字节),则**立即返回 -1**,且设置`errno` 为 `EAGAIN` 或 `EWOULDBLOCK` - 调用 `read(sockfd, buf, n)`: - 若 TCP 接收缓冲区**有数据**,则从 **"当前内核缓冲区" 中拷贝 ==`min(当前内核缓冲区中EOF之前的字节数, n)` 个字节==** 到 "**应用进程缓冲区**"(该过程阻塞),而后**返回读取到的字节数**。 - 若**无数据**,则直接返回 `-1`,且设置 `errno` 为 `EAGAIN` 或 `EWOULDBLOCK` > [!question] ❓ Linux 下,单次 `read` 可读取的字节数似乎 **"不严格受限于 `SO_RCVBUF`"** > [!NOTE] 开启 "非阻塞 I/O" 模式的两种方式 > > - 调用 `open` 打开文件时,指定 `O_NONBLOCK` 标志; > - 对于已打开的文件描述符,调用 `fcntl`,设置 `O_NONBLOCK` 文件状态标志。 > > [!NOTE] 无论是阻塞还是非阻塞 I/O,**单次 `read(,,n)` 都不一定能读满 n 字节**,故**需要多次循环读** <br><br> ## 同步 I/O 与异步 I/O 的区别 | | 完成 I/O 操作的主体 | 内核向程序通知的事件 | 是否阻塞 | | ------ | ------------ | ------------ | -------------------------------------------------------------------------------------------- | | 同步 I/O | 用户程序 | I/O 就绪事件 | - 对于 "**阻塞 I/O**":内核数据未就绪时阻塞 & 数据在内核与用户空间之间拷贝时阻塞; <br>- 对于 "**非阻塞 I/O**":数据在内核与用户空间之间拷贝时阻塞; | | 异步 I/O | 内核 | **I/O 完成事件** | 调用后立即返回,均不阻塞。 | > [!caution] "阻塞 I/O" 与 "非阻塞 I/O" 都是 "**==同步==**" 调用——当内核中数据就绪时,在执行 I/O 操作后需要 "**==等待==**" I/O 操作完成。 > > **同步 I/O**:包括 "阻塞 I/O" 与 "非阻塞 I/O" > > ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-6242FC50A91206156079F8BBFD2F522F.png|754]] > > **异步 I/O**: > > ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-4B37A1DDA9E9FDE6A9D5D2D24BAA1548.png|655]] > > > [!NOTE] 异步 I/O 相较于 "**轮询**" 的优点: > > - **==轮询==**:在一个 while 循环中,每一个文件描述符依次调用 "**==非阻塞== I/O**",即**依次检查是否有数据可读或可写**, > - 缺点:**浪费 CPU 时间**——大多数时间上,实际无数据可读。 > > - **异步 I/O**:发起 I/O 操作后**不阻塞,无需等待**,不占用 CPU 时间,只需**等待 OS 通知**即可。 > > [!info] POSIX 标准提供了 " POSIX 异步 I/O 库——AIO" <br><br><br> # I/O 多路复用 **I/O 多路复用**(**I/O multiplexing**)是指 "**==单个线程或进程== 同时 ==监控多个 I/O 事件==**" 的方法,即**多个 I/O 请求 "复用" 一个进程 or 线程**。 ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-466CCA3CE61F24D745BEC10D9A159514.png|414]] I/O 多路复用是 **高并发服务器** 和 **网络应用程序** 中常用的技术。 在 Unix/Linux 系统中,实现 I/O 多路复用主要基于 **`select`、`poll` 、 `epoll` 、`kqueue`等 API**。 > [!info] `epoll` 为 Linux 特有,`kqueue` 为 BSD 系列特有 <br> ## I/O 多路复用的基本原理 I/O 多路复用的基本原理是**通过一个系统调用==阻塞==**,在多个文件描述符上**等待 I/O 事件的发生**(例如:数据可读、可写、连接请求等)。 一旦事件发生,内核会返回相关的 **"==文件描述符列表=="以及 "==触发事件=="**,供应用程序处理。 I/O 多路复用的基本工作流程: - **注册 I/O 事件**:注册需要监视的 "**==文件描述符集==**" 和对应的 "**==事件==**"(可读、可写或异常)。 - **一个主循环**(Event Loop):在 while 循环中持续等待和处理 I/O 事件。 - **等待事件发生**:调用 `select()`、`poll()` 或 `epoll_wait()` 函数,将**进程或线程==阻塞挂起==**,等待注册的文件描述符上有事件发生。 - **处理 I/O 事件**:监听事件发生后,**系统调用返回事件的文件描述符列表**,应用程序可以**遍历这些文件描述符**并处理对应的 I/O 事件。 <br> ## 边缘触发与水平触发 在 I/O 多路复用下,**文件描述符就绪** 后的 **通知模式**(即触发 `select`/`poll`/`epoll_wait` 返回)有两种: - **水平触发**(level-trigger,LT):只要文件描述符上**可以被非阻塞地执行 I/O 系统调用**(数据可读或可写,**为 I/O 就绪态**),就能一直 **==触发事件通知==**。 - 对于读:只要文件描述符对应的内核缓冲区中 **还有==剩余数据==可读**,则触发。 - 对于写:只要文件描述符对应的内核缓冲区中 **还有==剩余空间==可写**,则触发。 - **边缘触发**(edge-trigger,ET):仅当**文件描述符状态由 "不可用=>可用"** 时 **触发==一次==通知**。 - 对于读:仅当 "**不可读 =>可读**",即 "**内核接收缓冲区由 ==空== =>==非空=="** 时,触发一次通知。 - 对于写:仅当 "**不可写 =>可写**",即 "**内核发送缓冲区由 ==已满== =>==未满=="** 时,触发一次通知。 > [!NOTE] I/O 多路复用通常都是与 "==非阻塞 I/O==" 搭配使用。 > > 在两种触发模式下,通常采取的做法如下(以 epoll 为例) > > 「**水平触发 LT**」模式下: > > - 对于读:进程可以一直挂着 EPOLLIN 监听,**只做单次 `read()`,未读取完则 EPOLLIN 始终有效,可反复读取**; > - 例如,muduo 的做法是,LT 下借用 64KB 的临时栈空间作为接收缓冲区,**只调用一次 `readv()` 读取**,未读完则**继续挂着 `EPOLL_IN`,下次再读**。 > - 对于写:进程可以**只做单次 `write()`**,但 **==不能一直挂着 EPOLLOUT 监听==** 🚨,否则只要发送缓冲区未满,始终触发通知,导致 buzy-loop。 > - 若**未写完预期发送数据(缓冲区满)**,则**手动注册监听 `EPOLLOUT`**,**等待下次缓冲区可写时,继续写入**。 > - 若**写完了预期发送数据**,则**必须==注销==监听 `EPOLLOUT`**。 > > 「**边缘触发 ET**」模式下: > > - 对于读:**进程==必须==读取完缓冲区中全部数据=>即在 while 中==循环读取直至触发 `EAGAIN`==**。否则,若进程**没有从内核缓冲区中读完所有数据**,**==还有数据剩余==**,则**此后不会再触发通知**,即使有新数据到来也不会触发通知,直至内核接收缓冲区被读取为空后又再收到新数据。 > - 对于写:进程可以一直**挂着 EPOLLOUT 监听**,**只做单次 `write()`**,**单次没有 `write` 完全部数据就等内核发送缓冲区可写、即 EPOLLOUT 事件触发时再写**。 > > [!info] > > - `select`、`poll` 仅支持 "**水平触发**"; > - `epoll` 同时支持 "**水平触发**"(默认) 与 "**边缘触发**"。 <br> ## select 函数 > 头文件 `<sys/select.h>` ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-9057D692D176F4BA1EFF04DEA7BD105A.png|954]] 传给 `select()` 的三个参数: - `maxfdpl`:内核**需要检查的描述符数(从 0 开始**),即三个文件描述符集中的 "**最大文件描述符编号**" + 1; - `readfds`,`writefds` ,`exceptfds`: 分别指定需要检测 "可读、可写、处于异常情况" 的**文件描述符集合**。 - `timeval`:等待时长 #### 文件描述符注册 `select` 使用 **`fd_set` 数据类型** 来表示 "**==文件描述符集合==**",其具体类型取决于实现,可以是 "**字节数组**" or "**位图**"。 > [!info] `fd_set` 表示的文件描述符集合具有 "**最大容量限制**",该值由常量 `FD_SETSIZE` 决定,在 Linux 上通常是 1024 采用下列 4 个函数处理 "**文件描述符集**": - `FD_ZERO(&fdset)` :将文件描述符集**清零** - `FD_SET(fd, &fdset)`:**注册**指定描述符 - `FD_CLR(fd, &fdset)`:**移除**指定描述符 - `FD_ISSET(fd, &fdset)`:**测试** 指定描述符上是否有 "事件" 发生,用以**在 `select` 返回之后检查各个描述符**。 #### select 接口的使用示例 > [!caution] 每次调用 `select` 前,都需要使用 `FD_ZERO` 将文件描述符**清零再重新设置**,避免**残留的状态**影响当前循环。 ```c /* I/O 多路复用: 使用select接口的示例流程 */ // 文件描述符集合 fd_set readfds; // 事件循环 while (1) { // 清空并设置监听的文件描述符集合. FD_ZERO(&readfds); for (int fd = minFD; fd < maxFD; fd++) { FD_SET(fd, &readfds); } // 调用select等待事件发生(阻塞) int rc = select(maxFD + 1, &readfds, NULL, NULL, NULL); // 检查发生的事件, 进行响应 for (int fd = minFD; fd < maxFD; fd++) { if (FD_ISSET(fd, &readfds)) { // 测试描述符fd上是否有事件 processFD(fd); } } } ``` <br> ## poll 函数 > 头文件 `<poll.h>` ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-01C3161CFF01568965A186AD9841FE92.png|817]] `poll` 的参数: - **`pollfd` 结构的数组**:每个数组元素指定一个 "**==描述符编号==**" 以及 "**==监听事件==**"。 - **`nfds`** :整型,指定**数组中的 "有效" 元素数量**(实际有效的项数,前 `nfds` 项) - `timeout`:等待时长 #### 文件描述符注册 将需要监听的文件描述符**保存在 `pollfd` 结构体数组**中,传递给 `poll`. 当 `poll` 返回后,检查 `pollfd` 结构中的 `revents`,**将其与特定值(例如 `POLLIN` )做按位与**,从而**判断是否有指定事件发生**。 > [!info] `pollfd` 结构 > > ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-3A56A4CE462B97D1F1711216DFF54ED1.png|565]] > > `events` 与 `revents` 均为位掩码。 其中 `revents` 在 `poll` 函数返回时由 "**内核**" 设置,说明了**该文件描述符上发生的事件**。 #### poll 接口的使用示例 ```c /* I/O 多路复用: 使用poll接口的示例流程 */ // pollfd结构体数组, 用于监听多个文件描述符 struct pollfd fds[N]; // 设置要监听的 "文件描述符" 以及 "监听事件" fds[0].fd = fd1; fds[0].events = POLLIN; // 监听可读事件(输入数据) fds[1].fd = fd2; fds[1].events = POLLIN; // ... // 设置超时时间(单位毫秒), -1表示永久等待 timeout = 5000 // 事件循环 while (1) { // 调用poll, 等待事件发生(阻塞) int ret = poll(fds, N, timeout); // 检查超时 if (ret == 0) { printf("超时\n"); continue; } // 检查发生的事件: 查看revents if (fds[0].revents & POLLIN) { processFD(fds[0].fd); } if (fds[1].revents & POLLIN) { processFD(fds[1].fd); } } ``` <br> ## epoll 函数 > 头文件 `<sys/epoll.h> ` **epoll API** [^3]基于一个**由内核维护** 的 **==`epoll` 实例==** ,其与一个 "**文件描述符**" 关联,该描述符仅用于**代表该实例**,并不用于 I/O。 epoll 实例中维护了**两个列表**: - (1)**已注册**监听的文件描述符列表(interest list) - (2)**==已处于 I/O 就绪态==** 的文件描述符列表(ready list) #### 相关函数 - `epoll_create()` 、`epoll_create1()` 用于 **创建一个 epoll 实例**,返回与该实例关联的 "**文件描述符**"。 - `epoll_ctl(epfd, op, fd, *ev)`: **添加/移除/修改** epoll 实例 `epfd` 中,描述符 `fd` 对应的 "**监听事件**"——**由 `ev` 结构指示**。 - `epoll_wait(epfd, *evlist, maxevent, timeout)`:返回 epoll 实例中处于 "**就绪态**" 的文件描述符信息——**由 `evlist` 数组给出**。 其中,`*ev` 、`*evlist`是**指向结构体 `epoll_event` 的指针**。 > [!info] `epoll_event` 结构体 > ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-D97BE6B35F1F1C4F0D2006A0B85EA42A.png|436]] > 其中 `data` 字段类型为: > > ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-2DAB230568079BB8599BC21F1FB7C30F.png|432]] > #### epoll 监听事件 | 事件 | 描述 | 触发示例 | 作为 `epoll_ctl()` 输入 | 由 `epoll_wait()` 返回 | | ------------ | ----------- | ------------------------------------------------------ | ------------------- | ------------------- | | EPOLLIN | 套接字可读 | 读缓冲区可读 <br>(包括为 EOF, `read()==0` 的情况) | ✔️ | ✔️ | | EPOLLPRI | 可读取高优先级数据 | TCP 带外数据 | ✔️ | ✔️ | | EPOLLOUT | 套接字可写 | 发送缓冲区可写,或 `connect()` 完成 | ✔️ | ✔️ | | EPOLLRDHUP | 对端半关闭 <br> | 对端调用`shutdow(SHUT_WR)` 或 `close()`, <br>**本端收到 FIN**; | ✔️ | ✔️ | | EPOLLHUP | 连接断开 | 例如**本端收到 RST**,表明连接已断开 | ❌ | ✔️ | | EPOLLERR | 错误发生 | | ❌ | ✔️ | | EPOLLET | 边缘触发,ET | | ✔️ | ❌ | | EPOLLONESHOT | 事件只触发一次 | | ✔️ | ❌ | > [!NOTE] 区分「EPOLLHUP」与「EPOLLRDHUP」 > > - `EPOLLHUP` 且 `!EPOLLIN`,则明确 **对端异常断开连接**。 > - `EPOLLRDHUP` 用以精确地表示 "**==对端半关闭==**",可通过监听 `EPOLLRDHUP` 替代 `EPOLLIN + read()==0` 的检查。 > #### 使用说明 - 1)通过 `epoll_create()` 创建 epoll 实例 - 2)声明 **`epoll_event` 结构**,通过 `epoll_ctl()` 函数向已创建的 epoll 实例中**添加**文件描述符及其对应的监听事件; - 3)调用 **`epoll_wait()`** 并**传入 `epoll_event` 结构体数组**,阻塞等待事件发生——处于 "I/O就绪态" 的文件描述符的信息**存储于该结构体数组中**。 > [!note] "**==多个 epoll 实例==**" 同时监听 "**==同一个文件描述符==**" > > 一个文件描述符可以被 **==多个 epoll 实例==** 同时注册,并监听相同的事件。 > 每个 epoll 实例维护独立的监听事件列表,互不影响。 > > 故当该文件描述符上发生监听事件时,**所有包含该文件描述符的 `epoll` 实例都会收到相应的通知**。 > [!note] "**==多个 `epoll_wait`==**" 同时监听 “**==同一个 epoll 实例==**” > > 若 **多个 `epoll_wait()`** 同时对 **==同一个 epoll 实例==** 调用(例如分属不同线程), > 则该 epoll 实例中监听事件发生时,**只会==触发一个== `epoll_wait()` 返回**。 > > 具体返回哪个取决于内核调度,但内核保证 "**只会通知一个 `epoll_wait()`**"。 > #### epoll 接口的使用示例 ```cpp #include <stdlib.h> #include <unistd.h> #include <netinet/in.h> #include <sys/epoll.h> #include <stdio.h> #include <string.h> #define PORT 8080 #define MAX_EVENTS 10 #define BUFFER_SIZE 1024 /* 基于epoll的I/O多路复用: TCP Server示例 */ int main(int argc, char** argv) { int listen_fd, client_fd; struct sockaddr_in address; socklen_t addrlen = sizeof(address); const char* msg = "Hello, client!"; // 创建服务器套接字 if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) { perror("socket"); exit(EXIT_FAILURE); } // 设置服务器地址 address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; address.sin_port = htons(PORT); // 绑定套接字 if (bind(listen_fd, (struct sockaddr*)&address, sizeof(address)) == -1) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } // 开始监听 if (listen(listen_fd, MAX_EVENTS) == -1) { perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } printf("服务器正在监听端口 %d...\n", PORT); // -----基于poll的I/O多路复用----- // 创建 epoll 实例 int epoll_fd; if ((epoll_fd = epoll_create1(0)) == -1) { perror("epoll_create1"); close(listen_fd); exit(EXIT_FAILURE); } // 声明 epoll_event 结构. // `ev`用于临时表示文件描述符及监听事件, 从而传入`epoll_ctl`进行注册. // `events`数组用于传给`epoll_wait`, 接收处于"I/O就绪态"的文件描述符. struct epoll_event ev = {0}, events[MAX_EVENTS]; // 将服务器套接字listen_fd添加到epoll监听列表. ev.events = EPOLLIN; ev.data.fd = listen_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) { perror("epoll_ctl"); close(listen_fd); close(epoll_fd); exit(EXIT_FAILURE); } // epoll 主循环 int nfds, timeout = -1; while (1) { // 等待事件, 返回值为处于就绪态的文件描述符数量. nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, timeout); // 遍历events数组: 检查处于I/O就绪态的各个描述符. for (int i = 0; i < nfds; ++i) { // 事件来自listen_fd, 则表明需处理新连接 if (events[i].data.fd == listen_fd) { client_fd = accept(listen_fd, (struct sockaddr*)&address, &addrlen); if (client_fd == -1) { perror("accept"); continue; } // 将新的客户端套接字添加到epoll监视列表. ev.events = EPOLLIN; ev.data.fd = client_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) == -1) { perror("epoll_ctl: client_fd"); close(client_fd); } printf("New connection: socket fd %d\n", client_fd); } else { // 事件来自其他fd, 表明有来自客户端的数据, 需处理客户端数据 client_fd = events[i].data.fd; char buffer[BUFFER_SIZE]; ssize_t rd_n = read(client_fd, buffer, sizeof(buffer) - 1); if (rd_n <= 0) { // 客户端关闭了连接. close(client_fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); // 从epoll实例中移除对该描述符的监听 printf("Client disconnection: socket fd %d\n", client_fd); } else { // 处理接收到的数据 buffer[rd_n] = '\0'; // 确保字符串结束 printf("Received from client(%d): %s\n", client_fd, buffer); ssize_t send_n = send(client_fd, msg, strlen(msg), 0); // 回送数据. if (send_n == -1) { perror("send"); } } } } } // 清理 close(listen_fd); close(epoll_fd); return 0; } ``` <br> ## kqueue Kqueue 是 BSD 系列中**实现 I/O 多路复用**的 API,类似于 Linux 中的 `epoll`,采用**事件驱动**的模型,旨在解决**高并发场景下的 I/O 多路复用**问题。 <br> ## poll, select, epoll 的区别 **`select()`** 和 **`poll()`** 的功能非常相似,都用于**在单线程中==监控多个文件描述符的状态==,处理 I/O 操作**。 > [!info] **`select` 最早出现于 BSD**,而 **`poll` 出现于 System V**,`epoll` 则是 Linux 特有 API。 #### 差异对比 - API 差异: - `select()` - 通过 `fd_set` 结构体来管理**监控的文件描述符**,**监听一组文件描述符集合,判断它们是否可读、可写或有异常**。 - 每轮事件循环中,需要**先通过 `FD_ZERO`清空文件描述符集合,再通过 `FD_SET` 重新设置** - `poll()` **使用 `struct pollfd` 动态数组**来管理要监控的**文件描述符**以及**监听事件类型**,不受文件描述符数量限制。 - `epoll` 通过 **`epoll` 实例**来管理监控的文件描述符及监听事件, - **可监听的文件描述符数量**限制: - `select()` 受限于 `FD_SETSIZE`,默认只能处理 1024 个文件描述符; - **设置监听事件类型** - `select()` 不支持设置监听事件类型,只具**有事件监听/通知**功能。 - `poll()`、`epoll` 允许**对每个文件描述符分别设置监听的事件类别**,例如 `POLLIN` 、`EPOLLIN`等 - 触发方式: - `select`、`poll` 仅支持 "**水平触发**"; - `epoll` 支持 "**水平触发**"(默认) 与 "**边缘触发**"。 #### epoll 效率远高于 select、poll 的原因 - (1)**内核对文件描述符的检查方式** - 每次调用 `select`、`poll` ,**内核需要==遍历所有注册的文件描述符==进行检查**。 - 对于 `epoll`,**内核会监视已注册的文件描述符的状态变化**,当一个描述符为 I/O 就绪状态时,内核就会**将其==添加到 epoll 实例的 "就绪列表"==**,调用 `epoll_wait` 时,**内核==只需要遍历并返回该就绪列表==**,而无需遍历所有注册的文件描述符。 - (2)**对文件描述符集合的传递**: - 每次调用 `select` 、`poll` 时需要传递**代表"被监视" 的文件描述符集合的数据结构**给内核; - 向 epoll 实例中注册描述符和事件后,**调用 `epoll_wait` 时无需再传递文件描述符相关的信息**,减少了**内核与用户空间**之间的数据拷贝。 - (3)**用户程序的文件描述符的检查**: - 对于 `select` 、`poll`,**用户程序需要==自行遍历、检查所有已注册的文件描述符==**,找出处于 I/O 就绪状态的描述符。 - 对于 `epoll`,**调用 `epoll_wait()` 只返回 "有事件发生" 的文件描述符**,用户无需全部遍历。 <br><br> # C10K 问题 > C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。 C10K 问题:C 是 Client 的缩写,C10K 是指 **单机如何高效地同时处理 1 万个请求** 的问题。 这一问题描述了**网络服务器设计和实现中面临的挑战**,尤其是在**高并发场景下的性能和资源管理**。 解决 C10K 问题的方法: - **==异步I/O==**: - 使用异步编程模型(如回调函数)来处理I/O操作,避免阻塞,从而提高并发处理能力。 - **==事件驱动的I/O模型==**: - 采用 `epoll`、`kqueue` 等**事件驱动的I/O多路复用**技术,这些技术能够高效管理大量并发连接。 - **非阻塞 I/O** - 设置描述符为 "**非阻塞模式**",避免因等待 I/O 操作而导致的线程或进程阻塞。 - **轻量级线程** - 使用协程或轻量级线程库(如 `libuv`、`boost.asio`)来减少上下文切换的开销。 随着技术的进步和现代硬件的提升,**现代服务器(如Nginx、Node.js等)都能够轻松处理数万个并发连接**,进一步演变为 C100K 或更高的挑战。 <br><br> # I/O 模型分类 ⭐ ## 事件驱动的 I/O 模型 「**事件驱动**」(event-driven)是一种**编程模型**,其要点包括: - **==事件循环==**(evet loop,也称 **Reactor**):单个进程 or 线程上运行一个**主循环**,持续地**等待事件发生**并进行处理; - **==回调机制==**:当**事件发生**时,**调用与事件关联的回调函数**进行处理。 该模型下的工作流程如下: 1. **事件注册**:程序将预期 "**事件**" 及对应的 "**事件处理函数**" 进行注册; 2. **事件监听**:"事件循环" 中**等待 I/O 事件发生** 3. **事件处理**:当事件发生时,**事件循环会调用注册的回调函数**来处理该事件。 应用程序通过事件循环和回调函数响应事件的发生,而非 "**轮询**"。(轮询会占据 CPU,而事件驱动下,阻塞等待时挂起,不占 CPU) > [!NOTE] **事件驱动 I/O** 通常通过 **"I/O 多路复用"** 机制实现(基于 `select`, `poll`, `epoll`,`kqueue` 等 API,这些 API 提供了高效的事件通知机制)。 <br> ## 信号驱动的 I/O 模型 **信号驱动 I/O** (single-driven I/O)依赖于**信号机制**来通知 I/O 状态变化(**内核描述符就绪时发送 `SIGIO` 信号通知进程**),适用场景较窄。 ![[_attachment/02-开发笔记/99-Unix 环境编程/高级 IO.assets/IMG-高级 IO-A7BA4E5814592EBA9CD4CDDC40F30ECC.png|792]] %% # Buffer ## 闪念 > sudden idea ## 候选资料 > Read it later %% <br><br> # ♾️参考资料 - [9.2 I/O 多路复用:select/poll/epoll | 小林coding](https://xiaolincoding.com/os/8_network_system/selete_poll_epoll.html#i-o-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8) # Footnotes [^1]: 《UNIX 网络编程:卷 1》 P127 [^2]: 《UNIX 网络编程:卷 1》 P123 [^3]: 《Linux-UNIX 系统编程手册》第 63.4 章节,P1113