- 1. 核心数据结构与内核源码对比
- select 的数据结构
- poll 的数据结构
- epoll 的数据结构
- 2. select 源码级别深度剖析
- 3. poll 源码级别深度剖析
- 4. epoll 源码级别深度剖析
- epoll_ctl:构建常驻内核的红黑树
- 核心回调:ep_poll_callback
- epoll_wait:直奔就绪链表
- 5. 多维对比与工程抉择
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
内核IO多路复用源码对比分析
1. 核心数据结构与内核源码对比
在 Linux 内核中,select、poll 和 epoll 都是多路复用 I/O 的实现方式,但它们的底层数据结构和事件通知机制有着天壤之别。
select: [__FD_SETSIZE (1024 bits)] ---> 每次调用都需要将整个位图拷贝到内核
poll: [pollfd 数组] ---> 每次调用都需要将整个数组拷贝到内核
epoll: [红黑树 (监控树)] + [双向链表 (就绪队列)] ---> 内核常驻,仅在 ctl 时修改,wait 时只返回就绪链表
select 的数据结构
select 使用的是固定长度的位图(Bitmap)结构 fd_set。在内核中定义如下:
typedef __kernel_fd_set fd_set;
#undef __FD_SETSIZE
#define __FD_SETSIZE 1024
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;
由于 fds_bits 是一个固定大小的 long 数组,最大比特位由 __FD_SETSIZE 限制(默认 1024),这就决定了单进程能监控的文件描述符数量存在天然上限。
poll 的数据结构
poll 摒弃了位图,改用动态数组(结构体数组)形式传递事件,定义在 include/uapi/asm-generic/poll.h 中:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 请求的事件类型(如 POLLIN, POLLOUT) */
short revents; /* 实际发生的事件类型(由内核填充后返回) */
};
通过数组结构,poll 解决了 1024 的数量限制,但它依然需要将整个数组在用户态和内核态之间来回拷贝。
epoll 的数据结构
epoll 在内核中引入了一个文件系统(eventpollfs),并在内核开辟了一块高效率的红黑树与双向链表空间。其核心结构体定义在 fs/eventpoll.c 中:
struct eventpoll {
// ...
/* 红黑树的根节点,用于保存所有通过 epoll_ctl 注册的被监控文件描述符 */
struct rb_root_cached rbr;
/* 双向链表,用于保存所有已经就绪的文件描述符 */
struct list_head rdllist;
// ...
};
每当使用 epoll_ctl 注册一个 fd 时,内核就会创建一个 epitem 结构体,将其挂载到红黑树 rbr 中:
struct epitem {
struct rb_node rbn;/* 红黑树节点 */
struct list_head rdllink;/* 双向链表节点,用于链接到 eventpoll->rdllist */
struct epoll_filefd ffd;/* 包含文件指针与 fd 的结构体 */
struct eventpoll *ep;/* 指向所属的 eventpoll 实例 */
struct epoll_event event;/* 用户态传入的事件配置 */
};
2. select 源码级别深度剖析
内核调用栈
sys_select→core_sys_select→do_select
在 fs/select.c 中,do_select 是核心执行逻辑。以下为其简化后的核心代码片段:
int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
struct poll_wqueues table;
poll_table *wait;
int retval = 0;
// 初始化 poll_table,并将 __pollwait 回调函数注册其中
poll_initwait(&table);
wait = &table.pt;
if (end_time && !end_time->tv_sec && !end_time->tv_nsec) {
wait->_qproc = NULL; // 如果是非阻塞,则不注册等待队列
}
for (;;) {
unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;
// 遍历所有 fd
inp = fds->in; outp = fds->out; exp = fds->ex;
rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
for (i = 0; i < n; ++i) {
unsigned long in, out, ex;
// 按 long(32位或64位)批量处理位图
in = *inp++; out = *outp++; ex = *exp++;
while (in || out || ex) {
if (in & 1) {
struct fd f = fdget(fd);
if (f.file) {
// 调用驱动或文件系统的 poll 函数
// 这一步会执行 poll_wait,将当前进程挂载到设备的等待队列上
mask = (*f.file->f_op->poll)(f.file, wait);
fdput(f);
if ((mask & POLLIN_SET) && !(in & bit)) {
res_in |= bit; // 记录就绪事件
retval++;
}
}
}
// 后移比特位,处理下一个 fd
in >>= 1; out >>= 1; ex >>= 1;
}
}
// 如果有事件发生、超时或者有信号中断,则跳出循环
if (retval || timed_out || signal_pending(current))
break;
// 第一次遍历后,不再重复注册等待队列
wait->_qproc = NULL;
// 让出 CPU,进入睡眠,等待设备驱动唤醒或超时
if (!schedule_timeout_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
}
// 释放等待队列相关的内存
poll_freewait(&table);
return retval;
}
select 存在的底层痛点
- 两次遍历:即使在只有 1 个
fd 活跃的情况下,内核也必须完整遍历 0…max_fd ;进程被唤醒后,用户态也必须完整遍历一遍位图才能知道哪个 fd 就绪。 - 重复注册与释放:每次调用
select,都需要在所有被监控 fd 的等待队列上挂载一次当前进程,退出时再全部拆除。 - 内存拷贝开销:每次调用都要通过
copy_from_user 将 3 个位图拷贝到内核,返回时再通过 copy_to_user 拷回用户态。
3. poll 源码级别深度剖析
poll 的内核逻辑与 select 高度相似。其内核调用栈为:sys_poll→do_sys_poll→do_poll。
在 fs/select.c 中,do_poll 负责轮询 pollfd 数组:
static int do_poll(struct poll_list *list, struct poll_wqueues *wait, struct timespec64 *end_time)
{
int count = 0;
poll_table* pt = &wait->pt;
for (;;) {
struct poll_list *walk;
// 遍历链表中的每一个 pollfd 数组节点
for (walk = list; walk != NULL; walk = walk->next) {
struct pollfd *pfd = walk->entries;
struct pollfd *pfd_end = pfd + walk->len;
for (; pfd != pfd_end; pfd++) {
if (pfd->fd >= 0) {
struct fd f = fdget(pfd->fd);
if (f.file) {
// 调用驱动的 poll
unsigned int mask = (*f.file->f_op->poll)(f.file, pt);
pfd->revents = mask & pfd->events;
if (pfd->revents) {
count++;
pt->_qproc = NULL; // 发现就绪,后续不再注册等待队列
}
}
}
}
}
if (count || timed_out || signal_pending(current))
break;
pt->_qproc = NULL; // 避免重复注册等待队列
if (!schedule_timeout_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
}
return count;
}
poll 对比 select 的改动
- 优点:采用
pollfd 结构体链表/数组存储,消除了 1024 个文件描述符的数量硬上限。 - 缺点依旧:时间复杂度仍为 O(N)。每次调用依然要整体拷贝输入数组,且进程唤醒后依旧需要全量遍历数组以筛选出活跃的
fd。
4. epoll 源码级别深度剖析
epoll 彻底改变了这种“每次调用都全量传入、全量遍历”的被动模式。它采用事件驱动机制,将监控和等待彻底解耦。
epoll_ctl:构建常驻内核的红黑树
当调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) 时,内核执行 fs/eventpoll.c 中的 ep_insert 函数。
static int ep_insert(struct eventpoll *ep, const struct epoll_event *event,
struct file *tfile, int fd, int full_check)
{
struct epitem *epi;
struct ep_pqueue epq;
// 分配并初始化 epitem
if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
return -ENOMEM;
INIT_LIST_HEAD(&epi->rdllink);
epi->ep = ep;
epi->ffd.file = tfile;
epi->ffd.fd = fd;
epi->event = *event;
// 设置等待队列的回调函数为 ep_poll_callback
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
// ep_ptable_queue_proc 内部会将系统的等待队列回调设置为 ep_poll_callback
// 调用驱动的 poll,触发 ep_ptable_queue_proc 绑定回调
revents = ep_item_poll(epi, &epq.pt, 1);
// 将当前 epitem 插入到红黑树中
ep_rbtree_insert(ep, epi);
// 如果当前 fd 本身就已经有就绪事件,直接挂入 rdllist 并唤醒 epoll_wait
if (revents & event->events) {
if (!ep_is_linked(epi)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
if (waitqueue_active(&ep->wq))
wake_up(&ep->wq);
}
}
return 0;
}
核心回调:ep_poll_callback
传统 select/poll 被唤醒后只能盲目遍历。而 epoll 在文件系统中注册了统一的回调 ep_poll_callback。当硬件驱动检测到 I/O 准备就绪并引发中断时,会调用当前进程挂在等待队列上的这个回调:
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
unsigned long flags;
__pm_stay_awake(epi->ws);
// 加锁,将当前的 epitem 挂入 eventpoll 的就绪链表 rdllist 中
if (!ep_is_linked(epi)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
// 唤醒在 epoll_wait 中挂起并等待的进程
if (waitqueue_active(&ep->wq)) {
wake_up(&ep->wq);
}
return 1;
}
epoll_wait:直奔就绪链表
由于 ep_poll_callback 已经主动把活跃的 fd 筛选了出来并放进了 rdllist 中,epoll_wait 几乎不需要做多余的计算:
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
// ...
if (list_empty(&ep->rdllist)) {
// 如果就绪链表为空,则将进程挂入 ep->wq 等待队列,进入睡眠
init_waitqueue_entry(&wait, current);
__add_wait_queue_priority(&ep->wq, &wait);
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (!list_empty(&ep->rdllist) || timed_out)
break;
if (signal_pending(current)) {
res = -EINTR;
break;
}
// 挂起 CPU
schedule();
}
__remove_wait_queue(&ep->wq, &wait);
__set_current_state(TASK_RUNNING);
}
// 将就绪事件精准拷贝到用户态
return ep_send_events(ep, events, maxevents);
}
5. 多维对比与工程抉择
综合对比一览表
| | | |
|---|
| 内核数据结构 | | | |
| 最大文件描述符限制 | 默认 1024(由 __FD_SETSIZE 决定) | | |
| 用户态与内核态拷贝 | | | 仅在 epoll_ctl 插入时拷贝,wait 时只返回就绪数据 |
| 时间复杂度(常规) | O(N) | O(N) | O(1) |
| 时间复杂度(修改限制) | O(N) | O(N) | O(logN) |
| 触发模式 | | | |
优缺点总结与选型建议
select
- 优点:跨平台移植性极佳,几乎所有主流操作系统都支持;在超时精度控制上(微秒级别),其对历史遗留API的适配较好。
- 缺点:1024 数量上限限制了高并发可能;高并发下 O(N) 的全量拷贝与全量遍历导致性能劣化极快。
poll
- 优点:通过链表形式解决了 1024 个文件描述符的上限问题,接口设计比
select 更清晰(输入输出事件分离)。 - 缺点:并未解决核心的高并发性能痛点,依然存在频繁的内核态拷贝和全局 O(N) 遍历问题。
epoll
高并发卓越性能:通过红黑树对 fd 进行常驻管理,避免了每次调用的重复拷贝。
主动通知:借助内核回调机制机制,时间复杂度降到 O(1),即使监控百万连接,性能也不会随着连接总数的增加而线性下降。
支持 ET 模式:边缘触发(Edge Triggered)减少了相同就绪事件的触发次数,配合非阻塞 I/O 能极大压榨网络吞吐量。
缺点:在连接数极少(如数十个连接)且均极度活跃的高并发轻量场景下,由于红黑树维护和内核回调的开销,其整体吞吐量可能反而略逊于直接顺序轮询的 select/poll。
工程选型准则:
- 如果是海量连接且多为长连接/空闲连接(如 Web 服务器、网关),无脑选择
epoll。 - 如果是连接数极少(如数十个以内)但每个连接都在高频发送数据,可以考虑
poll 或 select 以规避红黑树及回调带来的额外开销。 - 需要跨平台(如兼顾 Windows/BSD/Linux)且连接数规模受控时,优先采用
select 或直接使用更高阶的抽象网络库(如 libuv, libevent)。