
前言承接上一篇 TCP 粘包处理与多进程 / 多线程并发服务器这两种模型虽然实现简单但在万级甚至十万级高并发场景下进程 / 线程的创建销毁开销、上下文切换成本会急剧上升成为性能瓶颈。本篇讲解高并发网络编程的核心技术 ——IO 多路复用通过单线程即可同时监听海量连接是 Nginx、Redis 等高性能中间件的底层基石也是面试的绝对高频重点。一、IO 多路复用核心概念1. 传统并发模型的痛点多进程 / 多线程模型的核心问题是一个连接对应一个执行单元每新增一个连接就要创建一个进程 / 线程创建销毁开销大连接数上升后大量线程的上下文切换会消耗大量 CPU绝大多数连接大部分时间处于空闲状态线程阻塞在 read 上资源利用率极低高并发场景下CPU 的大部分时间都浪费在了线程调度上而不是真正处理业务数据。2. 什么是 IO 多路复用IO 多路复用的核心思想是用一个线程同时监听大量文件描述符Socket当某个描述符就绪可读、可写、出现异常时才通知应用程序去处理。整个过程只有一个线程在工作同一时间处理一个就绪事件因为 IO 等待的时间远大于数据处理时间单线程就能承载上万甚至十万级的并发连接几乎没有线程切换开销。多路指多条网络连接也就是多个文件描述符复用指复用同一个线程来处理所有连接Linux 系统提供了三种 IO 多路复用实现select、poll、epoll性能和能力依次递增。二、select 多路复用1. 函数原型与参数#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);nfds监听的所有文件描述符中最大的那个值 1用于限定内核遍历的范围readfds监听可读事件的描述符集合writefds监听可写事件的描述符集合exceptfds监听异常事件的描述符集合timeout超时时间填 NULL 则永久阻塞填 0 则非阻塞立即返回返回值就绪的描述符总数超时返回 0出错返回 - 12. fd_set 操作宏内核用位图来表示描述符集合大小固定为 1024 位只能通过四个宏操作void FD_ZERO(fd_set *set); // 清空集合 void FD_SET(int fd, fd_set *set); // 将fd加入集合 void FD_CLR(int fd, fd_set *set); // 将fd从集合中移除 int FD_ISSET(int fd, fd_set *set); // 判断fd是否就绪3. 核心工作流程定义 fd_set 集合将需要监听的 fd 加入集合调用 select将集合从用户态拷贝到内核态内核遍历所有 fd检查是否就绪select 返回前修改集合只保留就绪的 fd用户态遍历整个集合找出就绪的 fd 进行处理下一次调用前必须重新设置 fd 集合4. 实战select 版 TCP 回显服务器#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/select.h #define PORT 8888 #define BUF_SIZE 1024 int main(void) { int lfd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in serv_addr; memset(serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family AF_INET; serv_addr.sin_port htons(PORT); serv_addr.sin_addr.s_addr htonl(INADDR_ANY); bind(lfd, (struct sockaddr *)serv_addr, sizeof(serv_addr)); listen(lfd, 128); printf(select服务端启动端口%d\n, PORT); fd_set readfds, tmpfds; FD_ZERO(readfds); FD_SET(lfd, readfds); int maxfd lfd; while (1) { tmpfds readfds; // 每次调用前都要重新赋值 int ret select(maxfd 1, tmpfds, NULL, NULL, NULL); if (ret -1) { perror(select failed); break; } // 1. 监听套接字就绪有新连接 if (FD_ISSET(lfd, tmpfds)) { struct sockaddr_in cli_addr; socklen_t cli_len sizeof(cli_addr); int cfd accept(lfd, (struct sockaddr *)cli_addr, cli_len); FD_SET(cfd, readfds); if (cfd maxfd) maxfd cfd; printf(新客户端连接fd%d\n, cfd); if (--ret 0) continue; } // 2. 遍历所有客户端fd处理就绪事件 for (int i lfd 1; i maxfd; i) { if (FD_ISSET(i, tmpfds)) { char buf[BUF_SIZE]; memset(buf, 0, sizeof(buf)); ssize_t n read(i, buf, sizeof(buf)); if (n 0) { printf(客户端%d断开\n, i); close(i); FD_CLR(i, readfds); } else { printf(客户端%d%.*s\n, i, (int)n, buf); write(i, buf, n); } if (--ret 0) break; } } } close(lfd); return 0; }5. select 的核心缺陷监听数量有限fd_set 默认大小只有 1024最多监听 1024 个文件描述符修改需要重新编译内核每次调用都要重新设置集合内核会修改集合每次调用前都要重新拷贝用户态内核态拷贝开销大每次调用都要把整个 fd 集合从用户态拷贝到内核态效率随连接数下降内核需要遍历所有 fd 才能找出就绪的连接越多效率越低用户态需要遍历所有 fd应用层不知道哪些 fd 就绪必须全部遍历一遍三、poll 多路复用1. 函数原型与结构体poll 是 select 的改进版用结构体数组替代位图突破了 1024 的数量限制。#include poll.h int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; // 要监听的文件描述符 short events; // 要监听的事件如POLLIN可读、POLLOUT可写 short revents; // 实际发生的就绪事件内核返回时设置 };fdspollfd 结构体数组每个元素对应一个要监听的 fdnfds数组的元素个数timeout超时时间单位毫秒-1 表示永久阻塞返回值就绪的描述符数量2. 相对 select 的改进没有 1024 数量限制数组大小可以自定义支持海量 fd不需要每次重新设置监听事件events 和 revents 分离内核只修改 revents事件类型更丰富除了读写异常还支持优先级、带外数据等事件3. 依然存在的问题poll 只是解决了 select 的数量限制问题核心效率问题并没有解决每次调用依然需要把整个结构体数组从用户态拷贝到内核态内核依然需要轮询遍历所有 fd连接数越多效率越低用户态依然需要遍历所有 fd 来找出就绪的因此 poll 本质上和 select 属于同一层级的实现在高并发下性能依然很差实际工程中使用不多。四、epoll 高性能多路复用epoll 是 Linux 独有的高性能 IO 多路复用机制彻底解决了 select 和 poll 的性能问题是当前高并发服务器的标准实现方案。1. 核心工作原理epoll 在内核中维护两个核心结构红黑树存储所有要监听的文件描述符增删查效率都是 O (logn)就绪链表存储已经就绪的文件描述符当某个 fd 的事件就绪时内核通过回调函数自动把该 fd 加入就绪链表不需要轮询遍历所有 fd。应用程序调用 epoll_wait 时直接返回就绪链表中的内容即可效率极高不会随连接数增加而下降。2. 三个核心 API① 创建 epoll 实例epoll_create#include sys/epoll.h int epoll_create(int size);创建一个 epoll 句柄size 参数现在已经没有实际意义只要大于 0 即可返回值epoll 文件描述符使用完需要 close 关闭② 控制监听事件epoll_ctlint epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);功能向 epoll 中添加、修改、删除要监听的 fdop操作类型EPOLL_CTL_ADD添加新的 fd 到 epoll 中EPOLL_CTL_MOD修改已有 fd 的监听事件EPOLL_CTL_DEL从 epoll 中移除 fdevent要监听的事件结构体事件结构体定义struct epoll_event { uint32_t events; // 监听的事件EPOLLIN可读、EPOLLOUT可写、EPOLLET边沿触发 epoll_data_t data; // 附带数据通常存fd }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;③ 等待就绪事件epoll_waitint epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);events传出数组保存所有就绪的事件maxevents数组最大长度也就是最多返回多少个就绪事件timeout超时时间毫秒-1 永久阻塞返回值就绪的事件数量0 超时-1 出错核心优势每次返回的都是已经就绪的 fd应用程序只需要遍历就绪的即可不需要遍历所有连接。3. 两种触发模式epoll 支持两种事件触发模式水平触发LT和边沿触发ET。① 水平触发Level Triggered默认模式只要缓冲区里还有数据就会一直触发事件直到数据被读完行为和 select/poll 一致支持阻塞和非阻塞 IO编程简单不容易出错但是触发次数多效率略低② 边沿触发Edge Triggered只有当数据到达的那一刻触发一次事件缓冲区里剩余的数据不会重复触发只支持非阻塞 IO必须一次把数据读完直到返回 EAGAIN触发次数少效率更高但是编程复杂容易漏读数据核心区别LT 是水平状态触发有数据就一直通知ET 是边沿变化触发只有新数据到来才通知一次。4. 实战epoll LT 版并发服务器#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include arpa/inet.h #include sys/epoll.h #define PORT 8888 #define BUF_SIZE 1024 #define MAX_EVENTS 1024 int main(void) { int lfd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in serv_addr; memset(serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family AF_INET; serv_addr.sin_port htons(PORT); serv_addr.sin_addr.s_addr htonl(INADDR_ANY); bind(lfd, (struct sockaddr *)serv_addr, sizeof(serv_addr)); listen(lfd, 128); printf(epoll服务端启动端口%d\n, PORT); // 创建epoll实例 int epfd epoll_create(1024); struct epoll_event ev, events[MAX_EVENTS]; // 将监听套接字加入epoll ev.events EPOLLIN; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); while (1) { // 等待就绪事件 int nready epoll_wait(epfd, events, MAX_EVENTS, -1); if (nready -1) { perror(epoll_wait failed); break; } for (int i 0; i nready; i) { int fd events[i].data.fd; // 1. 监听套接字就绪处理新连接 if (fd lfd) { struct sockaddr_in cli_addr; socklen_t cli_len sizeof(cli_addr); int cfd accept(lfd, (struct sockaddr *)cli_addr, cli_len); printf(新客户端连接fd%d\n, cfd); ev.events EPOLLIN; ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); } // 2. 客户端套接字就绪处理数据 else { char buf[BUF_SIZE]; memset(buf, 0, sizeof(buf)); ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { printf(客户端%d断开\n, fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else { printf(客户端%d%.*s\n, fd, (int)n, buf); write(fd, buf, n); } } } } close(epfd); close(lfd); return 0; }5. ET 模式的正确使用规范使用 ET 模式必须遵守两点文件描述符必须设置为非阻塞模式每次读必须循环读取直到返回EAGAIN表示缓冲区已经读空设置非阻塞的方法int flag fcntl(fd, F_GETFL); flag | O_NONBLOCK; fcntl(fd, F_SETFL, flag);循环读取的核心逻辑while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 处理数据 } else if (n 0) { // 对端关闭 break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 数据读完了 break; } // 真正的错误 break; } }五、select /poll/epoll 全面对比对比维度selectpollepoll最大监听数量1024受限于 FD_SETSIZE无限制数组自定义无限制仅受限于系统资源数据拷贝每次调用都要用户态内核态来回拷贝同 select只在添加修改时拷贝一次就绪事件通过共享内存传递查找就绪 fd遍历所有 fdO (n)遍历所有 fdO (n)回调机制直接返回就绪链表O (1)触发模式仅水平触发仅水平触发支持水平触发 边沿触发性能随连接数急剧下降明显下降几乎无影响稳定高效跨平台所有 Unix 系统通用大部分 Unix 系统支持Linux 独有编程复杂度中等需要重置集合简单结构体分离稍复杂事件驱动适用场景连接数少、跨平台需求连接数中等、跨平台高并发、高性能 Linux 服务端六、面试高频考点与易错坑点1. 经典面试问答Q1epoll 为什么比 select 和 poll 快底层原理是什么答数据拷贝少epoll 只在添加 fd 时拷贝一次到内核不需要每次调用都重复拷贝而 select/poll 每次调用都要全量拷贝。不需要轮询epoll 用回调机制fd 就绪时内核自动加入就绪链表不需要遍历所有 fdselect/poll 内核要轮询所有 fd。返回即就绪epoll_wait 直接返回所有就绪的 fd应用层只需要遍历就绪事件select/poll 返回后应用层还要遍历所有 fd 判断哪个就绪。 底层结构是红黑树存储所有监听 fd就绪链表存就绪事件整体效率 O (1)不会随连接数上升而下降。Q2epoll 的 LT 和 ET 模式有什么区别ET 模式有什么注意事项答LT 水平触发缓冲区有数据就一直触发事件和 select 行为一致支持阻塞和非阻塞 IO编程简单。ET 边沿触发只有新数据到达时触发一次剩余数据不重复触发只支持非阻塞 IO效率更高。ET 模式注意事项必须设置非阻塞 IO每次读取必须循环读直到返回 EAGAIN否则会漏读数据。Q3ET 模式为什么必须用非阻塞 IO答 ET 模式只在数据到来时触发一次如果用阻塞 IO循环读到最后没有数据时 read 会阻塞住线程就会一直卡在 read 上无法处理其他事件。 用非阻塞 IO缓冲区读空后 read 返回 EAGAIN就可以退出循环继续处理其他就绪事件。Q4epoll 有惊群问题吗怎么解决答早期 epoll 存在 accept 惊群多个进程 / 线程监听同一个 listenfd新连接到来时所有进程都会被唤醒但只有一个能 accept 成功其他的空转浪费性能。解决方式Linux 2.6 内核新增了 SO_REUSEPORT 选项内核层面做负载均衡只唤醒一个进程也可以用单个线程 accept再分发给工作线程。Q5select 的最大监听数量为什么是 1024可以修改吗答 select 用 fd_set 位图表示描述符集合默认大小是 1024 位所以最多监听 1024 个 fd。 可以通过修改内核的 FD_SETSIZE 宏重新编译内核来调整但不推荐高并发场景直接用 epoll 即可。2. 常见易错坑点select 调用前忘记重置 fd 集合导致第二次调用监听的 fd 不全事件丢失epoll ET 模式下使用阻塞 IO导致 read 阻塞整个服务卡住ET 模式下只 read 一次没有循环读到 EAGAIN导致数据残留缓冲区下次才触发出现粘包延迟关闭 fd 前忘记从 epoll 中删除导致 epoll_wait 返回无效 fdepoll_event 的 data 字段存指针指针指向的内存提前释放出现野指针误以为 epoll 一定更快连接数很少的时候epoll 的开销反而大于 select水平触发模式下数据没读完就返回导致事件一直触发出现 busy loop以上就是 IO 多路复用三大机制的全部核心内容epoll 是 Linux 高性能网络编程的基石也是后续 Reactor 事件驱动模型的基础。下一篇我们将讲解 Reactor 反应堆模式拆解单 Reactor 单线程、单 Reactor 多线程、主从 Reactor 三种经典架构带你理解高性能服务器的设计思想。制作不易如果对你有用希望能点赞收藏支持一下。