目录
[a.传统阻塞 I/O 的并发困境:](#a.传统阻塞 I/O 的并发困境:)
[二.Linux 核心多路复用 API 使用:](#二.Linux 核心多路复用 API 使用:)
[1.Linux 下基于 select 的 TCP 服务:](#1.Linux 下基于 select 的 TCP 服务:)
[2.基于 poll 的 TCP 服务](#2.基于 poll 的 TCP 服务)
一.前言:
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.核心接口解析:
cpp
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.完整代码示例:
cpp
#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.核心接口剖析:
cpp
//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.完整代码示例
cpp
#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.核心接口剖析:
cpp
//创建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.完整代码示例:
cpp
#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:追求高并发高性能,且需要边缘触发或长连接的情况下。