首页 / 资讯中心 / 文章详情

Linux IO模型与select、poll、epoll多路复用技术

Linux IO模型与select、poll、epoll多路复用技术 ★ FEATURED ARTICLE
1. 引言在Linux网络编程中IO模型决定了程序如何与内核进行数据交互而多路复用技术则是高并发服务器的核心基础。本文将从IO模型的基本概念出发深入讲解阻塞IO、非阻塞IO、IO多路复用、信号驱动IO和异步IO五种模型并重点剖析select、poll、epoll三种多路复用函数的原理、用法与区别配合具体代码示例帮助读者彻底理解。2. 基本概念2.1 用户空间与内核空间操作系统为了安全将内存划分为用户空间和内核空间。用户程序运行在用户空间不能直接访问硬件和内核数据结构所有IO操作都必须通过系统调用进入内核空间完成。因此一次完整的IO操作通常分为两个阶段等待数据就绪内核等待数据从网卡、磁盘等设备到达。数据拷贝将数据从内核缓冲区拷贝到用户缓冲区。不同的IO模型区别就在于这两个阶段中程序是否阻塞、以及由谁等待。2.2 文件描述符在Linux中一切皆文件。socket、管道、普通文件等都以文件描述符File Descriptor简称fd表示它是一个非负整数。多路复用函数的核心工作就是同时监听多个fd上的事件。3. 五种IO模型3.1 阻塞IOBlocking IO阻塞IO是最常见的模型。当用户进程调用recvfrom等系统调用后进程会一直阻塞直到内核数据准备好并完成数据拷贝才返回结果。// 阻塞IO示例 int sockfd socket(AF_INET, SOCK_STREAM, 0); char buf[1024]; // 进程在此阻塞直到有数据到达并拷贝完成 ssize_t n recv(sockfd, buf, sizeof(buf), 0); if (n 0) { printf(收到数据: %s\n, buf); }阻塞IO的优点是编程简单缺点是当需要同时处理多个连接时一个线程只能服务一个连接必须为每个连接创建线程导致资源消耗巨大。3.2 非阻塞IONon-blocking IO非阻塞IO下用户进程发起recvfrom调用后如果内核数据尚未准备好会立即返回EWOULDBLOCK错误而不会阻塞进程。进程需要不断轮询检查数据是否就绪这种方式称为忙等待。// 非阻塞IO示例 int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); // 设置为非阻塞 char buf[1024]; while (1) { ssize_t n recv(sockfd, buf, sizeof(buf), 0); if (n 0) { printf(收到数据: %s\n, buf); break; } else if (errno EWOULDBLOCK) { // 数据未就绪继续轮询 usleep(1000); } else { perror(recv); break; } }非阻塞IO避免了线程阻塞但轮询会消耗大量CPU时间实际应用中很少单独使用通常与多路复用结合。3.3 IO多路复用IO MultiplexingIO多路复用通过select、poll、epoll等系统调用让一个进程同时监听多个fd。当其中任意一个fd就绪时内核通知进程进程再对就绪的fd进行读写。这是本文的重点将在第4节详细展开。3.4 信号驱动IOSignal-driven IO信号驱动IO中进程先通过sigaction系统调用注册SIGIO信号处理函数然后继续执行其他任务。当内核数据准备好时发送SIGIO信号通知进程进程在信号处理函数中调用recvfrom读取数据。// 信号驱动IO示例简化 void sigio_handler(int sig) { char buf[1024]; ssize_t n recv(sockfd, buf, sizeof(buf), 0); if (n 0) { printf(收到数据: %s\n, buf); } } int main() { signal(SIGIO, sigio_handler); fcntl(sockfd, F_SETOWN, getpid()); // 设置socket归属进程 int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_ASYNC); // 开启异步通知 // 主循环继续做其他事情 while (1) { sleep(1); } }信号驱动IO在数据准备阶段不阻塞进程但数据拷贝阶段仍然阻塞且信号处理逻辑复杂实际使用较少。3.5 异步IOAsynchronous IO异步IO是理论上的理想模型。进程发起aio_read调用后立即返回内核负责等待数据并完成数据拷贝整个过程完成后通过信号或回调通知进程。进程在等待期间完全不被阻塞。// 异步IO示例使用POSIX AIO简化 struct aiocb cb; cb.aio_fildes sockfd; cb.aio_buf buf; cb.aio_nbytes sizeof(buf); cb.aio_sigevent.sigev_notify SIGEV_SIGNAL; cb.aio_sigevent.sigev_signo SIGIO; aio_read(cb); // 立即返回内核后台完成整个IO // 进程继续执行其他任务数据就绪后通过信号通知Linux的glibc对POSIX AIO支持有限真正的异步IO通常使用io_uring等高级接口。异步IO编程复杂度最高但性能潜力最大。3.6 五种模型对比IO模型等待数据阶段数据拷贝阶段并发处理能力编程复杂度阻塞IO阻塞阻塞低一线程一连接低非阻塞IO轮询阻塞中需配合多路复用中IO多路复用阻塞在select/poll/epoll阻塞高单线程多连接中信号驱动IO不阻塞信号通知阻塞中高异步IO不阻塞不阻塞高高4. IO多路复用详解4.1 为什么需要多路复用阻塞IO无法高效处理大量并发连接非阻塞IO的忙等待又浪费CPU。IO多路复用让一个线程同时监听成百上千个fd只有就绪的fd才会被处理既避免了线程开销又避免了无意义的轮询是构建高并发服务器的核心手段。4.2 select函数select是最早出现的多路复用函数原型如下#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);参数说明nfds所有监听fd中的最大值加1用于告诉内核需要检查的fd范围。readfds监听可读事件的fd集合传入时是用户关心的fd返回时内核标记哪些fd可读。writefds监听可写事件的fd集合。exceptfds监听异常事件的fd集合。timeout超时时间NULL表示永久阻塞0表示立即返回非阻塞轮询。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是否在集合中就绪select完整示例#include stdio.h #include sys/select.h #include sys/socket.h #include netinet/in.h #include string.h #include unistd.h int main() { // 1. 创建监听socket int listenfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8888); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(listenfd, (struct sockaddr *)addr, sizeof(addr)); listen(listenfd, 128); // 2. 初始化fd集合readfds用于select调用allfds保存所有需要监听的fd fd_set readfds, allfds; int maxfd listenfd; // 记录当前最大fdselect需要用它计算检查范围 int clients[1024] {0}; // 记录所有客户端fd int client_count 0; FD_ZERO(allfds); // 清空集合 FD_SET(listenfd, allfds); // 将监听fd加入集合 while (1) { readfds allfds; // 每次循环都要重新赋值因为select会修改集合 struct timeval tv {5, 0}; // 5秒超时 int ret select(maxfd 1, readfds, NULL, NULL, tv); if (ret -1) { perror(select); break; } else if (ret 0) { printf(超时无事件\n); continue; } // 3. 检查监听fd是否可读有新连接到来 if (FD_ISSET(listenfd, readfds)) { int connfd accept(listenfd, NULL, NULL); if (connfd 0) { clients[client_count] connfd; // 保存新客户端fd FD_SET(connfd, allfds); // 加入监听集合 if (connfd maxfd) maxfd connfd; // 更新最大fd printf(新连接: %d\n, connfd); } } // 4. 遍历所有客户端fd检查是否有数据可读 char buf[1024]; for (int i 0; i client_count; i) { int fd clients[i]; if (fd 0 FD_ISSET(fd, readfds)) { // 该fd就绪可读 ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { // 连接关闭或出错清理该fd close(fd); FD_CLR(fd, allfds); clients[i] 0; printf(连接 %d 关闭\n, fd); } else { buf[n] \0; printf(来自 %d: %s\n, fd, buf); send(fd, buf, n, 0); // 回显给客户端 } } } } close(listenfd); return 0; }select的缺点fd数量受限fd_set位图大小由FD_SETSIZE决定通常为1024无法支持更多连接。效率随fd数量线性下降每次调用都需要将整个fd集合从用户态拷贝到内核态内核也要线性扫描所有fd。需要重新设置集合select会修改传入的fd_set每次循环都必须重新赋值。不知道具体哪个fd就绪返回后需要遍历所有fd用FD_ISSET逐个判断。4.3 poll函数poll解决了select的fd数量限制问题使用pollfd数组代替位图不再受FD_SETSIZE限制。原型如下#include poll.h // pollfd结构体描述一个要监听的fd及其关心的事件 struct pollfd { int fd; // 要监听的fd short events; // 关心的事件输入由用户设置 short revents; // 实际发生的事件输出由内核填写 }; // poll函数同时监听多个fd上的事件 // fds: pollfd数组首地址 // nfds: 数组长度要监听的fd个数 // timeout: 超时时间毫秒-1表示永久阻塞0表示立即返回 int poll(struct pollfd *fds, nfds_t nfds, int timeout);常用事件标志POLLIN可读事件。POLLOUT可写事件。POLLERR错误事件。POLLHUP挂起对端关闭。poll完整示例#include stdio.h #include poll.h #include sys/socket.h #include netinet/in.h #include string.h #include unistd.h #define MAX_CLIENTS 2048 // 最大客户端数量 int main() { // 1. 创建监听socket int listenfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8888); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(listenfd, (struct sockaddr *)addr, sizeof(addr)); listen(listenfd, 128); // 2. 初始化pollfd数组 struct pollfd fds[MAX_CLIENTS]; int nfds 1; // 当前监听的fd个数初始只有监听socket fds[0].fd listenfd; // 第一个元素监听listenfd fds[0].events POLLIN; // 关心可读事件新连接到来 while (1) { // 3. 调用poll等待事件5秒超时 int ret poll(fds, nfds, 5000); if (ret -1) { perror(poll); break; } else if (ret 0) { printf(超时无事件\n); continue; } // 4. 检查监听socket是否有新连接fds[0]的revents被内核填充 if (fds[0].revents POLLIN) { int connfd accept(listenfd, NULL, NULL); if (connfd 0 nfds MAX_CLIENTS) { // 将新客户端fd加入pollfd数组 fds[nfds].fd connfd; fds[nfds].events POLLIN; // 关心该fd的可读事件 nfds; // 监听数量加1 printf(新连接: %d\n, connfd); } } // 5. 遍历所有客户端fd检查是否有数据可读 char buf[1024]; for (int i 1; i nfds; i) { // 通过revents判断该fd是否就绪可读 if (fds[i].revents POLLIN) { ssize_t n recv(fds[i].fd, buf, sizeof(buf), 0); if (n 0) { // 连接关闭或出错关闭fd并从数组中移除 close(fds[i].fd); // 用最后一个fd填补空缺避免数组空洞 fds[i] fds[nfds - 1]; nfds--; // 监听数量减1 i--; // 回退索引继续检查填补过来的fd printf(连接关闭\n); } else { buf[n] \0; printf(收到: %s\n, buf); send(fds[i].fd, buf, n, 0); // 回显给客户端 } } } } close(listenfd); return 0; }poll相比select的改进无fd数量限制使用数组只受内存限制。事件与就绪分离events和revents分开不需要每次重新设置。poll的缺点效率问题依旧每次调用仍需将整个fd数组从用户态拷贝到内核态内核线性扫描所有fd。不知道具体哪个fd就绪仍需遍历整个数组检查revents。4.4 epoll函数epoll是Linux特有的高效多路复用机制专为处理大量fd而设计。它解决了select和poll的两个核心痛点每次调用都要全量拷贝fd集合、以及内核需要线性扫描所有fd。epoll通过事件驱动和内核维护就绪链表的方式实现了接近O(1)的复杂度是构建高并发服务器的首选方案。epoll由三个系统调用组成分工明确#include sys/epoll.h // 1. 创建epoll实例返回一个文件描述符epfd // size参数在Linux 2.6.8之后已不再使用但必须大于0 int epoll_create(int size); // 2. 控制epoll实例注册/修改/删除要监听的fd // epfd: epoll_create返回的实例fd // op: EPOLL_CTL_ADD添加、EPOLL_CTL_MOD修改、EPOLL_CTL_DEL删除 // fd: 要操作的fd // event: 描述要监听的事件类型 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 3. 等待事件发生返回就绪的fd数量 // epfd: epoll实例fd // events: 输出参数内核将就绪的事件填入该数组 // maxevents: events数组容量必须大于0 // timeout: 超时时间毫秒-1表示永久阻塞 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll_event结构体定义如下// epoll_event结构体描述一个fd上关心的事件及携带的用户数据 struct epoll_event { uint32_t events; // 感兴趣的事件输入或就绪的事件输出 epoll_data_t data; // 用户数据通常存放fd或指针 }; // epoll_data联合体用于存放用户自定义数据 typedef union epoll_data { void *ptr; // 指向任意用户数据 int fd; // 文件描述符 uint32_t u32; uint64_t u64; } epoll_data_t;常用事件标志EPOLLIN可读事件对端有数据到达或连接关闭。EPOLLOUT可写事件发送缓冲区有空间。EPOLLERR错误事件通常无需手动设置内核自动监听。EPOLLHUP挂起事件对端关闭连接。EPOLLET边缘触发Edge Triggered模式默认是水平触发Level Triggered。epoll完整服务器示例包含LT和ET两种触发模式的对比注释#include stdio.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #include string.h #include unistd.h #include fcntl.h #include errno.h #define MAX_EVENTS 1024 // epoll_wait一次最多返回的就绪事件数 #define MAX_BUF 1024 // 接收缓冲区大小 // 将fd设置为非阻塞模式ET模式必须配合非阻塞IO使用 int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); // 获取当前fd状态标志 return fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 追加非阻塞标志 } int main() { // 1. 创建监听socket int listenfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8888); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(listenfd, (struct sockaddr *)addr, sizeof(addr)); listen(listenfd, 128); set_nonblocking(listenfd); // 监听socket也设为非阻塞配合ET模式 // 2. 创建epoll实例 int epfd epoll_create(1); // size参数已废弃传大于0即可 struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; // 水平触发LT默认模式 // ev.events EPOLLIN | EPOLLET; // 边缘触发ET改为这行即可切换 ev.data.fd listenfd; // 将listenfd存入data事件返回时通过data.fd识别 epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev); // 注册监听socket while (1) { // 3. 等待事件永久阻塞timeout-1 int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n -1) { perror(epoll_wait); break; } // 4. 遍历所有就绪的事件 for (int i 0; i n; i) { int fd events[i].data.fd; // 取出就绪的fd // 处理新连接 if (fd listenfd) { // 循环accept直到EAGAINET模式下必须一次性处理完所有连接 while (1) { int connfd accept(listenfd, NULL, NULL); if (connfd -1) { // EAGAIN表示没有更多待处理的连接 if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } set_nonblocking(connfd); // 新连接也设为非阻塞 ev.events EPOLLIN; // LT模式 // ev.events EPOLLIN | EPOLLET; // ET模式 ev.data.fd connfd; // 保存客户端fd epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, ev); // 注册到epoll printf(新连接: %d\n, connfd); } continue; } // 处理客户端数据 char buf[MAX_BUF]; ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { // 连接关闭或出错 close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // 从epoll中移除 printf(连接 %d 关闭\n, fd); } else { buf[n] \0; printf(来自 %d: %s\n, fd, buf); send(fd, buf, n, 0); // 回显 // LT与ET模式的关键区别 // LT水平触发只要缓冲区还有数据epoll_wait就会持续返回该fd // 即使一次没读完下次调用仍会通知编程简单但可能重复唤醒。 // ET边缘触发只有状态发生变化从无数据到有数据时才通知一次 // 必须一次性把数据读完循环recv直到EAGAIN否则数据会丢失。 // 因此ET模式必须配合非阻塞IO且读取要循环到EAGAIN为止。 // // 下面演示ET模式下必须循环读取 // while (1) { // ssize_t m recv(fd, buf, sizeof(buf), 0); // if (m 0) { // send(fd, buf, m, 0); // } else if (m -1 errno EAGAIN) { // break; // 数据读完退出循环 // } else { // close(fd); // epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // break; // } // } } } } close(listenfd); close(epfd); return 0; }epoll相比select/poll的优势事件驱动无需全量扫描select/poll每次调用都要把整个fd集合从用户态拷贝到内核态内核线性扫描所有fdepoll通过红黑树管理fd内核只把就绪的fd放入就绪链表epoll_wait直接返回就绪fd复杂度接近O(1)。无fd数量限制select受FD_SETSIZE通常1024限制poll虽用数组但仍有性能瓶颈epoll的fd数量只受系统内存限制可轻松支持数十万连接。mmap内存映射减少拷贝epoll通过mmap在内核和用户空间共享一块内存用于传递就绪事件避免了select/poll每次调用都全量拷贝fd集合的开销。支持LT和ET两种触发模式LT模式编程简单ET模式配合非阻塞IO可减少重复唤醒进一步提升性能。回调机制epoll为每个fd注册回调函数当fd就绪时内核直接调用回调将其加入就绪链表而不是像select/poll那样轮询检查。5. 总结本文系统梳理了 Linux 网络编程中的五种 IO 模型并重点剖析了 select、poll、epoll 三种多路复用函数的原理与用法。核心要点如下五种 IO 模型阻塞 IO、非阻塞 IO、IO 多路复用、信号驱动 IO 和异步 IO其本质区别在于「等待数据就绪」和「数据拷贝」两个阶段中程序是否阻塞、以及由谁等待。select最早出现的多路复用函数使用 fd_set 位图受 FD_SETSIZE通常 1024限制每次调用需全量拷贝并线性扫描适合连接数较少的场景。poll用 pollfd 数组替代位图突破了 fd 数量限制但效率问题依旧仍需全量拷贝和线性扫描。epollLinux 特有的高效方案通过红黑树管理 fd、就绪链表和回调机制实现接近 O(1) 的复杂度支持 LT 和 ET 两种触发模式是高并发服务器的首选。在实际开发中建议优先选择 epoll 构建高并发服务若连接数较少或需要跨平台兼容select 和 poll 仍是可行的选择。理解这五种 IO 模型的内在差异是写出高性能网络程序的重要基础。
阅读完成 · 觉得有帮助?
咨询建站