目录
一.前言:
1.为什么需要多路转接:
a.传统阻塞 I/O 的并发困境:
b.多路转接解决了什么:
2.事件探视机制的工作模型:
3.注意事项:
二.Linux 核心多路复用 API 使用:
1.Linux 下基于 select 的 TCP 服务:
a.底层结构刨析:
b.核心接口解析:
c.完整代码示例:
d.技术复盘:
2.基于 poll 的 TCP 服务
a.底层结构刨析:
b.核心接口剖析:
c.完整代码示例
d.技术复盘:
3.基于epoll实现tcp服务
a.底层原理:
b.核心接口剖析:
c.完整代码示例:
d.技术复盘:
三.总结:
1.时代意义:
2.工业界现状抉择:
一.前言:
1.为什么需要多路转接:
a.传统阻塞 I/O 的并发困境:
- 阻塞 I/O会导致线程“死等”数据,高并发时必须开辟海量线程,极易耗尽内存并引发灾难性的上下文切换开销;
- 非阻塞 I/O依靠死循环不断轮询探查数据状态,会白白榨干 CPU 算力;
- 信号驱动 I/O机制复杂且信号触发过于频繁,尤其是在基于字节流的 TCP 协议下;
- 异步 I/O(AIO)看着是解决了上述大部分问题,但开发难度高与跨平台优势低。
b.多路转接解决了什么:
- 主要解决了原先需要依赖多进程或多线程处理的多 fd 的 I/O情况,变为仅使用一个线程就可以完成,即“用最少的系统资源,去支撑最大量级的并发连接”。
- 也就是说,多路转接不存在 CPU 空转,不会有多线程开销,线程多次触发等问题,完美兼顾了高并发处理能力与极低的系统资源消耗。
2.事件探视机制的工作模型:
- 主要分为:fd 注册,事件探视,进程唤醒以及读写四大部分。
- 在这个模型中,进程不会主动去遍历每个 fd,而是将 fd 交给操作系统内核,此时进程只需要查看注册好的 fd 中是否有事件准备就绪(期间阻塞休眠),一旦有事件发生可以立刻找到对应的 fd,进行数据操作。
3.注意事项:
本文的高度围绕代码,帮助读者学习与使用select、poll、epoll等。
二.Linux 核心多路复用 API 使用:
1.Linux 下基于 select 的 TCP 服务:
a.底层结构刨析:
- 结构承载:
select采用固定大小的位图来承载文件描述符。位图的索引直接对应文件描述符的数值,将第n个 bit 位置 1,即代表指示内核监控第n号文件描述符。 - 入核与挂载:调用
select时,系统会将整个位图从用户空间全量拷贝到内核空间。内核对位图进行第一次线性遍历,将当前文件描述符挂载到等待队列上。若此时没有检测到任何事件就绪,进程会主动让出 CPU,进入休眠阻塞状态。 - 唤醒与出核:当网卡接收到数据触发硬件中断后,会唤醒该进程。进程醒来后,内核必须进行第二次线性遍历,将位图中未就绪的 bit 清零。最后,将修改后的位图从内核空间覆盖拷贝回用户空间,程序据此判断事件归属。
b.核心接口解析:
fd_set master_set; // 保存所有需要监控的 fd 的结构体 FD_ZERO(&master_set); // 初始化位图 FD_SET(fd, &master_set); //把 _fd 加入位图集合 struct timeval tv; //设置阻塞超时时间 tv.tv_sec = 3; // 秒 tv.tv_usec = 0; //微妙 /* 参数:最大fd序号+1,需要监控读事件的fd,需要监控写事件的fd,需要监控异常事件的fd,超时设置 返回值:小于零出现错误,等于零超时,大于零则是事件就绪的链接个数 */ select(max_fd + 1, nullptr, nullptr, nullptr, &tv); /* 参数:当前遍历到的fd,判断fd在哪个队列就绪 返回值:存在返回1,不存在返回0 */ FD_ISSET(_fd, &read_fds)c.完整代码示例:
#include <iostream> #include <vector> #include <cstring> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/select.h> int main() { //创建 TCP 监听 socket int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } //允许地址复用 int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //绑定 IP 与 端口 sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(8080); if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } //开始监听 if (listen(listen_fd, 5) < 0) { perror("listen"); return 1; } std::cout << "Server started on port 8080...\n"; //设置select fd_set master_set; // 保存所有需要监控的 fd FD_ZERO(&master_set); // 清空位图 FD_SET(listen_fd, &master_set); // 把 listen_fd 加入集合 int max_fd = listen_fd; // 当前监控的最大 fd 数值 std::vector<int> client_fds; // 保存所有已连接的客户端 fd // 5. 事件循环 while (true) { // select 会修改传入的集合,因此必须每次用临时变量备份 fd_set read_fds = master_set; // 阻塞等待,直到有 socket 变为可读 struct timeval tv; tv.tv_sec = 3; // 秒 tv.tv_usec = 0; int ret = select(max_fd + 1, &read_fds, nullptr, nullptr, &tv); if (ret < 0) { perror("select"); break; } else if(ret == 0) { //超时 std::cout << "执行自己的任务中..." << std::endl; sleep(1); } else { //返回值大于零,此时有 socket 变读事件 // 监听 socket 可读 -> 代表有新客户端连接 if (FD_ISSET(listen_fd, &read_fds)) { sockaddr_in client_addr{}; socklen_t len = sizeof(client_addr); int new_fd = accept(listen_fd, (sockaddr*)&client_addr, &len); if (new_fd >= 0) { std::cout << "新客户端接入, fd = " << new_fd << "\n"; FD_SET(new_fd, &master_set); // 加进监控集合 client_fds.push_back(new_fd); // 存起来供后续遍历 if (new_fd > max_fd) max_fd = new_fd; } } //普通客户端 socket for (auto it = client_fds.begin(); it != client_fds.end(); ) { int fd = *it; if (FD_ISSET(fd, &read_fds)) { char buf[1024]; ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0); if (n > 0) { // 收到数据,打印并原样回显 (Echo) buf[n] = '\0'; std::cout << "收到客户端[" << fd << "]: " << buf; send(fd, buf, n, 0); ++it; } else { // n == 0 表示对端正常关闭连接,n < 0 表示读取出错 std::cout << "客户端[" << fd << "] 断开连接\n"; close(fd); FD_CLR(fd, &master_set); // 从位图中移除 it = client_fds.erase(it); // 从容器中删除 // 如果关掉的是最大的 fd,重新找一次最大值 if (fd == max_fd) { max_fd = listen_fd; for (int c : client_fds) { if (c > max_fd) max_fd = c; } } } } else { ++it; } // } // } // } close(listen_fd); return 0; }d.技术复盘:
- 由于底层采用定长的
unsigned long数组实现位图,导致承载文件描述符数量存在上限。 - 由于每次调用都会破坏传入参数,把没有事件就绪的 bit 直接清零,需要重新对整个位图进行赋值和恢复,导致用户态维护成本高。
- 由于每次调用的双向全量拷贝,导致内存开销巨大。
- 无论内核维护文件描述符,还是用户判断哪个文件描述符事件就绪,都需要线性遍历文件描述符,导致海量并发下 CPU 性能急剧衰退。
- 跨平台兼容性极佳,几乎所有操作系统均原生支持。
- 超时时间可以设置到微秒级别,精度较高。
- 支持单独设置异常监控,在BMC等领域效果出众。
2.基于 poll 的 TCP 服务
a.底层结构刨析:
- 用户层注册:采用pollfd结构数组记录文件描述符以及对应监听事件。
- 入核与挂载:通过系统调用poll进入内核以后,拷贝pollfd,创建poll_list跟踪监控文件描述符,为其注册唤醒回调,并首次遍历判断事件状态。
- 唤醒与出核:线程被唤醒后,遍历监控 fd,发现存在事件准备就绪后,将更新好的pollfd拷贝回用户空间,遍历其并进行业务操作。
b.核心接口剖析:
//poll 文件描述符维护结构 struct pollfd fds[MAX_CLIENTS]; //初始化与添加fd for (int i = 0; i < MAX_CLIENTS; ++i) { fds[i].fd = -1; fds[i].events = 0; } fds[0].fd = listen_fd; fds[0].events = POLLIN; //参数:文件描述符数组,监控下标0到nfds之间的数据,超时设置0不阻塞,-1永远等待,大于0则是等待事件,单位ms int ret = poll(fds, nfds, -1);c.完整代码示例
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <poll.h> #include <errno.h> #define MAX_CLIENTS 1024 // 最多同时连接的客户端数量 #define BUFFER_SIZE 4096 // 读写缓冲区大小 int main() { int listen_fd, new_fd; // 监听套接字和新连接套接字 struct sockaddr_in server_addr, client_addr; socklen_t addr_len = sizeof(client_addr); char buffer[BUFFER_SIZE]; ssize_t nread; /* 创建监听套接字 */ listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket"); exit(EXIT_FAILURE); } /* 设置地址重用,避免 "Address already in use" */ int optval = 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) == -1) { perror("setsockopt"); close(listen_fd); exit(EXIT_FAILURE); } /*绑定地址 */ memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网络接口 server_addr.sin_port = htons(8888); if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } /* 开始监听 */ if (listen(listen_fd, 5) == -1) { // 5 是未完成连接队列的大小 perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } printf("Echo server listening on port %d\n", 8888); /* 准备 poll 所需的数据结构 */ struct pollfd fds[MAX_CLIENTS]; // poll 监控的文件描述符数组 int nfds = 1; // 当前有效的 fd 数量(初始只有监听套接字) // 初始化所有槽位,fd 设为 -1 表示空闲 for (int i = 0; i < MAX_CLIENTS; ++i) { fds[i].fd = -1; fds[i].events = 0; } // 将监听套接字放入第一个位置 fds[0].fd = listen_fd; fds[0].events = POLLIN; // 关心可读事件(有新连接到来) /* 主循环:不断调用 poll 处理事件 */ while (1) { int ret = poll(fds, nfds, -1); // -1 表示无限等待 if (ret == -1) { perror("poll"); break; } /* 检查监听套接字是否有新连接 */ if (fds[0].revents & POLLIN) { new_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len); if (new_fd == -1) { perror("accept"); continue; } printf("New connection from %s:%d (fd=%d)\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), new_fd); // 找一个空闲槽位存放新客户端的 fd int slot = -1; for (int i = 1; i < MAX_CLIENTS; ++i) { // 从下标 1 开始找 if (fds[i].fd == -1) { slot = i; break; } } if (slot == -1) { fprintf(stderr, "Too many clients, rejecting new connection.\n"); close(new_fd); } else { fds[slot].fd = new_fd; fds[slot].events = POLLIN; // 只关心可读事件 if (slot >= nfds) { nfds = slot + 1; // 更新有效 fd 上限 } } } /* 遍历其余 fd,处理客户端数据 */ for (int i = 1; i < nfds; ++i) { if (fds[i].fd == -1) continue; // 跳过空槽位 if (fds[i].revents & POLLIN) { // 有数据可读 nread = read(fds[i].fd, buffer, sizeof(buffer) - 1); if (nread > 0) { buffer[nread] = '\0'; // 方便调试打印 printf("Received %zd bytes from fd %d: %s", nread, fds[i].fd, buffer); // Echo 回显:把收到的数据原样写回 write(fds[i].fd, buffer, nread); } else if (nread == 0) { // 客户端关闭连接 printf("Client fd %d disconnected.\n", fds[i].fd); close(fds[i].fd); fds[i].fd = -1; // 标记槽位为空 // 可选:调整 nfds 以缩小 poll 扫描范围(非必须) // 如果当前 i 正好是 nfds-1,则向前收缩 nfds while (nfds > 1 && fds[nfds-1].fd == -1) { --nfds; } } else { // 读取错误 perror("read"); close(fds[i].fd); fds[i].fd = -1; while (nfds > 1 && fds[nfds-1].fd == -1) { --nfds; } } } // 如果发生错误或挂断,也关闭连接 if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) { printf("Error/Hangup on fd %d, closing.\n", fds[i].fd); close(fds[i].fd); fds[i].fd = -1; while (nfds > 1 && fds[nfds-1].fd == -1) { --nfds; } } } } /* 清理:关闭所有客户端连接和监听套接字 */ for (int i = 0; i < nfds; ++i) { if (fds[i].fd != -1) { close(fds[i].fd); } } close(listen_fd); return 0; }d.技术复盘:
- 可独立设置管理上限,但需要手动维护数组,系统底层采用线性扫描的方式,运行时间复杂度高(On)。
- 支持多种事件,如对端关闭、挂起等,但不支持边缘触发。
- 每次处理完事件后,不需要重新设置poll_fd,但仍然要线性遍历所有文件描述符。
- POSIX标准内容之一,可移植性高。
3.基于epoll实现tcp服务
a.底层原理:
相比于poll与select,epoll主要在描述符管理方式和事件通知机制上进行了根本性优化(epoll并非poll的简单增强,而是一种全新的 I/O 多路复用模型)。
- epoll 在内核中使用红黑树存储所有注册的文件描述符及其关注事件,使得增、删、改、查操作的时间复杂度均为O(log n)。
- 每个注册的 fd 注册了一个回调函数。当 fd 上发生感兴趣的事件时(如数据到达),内核驱动会调用该回调函数,将对应的节点直接添加到内核维护的就绪链表中,该操作的时间复杂度为O(1)。
- epoll_wait调用时,只需检查就绪链表是否为空。若非空,则将链表中的事件批量拷贝到用户空间的events数组,并清空链表(水平触发模式下保留就绪状态)。
b.核心接口剖析:
//创建epoll实例: //注:epoll_create1是Linux 2.6.27后代替epoll_create的新接口,不是写错了 // 参数:0 表示无特殊标志;EPOLL_CLOEXEC 表示执行 exec 时自动关闭该 fd,防止泄漏 epfd = epoll_create1(0); //设置监听属性 struct epoll_event { uint32_t events; /* 监听的事件 */ epoll_data_t data; /* 用户数据联合体 */ } __EPOLL_PACKED; //采用紧凑对齐方式编译 //控制epollfd接口 //参数:epoll文件描述符,操作类型,目标文件描述符,监听设置类型 epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); //参数:epoll文件描述符,用于接收就绪事件的 events 数组,队列最大值,等待事件(-1为阻塞等待) //返回值:event输出的有效数量 nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);c.完整代码示例:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <errno.h> #define PORT 8888 // 监听端口 #define MAX_EVENTS 1024 // epoll_wait 一次最多返回的事件数 #define BUFFER_SIZE 4096 // 读写缓冲区大小 // 设置非阻塞模式 static int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd, epfd, nfds, i; struct sockaddr_in server_addr, client_addr; socklen_t addr_len = sizeof(client_addr); char buffer[BUFFER_SIZE]; /* 创建监听套接字 */ listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket"); exit(EXIT_FAILURE); } /* 设置地址重用 */ int optval = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)); /* 3. 绑定地址 */ memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } /* 开始监听 */ if (listen(listen_fd, 5) == -1) { perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } /* 将监听套接字设为非阻塞(推荐,但不是必须) */ if (set_nonblocking(listen_fd) == -1) { perror("set_nonblocking"); close(listen_fd); exit(EXIT_FAILURE); } /* 创建 epoll 实例 */ epfd = epoll_create1(0); // 参数 0 等同于 epoll_create(1),但更现代 if (epfd == -1) { perror("epoll_create1"); close(listen_fd); exit(EXIT_FAILURE); } /* 将监听套接字加入 epoll,关注可读事件(新连接) */ struct epoll_event ev; ev.events = EPOLLIN; // 水平触发模式(默认) ev.data.fd = listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) { perror("epoll_ctl: listen_fd"); close(listen_fd); close(epfd); exit(EXIT_FAILURE); } printf("Epoll echo server started on port %d\n", PORT); /* 事件循环 */ struct epoll_event events[MAX_EVENTS]; // 用于接收就绪事件 while (1) { nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // -1 表示无限等待 if (nfds == -1) { perror("epoll_wait"); break; } for (i = 0; i < nfds; ++i) { int fd = events[i].data.fd; /* 处理新连接 */ if (fd == listen_fd) { int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len); if (conn_fd == -1) { perror("accept"); continue; } printf("New connection from %s:%d (fd=%d)\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), conn_fd); // 将新连接设为非阻塞(重要:epoll 配合非阻塞更高效) if (set_nonblocking(conn_fd) == -1) { perror("set_nonblocking"); close(conn_fd); continue; } // 将新连接加入 epoll,关注可读事件 ev.events = EPOLLIN | EPOLLRDHUP; // EPOLLRDHUP 可检测对端关闭 ev.data.fd = conn_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) { perror("epoll_ctl: add client"); close(conn_fd); } } /* 处理客户端数据或关闭 */ else { /* 检查是否发生错误或挂起 */ if (events[i].events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { printf("Client fd %d closed or error.\n", fd); close(fd); continue; } /* 可读事件 */ if (events[i].events & EPOLLIN) { ssize_t nread = read(fd, buffer, sizeof(buffer) - 1); if (nread > 0) { buffer[nread] = '\0'; printf("Received %zd bytes from fd %d: %s", nread, fd, buffer); // Echo 回显 write(fd, buffer, nread); } else if (nread == 0) { // 客户端关闭(read 返回 0) printf("Client fd %d disconnected.\n", fd); close(fd); } else { // 读错误 if (errno != EAGAIN) { // EAGAIN 是非阻塞的正常情况,忽略 perror("read"); close(fd); } } } } } } /* 清理 */ close(listen_fd); close(epfd); return 0; }d.技术复盘:
- epoll 的触发、添加速度极快,监控上限只受内存影响,但不支持普通文件描述符的读写。
- 专用接口与 epoll_fd 管理,但维护与编写相对复杂。
- 支持多种事件类型设置,但跨平台能力差。
三.总结:
1.时代意义:
select:传统阻塞 I/O 模型(每连接一线程)在并发量上升时,线程开销巨大,导致性能低下、资源浪费。select引入了多路复用思想,允许单线程同时监控多个文件描述符,初步解决了并发瓶颈。
poll:解决了 select 文件描述符数量存在明显上限,每次调用需要重新设置 fd 相关配置,返回后仍需要遍历所有文件描述符的问题。
epoll:为解决 select 与 poll 线性遍历所有监控的 fd,时间复杂度高,返回后仍需要全量扫描文件描述符等问题,做到了真正高并发高性能服务。
2.工业界现状抉择:
select: 通常用在连接数量少,不需要高并发性能,需要跨平台的场景。
poll:通常用在连接数量较多的并发,且限于Linux等系统下。
epoll:追求高并发高性能,且需要边缘触发或长连接的情况下。