Linux网络通信(三)----多路IO复用
一、什么是 IO 多路复用1. 核心定义IO 多路复用本质是单线程 / 单进程同时监测多个文件描述符fd等待 IO 事件读 / 写 / 异常就绪的通知机制。简单来说就是让一个执行体进程 / 线程同时 “盯着” 多个 IO 设备比如 socket、管道、标准输入当其中某个 / 某些设备就绪可以读 / 写时就通知程序去处理避免了为每个 IO 单独开线程 / 进程的资源浪费。2. 为什么需要它我们日常的电脑需要同时处理键盘、鼠标输入、中断信号Web 服务器比如 Nginx需要同时处理成千上万客户端的连接请求。如果用传统的阻塞 IO一个线程只能处理一个连接并发量上来后线程数会爆炸资源消耗极高。IO 多路复用的核心作用就是用单个执行体高效检测多个阻塞 IO 设备的就绪状态用极低的资源开销实现高并发二、Linux 5 种 IO 模型全解析在深入 select/epoll 之前必须先搞懂 Linux 下的 5 种 IO 模型这是理解多路复用的基础IO 模型核心特点适用场景阻塞 IO默认调用 IO 操作时线程会一直阻塞直到数据就绪简单场景单连接处理并发能力差非阻塞 IOIO 操作立即返回没数据返回EAGAIN需要轮询忙等待CPU 占用高很少单独使用信号驱动 IOSIGIO内核数据就绪时发信号通知进程继续做其他事用得极少兼容性和稳定性一般并行模型多进程 / 多线程每个 IO 对应一个进程 / 线程各自阻塞等待并发量低时可用高并发下资源爆炸IO 多路复用select/poll/epoll单线程监测多个 fd等待就绪事件批量处理高并发服务器Nginx/Redis 等中间件核心2.1 阻塞 IO读端代码#include stdio.h #include stdlib.h #include unistd.h #include string.h #include sys/types.h #include sys/stat.h #include fcntl.h #include errno.h int main(int argc, char **argv) { // 1. 创建FIFO文件写端可能已创建此处做兼容 // mkfifo不阻塞仅处理非EEXIST的错误如权限问题 int ret mkfifo(myfifo, 0666); if (-1 ret) { if (EEXIST errno) // FIFO已存在无需处理 { // } else // 其他错误打印并退出 { perror(mkfifo); return 1; } } // 2. 阻塞IO核心点1以只读方式打开FIFO会阻塞直到有写端以O_WRONLY打开myfifo // 若没有写端打开进程会停在这里直到写端连接 int fd open(myfifo, O_RDONLY); if (-1 fd) { perror(open myfifo); return 1; } while (1) { char buf[100] {0}; // 3. 阻塞IO核心点2读端read若FIFO无数据则阻塞直到写端写入数据 // 数据就绪后read才会返回读取的字节数否则进程睡眠释放CPU read(fd, buf, sizeof(buf)); printf(fifo :%s\n, buf); bzero(buf,sizeof(buf)); // 清空缓冲区不涉及阻塞 // 4. 额外阻塞点标准输入的阻塞IO等待终端输入数据否则阻塞 fgets(buf,sizeof(buf),stdin); printf(terminal:%s,buf); fflush(stdout); // 刷新输出缓冲区避免打印延迟 } close(fd); // remove(myfifo); return 0; }阻塞 IO核心特征是当系统调用如 read/write/open无法立即完成时进程 / 线程会被挂起阻塞直到资源就绪或操作完成期间不会占用 CPU 资源。结合 FIFO命名管道代码场景核心要点如下1. FIFO 的阻塞特性创建特性mkfifo仅创建管道文件不涉及数据读写本身不阻塞打开阻塞以O_WRONLY打开 FIFO 时会阻塞直到有进程以O_RDONLY打开该 FIFO以O_RDONLY打开 FIFO 时会阻塞直到有进程以O_WRONLY打开该 FIFO读写阻塞读端O_RDONLY调用read时若管道无数据会阻塞直到写端写入数据写端O_WRONLY调用write时若管道缓冲区满会阻塞直到读端读取数据本案例中数据量小未触发此场景2. 阻塞 IO 的通用特征阻塞阶段进程从 “运行态” 转为 “睡眠态”释放 CPU直到事件就绪如 FIFO 被打开、有数据可读就绪后进程被内核唤醒转为 “就绪态”等待 CPU 调度后完成系统调用无需主动轮询相比非阻塞 IO阻塞 IO 代码更简洁无需循环检查状态。3. 本案例额外阻塞点读端代码中fgets(buf, sizeof(buf), stdin)会阻塞等待终端输入属于标准输入的阻塞 IO 场景。2.2 非阻塞IO#include stdio.h #include stdlib.h #include unistd.h #include string.h #include sys/types.h #include sys/stat.h #include fcntl.h #include errno.h int main(int argc, char **argv) { // 1. 创建FIFO0666是权限最终受umask影响 int ret mkfifo(myfifo, 0666); if (-1 ret) { if (EEXIST errno) // 管道已存在时不报错避免重复创建失败 { printf(FIFO已存在无需重复创建\n); } else { perror(mkfifo); return 1; } } // 2. 阻塞式打开FIFO读端默认行为会阻塞直到写端打开 // 若想open时就非阻塞可改为open(myfifo, O_RDONLY | O_NONBLOCK) int fd open(myfifo, O_RDONLY); if (-1 fd) { perror(open myfifo); return 1; } // 非阻塞IO核心操作 // 3. 获取文件描述符当前的状态标志F_GETFLget file status flags int flag fcntl(fd, F_GETFL); if (flag -1) { // 容错获取标志失败时处理 perror(fcntl F_GETFL); close(fd); return 1; } // 4. 设置非阻塞标志F_SETFLset file status flags // 核心通过 | O_NONBLOCK 追加非阻塞属性保留原有标志 if (fcntl(fd, F_SETFL, flag | O_NONBLOCK) -1) { perror(fcntl F_SETFL); close(fd); return 1; } // 5. 给标准输入stdin文件描述符0也设置非阻塞模式演示终端输入非阻塞 flag fcntl(0, F_GETFL); fcntl(0, F_SETFL, flag | O_NONBLOCK); // 6. 循环轮询检测非阻塞IO非阻塞IO核心通过循环持续检测数据 while (1) { char buf[100] {0}; // 7. 非阻塞读FIFO无数据时立即返回-1有数据时返回读取的字节数 ssize_t read_len read(fd, buf, sizeof(buf)); if (read_len 0) // 成功读取到数据 { printf(fifo :%s\n, buf); } // 非阻塞读无数据时read返回-1errno为EAGAIN/EWOULDBLOCK无需处理继续轮询 else if (read_len -1 errno ! EAGAIN errno ! EWOULDBLOCK) { perror(read fifo error); // 非“无数据”的真实错误才处理 break; } // 清空缓冲区准备读取标准输入 bzero(buf, sizeof(buf)); // 8. 非阻塞读标准输入终端无输入时fgets立即返回NULL if (fgets(buf, sizeof(buf), stdin)) { printf(terminal:%s, buf); fflush(stdout); // 强制刷新输出缓冲区避免数据滞留 } // 轻微延时减少CPU空转非必须仅优化轮询效率 usleep(100000); } close(fd); return 0; }非阻塞 IO 需要用fcntl函数设置核心是操作 IO 时不会阻塞进程 / 线程即便数据未就绪也会立即返回需通过循环轮询检测 IO 状态。结合给出的 FIFO命名管道代码核心要点如下1. 非阻塞 IO 的核心特性非阻塞标志通过O_NONBLOCK标志设置文件描述符为非阻塞模式操作行为读 / 写非阻塞 FD 时无数据 / 无法写入会立即返回读返回 - 1errno 为 EAGAIN/EWOULDBLOCK而非阻塞等待轮询检测需通过循环持续检测 IO 状态判断是否有数据可读 / 可写标志位操作通过fcntl函数的F_GETFL获取标志和F_SETFL设置标志修改文件描述符的阻塞属性。fcntl(fd, F_GETFL) : 获取文件描述符当前的状态标志如阻塞 / 非阻塞、读写模式等fcntl(fd, F_SETFL, ...) : 将修改后的标志写回文件描述符使非阻塞生效2. FIFO 与非阻塞 IO 结合的特殊点FIFO 默认打开行为open(myfifo, O_RDONLY/O_WRONLY)会阻塞直到对端以对应模式打开非阻塞打开 FIFO若需避免 open 阻塞可在 open 时直接加O_NONBLOCK代码中是先阻塞 open 再改非阻塞也可直接open(myfifo, O_RDONLY | O_NONBLOCK)。三、select1. select 核心函数int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);函数功能动态检测指定文件描述符集合中哪些 fd 已经就绪可读 / 可写 / 异常函数自带阻塞等待执行完毕后集合中只会保留就绪的 fd。参数详解nfds所有待监测 fd 的最大值 1也可以直接写 1024因为 select 最大支持 1024 个 fdreadfds只读事件集合我们最常关注的读事件writefds只写事件集合exceptfds异常事件集合timeout超时时间NULL表示永久阻塞直到有事件就绪返回值超时返回0失败返回-1错误码存于errno成功返回就绪的 fd 数量0配套宏函数为了操作 fd_set 集合select 提供了 4 个核心宏// 1. 清空集合中所有fd void FD_ZERO(fd_set *set); // 2. 向集合中添加指定fd void FD_SET(int fd, fd_set *set); // 3. 从集合中删除指定fd void FD_CLR(int fd, fd_set *set); // 4. 判断fd是否在集合中是否就绪 int FD_ISSET(int fd, fd_set *set);2. select 使用步骤读事件为例结合流程图select 的标准使用流程如下创建 fd 集合定义fd_set rd_set读集合用FD_ZERO清空添加待监测 fd用FD_SET把需要监测的 fd比如标准输入 stdin、管道 fd、socket fd加入集合调用 select 阻塞等待select(nfds, rd_set, NULL, NULL, NULL)等待事件就绪轮询检查就绪 fd用FD_ISSET遍历所有待监测 fd找到就绪的 fd处理 IO 重置集合对就绪 fd 执行 read 操作必须重新用 FD_SET 添加 fd 到集合因为 select 会修改原集合循环下一轮监测核心注意select 会修改传入的 fd_set 集合所以循环调用时每次都要重新初始化集合否则会漏监测3. select 的致命缺点fd 数量上限 1024内核默认最大支持 1024 个 fd无法满足高并发场景轮询效率低每次 select 都要遍历所有待监测 fdfd 越多效率越低用户态 / 内核态数据拷贝每次调用 select都要把 fd 集合从用户态拷贝到内核态返回时再拷贝回来开销大需要手动遍历找就绪 fdselect 只返回就绪数量需要自己遍历所有 fd 找就绪项效率低示例 : 通过select同时监听「FIFO 管道」和「标准输入终端」#include stdio.h #include stdlib.h #include unistd.h #include string.h #include sys/types.h #include sys/stat.h #include fcntl.h #include errno.h #include sys/select.h // select多路复用头文件 int main(int argc, char **argv) { // 1. 创建/检查命名管道 int ret mkfifo(myfifo, 0666); if (-1 ret) { if (EEXIST errno) { } else { perror(mkfifo); return 1; } } // 2. 以只读方式打开管道阻塞直到有写端打开 int fd open(myfifo, O_RDONLY); if (-1 fd) { perror(open myfifo); return 1; } // 3. select多路复用初始化监听FIFO和标准输入fd0 fd_set rd_set, tmp_set; FD_ZERO(rd_set); // 清空文件描述符集合 FD_ZERO(tmp_set); FD_SET(0, tmp_set); // 将标准输入终端加入监听集合 FD_SET(fd, tmp_set);// 将FIFO管道加入监听集合 while (1) { char buf[100] {0}; // 4. 每次循环重置监听集合select会修改集合只保留就绪的fd rd_set tmp_set; // 5. 阻塞等待监听集合中的fd就绪可读 // 参数最大fd1 | 读集合 | 写集合 | 异常集合 | 超时时间NULL永久阻塞 select(fd 1, rd_set, NULL, NULL, NULL); // 6. 遍历检查哪个fd就绪 int i 0; for (i 0; i fd 1; i) { // 情况1FIFO管道有数据可读 if (FD_ISSET(i, rd_set) i fd) { read(fd, buf, sizeof(buf)); // 读取管道数据 printf(fifo :%s\n, buf); } // 情况2终端标准输入有数据可读 if (FD_ISSET(i, rd_set) 0 i) { bzero(buf, sizeof(buf)); // 清空缓冲区 fgets(buf, sizeof(buf), stdin); // 读取终端输入 printf(terminal:%s, buf); fflush(stdout); // 强制刷新输出缓冲区 } } } close(fd); // remove(myfifo); // 可选程序退出时删除管道文件 return 0; }FIFO 文件程序运行后会在当前目录生成myfifo文件程序退出后需手动删除或取消注释remove(myfifo)阻塞特性若写端退出读端read会返回 0表示管道关闭若读端退出写端write会触发SIGPIPE信号默认导致程序崩溃select多路复用核心价值同时监听「FIFO 管道」和「终端输入」避免传统read/fgets的单阻塞问题比如不用等管道数据时终端输入也能立即响应FD_ZERO/FD_SET/FD_ISSETselect的核心宏分别用于清空集合、添加监听 fd、检查 fd 是否就绪rd_set tmp_setselect会修改传入的读集合仅保留就绪的 fd因此每次循环需重置集合循环遍历fd1个文件描述符覆盖「0标准输入~fdFIFO」的所有可能就绪 fd。四、epoll1. epoll 核心函数epoll 由 3 个核心函数组成分工明确1epoll_create创建 epoll 实例int epoll_create(int size);功能创建一个 epoll 实例本质是内核中的红黑树 就绪链表返回 epoll 专用的文件描述符epfd参数size早期指定最大 fd 数现在内核自动扩容填大于 0 的数即可返回值成功返回epfd0失败返回-12epoll_ctl管理 epoll 中的 fdint epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);功能向 epoll 实例中添加 / 删除 / 修改待监测的 fd参数epfdepoll_create 返回的实例 fdop操作类型EPOLL_CTL_ADD添加 fdEPOLL_CTL_DEL删除 fdEPOLL_CTL_MOD修改 fd 的监听事件fd待操作的文件描述符event事件结构体指定监听的事件类型最常用EPOLLIN读事件、EPOLLOUT写事件struct epoll_event { uint32_t events; // 监听的事件类型 epoll_data_t data;// 用户自定义数据存fd或指针方便后续处理 };返回值成功返回0失败返回-13epoll_wait等待事件就绪int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);功能阻塞等待 epoll 实例中的就绪事件把就绪事件拷贝到用户态的events数组中参数epfdepoll 实例 fdevents输出参数存储所有就绪的事件maxevents一次最多返回的就绪事件数一般填 1024/4096timeout超时时间-1永久阻塞0非阻塞5000表示 5 秒超时返回值成功返回就绪事件数0超时返回0失败返回-12. epoll 使用步骤读事件为例结合流程图epoll 的标准流程如下创建 epoll 实例int epfd epoll_create(1024);添加待监测 fd定义epoll_event设置events EPOLLINdata.fd 待监测fd用epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event)添加到 epoll循环调用 epoll_waitint n epoll_wait(epfd, events, 1024, -1);阻塞等待就绪事件遍历就绪事件遍历events[0..n-1]直接拿到就绪的 fd执行 read 操作循环处理无需重置集合继续下一轮epoll_wait3. epoll 的 4 大核心优势对比 selectepoll 之所以成为王者核心是这 4 个特性无 fd 数量限制不再受 1024 限制理论上支持系统最大文件数几十万甚至上百万事件主动上报效率不随 fd 增长下降内核维护就绪链表epoll_wait只返回就绪的 fd不需要遍历所有待监测 fd时间复杂度 O (1)共享内存避免多次拷贝epoll 用内核态红黑树存储 fd只在添加 / 删除时拷贝一次epoll_wait直接从内核就绪链表拷贝就绪事件开销极低直接获取就绪 fd无需轮询epoll_wait返回的events数组里全是就绪的 fd直接处理即可不需要遍历所有待监测 fd示例 : 通过epoll 同时监听「标准输入终端」和「命名管道FIFO」的可读事件#include stdio.h #include stdlib.h #include unistd.h #include string.h #include sys/types.h #include sys/stat.h #include fcntl.h #include errno.h #include sys/epoll.h // epoll 多路复用必须的头文件 // 封装 epoll_ctl(ADD) 把一个fd添加到epoll实例监听 可读事件(EPOLLIN) int add_fd(int epfd, int fd) { struct epoll_event ev {0}; ev.events EPOLLIN; // EPOLLIN 内核通知有数据可以读了 ev.data.fd fd; // 把要监听的fd存在data里 // epoll_ctl 添加fd到红黑树 EPOLL_CTL_ADD添加监听 int ret epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev); if (-1 ret) { perror(add_fd fail); return ret; } return 0; } int main(int argc, char **argv) { // 1. 创建命名管道 int ret mkfifo(myfifo, 0666); if (-1 ret) { if (EEXIST errno){} else { perror(mkfifo); return 1; } } // 2. 打开FIFO 阻塞等待对端 // 以只读打开时会阻塞 直到有进程以只写打开myfifo int fd open(myfifo, O_RDONLY); if (-1 fd) { perror(open myfifo); return 1; } // 3. 创建epoll实例 epoll_create 创建内核红黑树 就绪链表 int epfd epoll_create(2); if (-1 epfd) { perror(epoll_create); return 1; } // 4. 把要监听的描述符加入epoll 同时监听 两个fd add_fd(epfd, 0); // 0 标准输入(终端) add_fd(epfd, fd); // fd FIFO管道 // 存放epoll返回的 就绪事件最多2个事件 struct epoll_event rev[2]; while (1) { char buf[100] {0}; // epoll_wait 阻塞直到有fd可读 / 出错 // 参数epoll实例、接收事件数组、最大事件数、超时(-1永久阻塞) int ep_ret epoll_wait(epfd, rev, 2, -1); // 遍历所有 就绪fd (ep_ret 就绪的fd数量) int i; for (i 0; i ep_ret; i) { // 情况1FIFO管道有数据 if (rev[i].data.fd fd) { // epoll保证此时read一定不会阻塞 read(fd, buf, sizeof(buf)); printf(fifo 收到: %s\n, buf); } // 情况2终端输入有数据 if (rev[i].data.fd 0) { bzero(buf, sizeof(buf)); // epoll保证此时fgets一定不会阻塞 fgets(buf, sizeof(buf), stdin); printf(终端输入: %s, buf); fflush(stdout); } } } close(fd); // remove(myfifo); return 0; }epoll 多路复用替代传统的select/poll高效监听多个文件描述符的事件三步固定流程epoll_create创建实例 →epoll_ctl添加监听 fd →epoll_wait等待就绪核心结构内核红黑树存 fd 就绪链表返就绪事件EPOLLIN监听读就绪事件ev.data.fd fd保存描述符返回时直接识别是谁就绪epoll_ctl(ADD)把 fd 加入内核红黑树FIFO 打开会阻塞等待配对epoll_create创建内核的红黑树 链表可以同时监听多个 fd终端 管道无阻塞读epoll_wait通知就绪后read/fgets不会阻塞遍历只遍历就绪列表效率O(1)select 是 O (n)五、select vs epoll对比维度selectepoll最大 fd 数1024内核硬限制无限制受系统最大文件数限制检测机制轮询遍历所有 fdO (n) 复杂度内核主动上报就绪事件O (1) 复杂度数据拷贝每次调用都要用户态↔内核态拷贝 fd 集合仅添加 / 删除时拷贝一次共享内存就绪 fd 获取需遍历所有 fd手动判断是否就绪直接返回就绪事件数组直接处理适用场景低并发、小连接数场景高并发、大连接数场景服务器主流兼容性全平台支持仅 Linux 支持select 核心关键基于位图用fd_set位图存储待监听 fd默认最大监听1024 个 fd内核硬限制轮询检测select返回后需遍历所有待监听 fd通过FD_ISSET找就绪 fd时间复杂度O(n)fd 越多效率越低重复拷贝每次调用select需将 fd 集合从用户态拷贝到内核态返回时再拷贝回用户态开销随 fd 数增加而变大集合重置select会修改传入的 fd 集合仅保留就绪 fd每次循环需重新初始化并添加 fd代码冗余全平台兼容几乎所有系统支持属于 POSIX 标准无系统限制。epoll 核心关键内核双结构epoll_create创建实例时内核生成红黑树存待监听 fd 就绪链表存就绪 fdfd 增删改查效率高事件驱动fd 就绪时内核主动将其加入就绪链表epoll_wait仅返回就绪 fd遍历仅针对就绪数时间复杂度O(1)效率不随 fd 数下降一次拷贝仅在epoll_ctl添加 / 删除 fd时将 fd 信息拷贝到内核后续无需重复拷贝大幅降低开销无 fd 硬限突破 1024 限制监听数仅受系统最大文件描述符数限制可配置支持上万 / 百万级 fd无需重置待监听 fd 存于内核红黑树用户态仅需维护就绪事件数组循环无需重新添加 fd代码简洁Linux 专属仅 Linux 内核 2.6 及以上支持无跨平台性。1. select 是用户态轮询 重复拷贝 有限 fd的基础实现2. epoll 是内核事件驱动 一次拷贝 无硬限 fd的高性能实现是高并发场景如 Nginx/Redis的首选。