一、为什么需要 IO 复用?
如果在并发的环境下,可能会N个人向应用B发送消息,这种情况下我们的应用就必须创建多个线程去读取数据,每个线程都会自己调用recvfrom 去读取数据。那么此时情况可能如下图:

如上图一样,并发情况下服务器很可能一瞬间会收到几十上百万的请求,这种情况下应用B就需要创建几十上百万的线程去读取数据,同时又因为应用线程是不知道什么时候会有数据读取,为了保证消息能及时读取到,那么这些线程自己必须不断的向内核发送recvfrom 请求来读取数据;
这么多的线程不断调用recvfrom 请求数据,先不考虑服务器能不能扛得住这么多线程,就算扛得住那么很明显这种方式也是非常资源,线程是我们操作系统的宝贵资源,大量的线程用来去读取数据了,那么就意味着能做其它事情的线程就会少。
所以就提供了一种方式,可以由一个线程监控多个网络请求(称为fd文件描述符,linux系统把所有网络请求以一个fd来标识),这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据,这么做就可以节省出大量的线程资源出来,这个就是IO复用模型的思路。

正如上图,IO复用模型的思路就是系统提供了一种函数可以同时监控多个fd的操作,这个函数就是我们常说到的select、poll、epoll函数,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,select函数监控的fd中只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时询问线程再去通知处理数据的线程,对应线程此时再发起recvfrom请求去读取数据。
换句话说就是进程通过将一个或多个fd传递给select,阻塞在select操作上,select帮我们侦测多个fd是否准备就绪,当有fd准备就绪时,select返回数据可读状态,应用程序再调用recvfrom读取数据。
二、select函数的原理介绍和使用
1.为什么需要 select?
假设:
- 服务器要同时监听 1000 个 socket
- 如果每个 socket 一个线程
会出现:
- 线程爆炸
- CPU 上下文切换严重
- 内存占用巨大
如果用阻塞:
recv(fd, buf, ...)
没有数据就卡死。
select 的作用:一个线程同时监控多个 fd;哪个 fd 就绪,就处理哪个
2.select 的工作原理
select 做了三件事:
- 用户把所有需要监听的 fd 传给内核
- 内核遍历这些 fd
- 把“就绪的 fd”返回给用户
本质:
- 轮询遍历
- 时间复杂度 O(n)
3.select 的内核机制
在内核中:
- 维护一个 fd_set 位图
- 每个 bit 表示一个 fd
- 内核遍历 0~maxfd
流程:
用户态:
fd_set 复制到内核
内核态:
遍历全部 fd
检查是否就绪
标记就绪位
拷贝回用户态
所以 select 的性能瓶颈:
- 每次都拷贝 fd_set
- 每次都遍历所有 fd
4.select 完整服务器示例
#include <sys/select.h>
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout
);
参数解释:
- nfds:最大 fd + 1;不是监听数量,而是:maxfd + 1
- readfds:监听“可读”;例如:新连接、收到数据、对端关闭
- writefds:监听“可写”;一般用于:非阻塞 connect、发送缓冲区可写
- exceptfds:异常事件:OOB 数据,但是很少用
- timeout:NULL → 永久阻塞;0 → 立即返回;指定时间 → 超时返回
fd_set 的操作宏
select 使用位图管理 fd。必须使用以下宏:
FD_ZERO(&set); // 清空
FD_SET(fd, &set); // 添加
FD_CLR(fd, &set); // 删除
FD_ISSET(fd, &set); // 判断是否就绪
完整服务器示例
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
bind(listenfd, ...);
listen(listenfd, 5);
fd_set allset, rset;
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
int maxfd = listenfd;
while (1)
{
rset = allset;
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
for (int i = 0; i <= maxfd; ++i)
{
if (FD_ISSET(i, &rset))
{
if (i == listenfd)
{
int connfd = accept(listenfd, NULL, NULL);
FD_SET(connfd, &allset);
if (connfd > maxfd)
maxfd = connfd;
}
else
{
char buf[1024];
int n = recv(i, buf, sizeof(buf), 0);
if (n <= 0)
{
close(i);
FD_CLR(i, &allset);
}
else
{
send(i, buf, n, 0);
}
}
}
}
}
5.select 的缺陷
- 最大 fd 限制:最多监听 1024 个 fd。
- 每次都遍历全部 fd:即使只有一个连接活跃,也要检查 0~maxfd。时间复杂度:O(n)
- 每次都拷贝 fd_set:用户态 ↔ 内核态 来回拷贝。
- 修改原集合,就必须重新设置 fd。
三、poll函数的原理介绍和使用
1.poll 是为了解决什么问题?
select 的主要缺陷:
- 最大 1024 个 fd(FD_SETSIZE 限制)
- 位图结构不灵活
- 每次调用都会修改 fd_set
- 每次遍历 0~maxfd
poll 解决了:
- 监听 fd 数量不受 1024 限制
- 不使用位图,而是使用数组
但仍然:
- 每次调用都要遍历所有 fd
- 时间复杂度仍然 O(n)
2.poll 函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数解释
fds:
struct pollfd {
int fd; // 监听的文件描述符
short events; // 关心的事件
short revents; // 返回的就绪事件
};
nfds:数组元素个数(不是最大 fd)
timeout:
- -1 → 永久阻塞
- 0 → 立即返回
- 0 → 指定毫秒超时
3.poll 监听的事件类型
POLLIN // 可读
POLLOUT // 可写
POLLERR // 出错
POLLHUP // 对端关闭
POLLNVAL // 无效fd
通常我们监听:
events = POLLIN;
4.poll 的工作原理
流程:
用户态:
构造 pollfd 数组
调用 poll()
内核态:
复制 pollfd 数组
遍历所有 fd
检查状态
标记 revents
返回就绪数量
用户态:
遍历 pollfd 数组
判断 revents
核心特点:
- 线性遍历
- O(n)
- 不再受 1024 限制
5.poll 与 select 的本质区别

6.poll 使用步骤
① 定义数组
struct pollfd fds[1024];
② 初始化
fds[0].fd = listenfd;
fds[0].events = POLLIN;
③ 调用 poll
int nready = poll(fds, nfds, -1);
④ 遍历 revents
for (int i = 0; i < nfds; ++i)
{
if (fds[i].revents & POLLIN)
{
// 可读
}
}
完整服务器示例:
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
bind(listenfd, ...);
listen(listenfd, 5);
struct pollfd fds[1024];
fds[0].fd = listenfd;
fds[0].events = POLLIN;
int nfds = 1;
while (1)
{
int nready = poll(fds, nfds, -1);
for (int i = 0; i < nfds; ++i)
{
if (fds[i].revents & POLLIN)
{
if (fds[i].fd == listenfd)
{
int connfd = accept(listenfd, NULL, NULL);
fds[nfds].fd = connfd;
fds[nfds].events = POLLIN;
nfds++;
}
else
{
char buf[1024];
int n = recv(fds[i].fd, buf, sizeof(buf), 0);
if (n <= 0)
{
close(fds[i].fd);
fds[i] = fds[nfds-1];
nfds--;
}
else
{
send(fds[i].fd, buf, n, 0);
}
}
}
}
}
7.poll 的缺点
- 每次都遍历所有 fd:即使只有 1 个连接活跃,也要检查全部。
- 每次都拷贝数组:用户态 → 内核态
- 高并发性能差:当连接数达到几万:CPU 占用高、延迟增加
四、epoll函数的原理介绍和使用
1.epoll 是什么?
epoll 是 Linux 下的高性能 IO 多路复用机制。它通过红黑树管理所有监听的 fd,通过就绪链表存储活跃事件。相比 select/poll 每次 O(n) 扫描,epoll 采用事件回调机制,实现 O(1) 事件通知。支持 LT 和 ET 两种触发模式,适用于高并发服务器。
2.为什么需要 epoll?
在 epoll 之前:
- select
- poll
问题是:

当连接数 10 万时:每次都扫描 10 万个 fd → 性能崩溃
3.epoll 为什么快?
1️⃣ 事件驱动(回调机制)
内核中:
- 每个 socket 注册回调函数
- 只要数据到达,内核主动把 fd 放入“就绪链表”
不再全量扫描
2️⃣ 红黑树管理 fd
内核中使用:
- 红黑树(管理所有监听的 fd)
- 就绪链表(ready list)
时间复杂度:

epoll 内核结构示意
epoll instance
├── 红黑树(所有监听 fd)
└── 就绪链表(ready list)
4.epoll 三个核心函数
1️⃣ epoll_create
创建 epoll 实例
int epfd = epoll_create(1);
本质:在内核创建一个 eventpoll 结构体
2️⃣ epoll_ctl
注册 / 修改 / 删除 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
常见操作:

3️⃣ epoll_wait
等待事件
int n = epoll_wait(epfd, events, maxevents, timeout);
返回:就绪的 fd 数量
完整服务器示例(核心结构)
int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == listenfd) {
int connfd = accept(...);
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
} else {
read(fd, buf, ...);
}
}
}
5.epoll 两种模式
1️⃣ LT(Level Trigger)水平触发
默认模式。
特点:只要缓冲区有数据,就一直通知
优点:不容易丢事件
缺点:可能重复触发
2️⃣ ET(Edge Trigger)边沿触发
需要设置:
ev.events = EPOLLIN | EPOLLET;
特点:只在状态变化时通知一次
要求:
- 必须使用非阻塞 IO
- 必须一次性读完数据
- 否则会丢数据
6.为什么 epoll 适合高并发?
举例:10 万连接
select:每次扫描 10 万个 fd
epoll:只返回有事件的 fd;如果只有 100 个活跃:只处理 100 个
4456

被折叠的 条评论
为什么被折叠?



