Linux内核IO多路复用工作机制对比分析


前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

内核IO多路复用源码对比分析

1. 核心数据结构与内核源码对比

在 Linux 内核中,selectpollepoll 都是多路复用 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 → \rightarrow core_sys_select → \rightarrow 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. 两次遍历:即使在只有 1 个 fd 活跃的情况下,内核也必须完整遍历 0 … max_fd 0 \dots \text{max\_fd} 0max_fd ;进程被唤醒后,用户态也必须完整遍历一遍位图才能知道哪个 fd 就绪。
  2. 重复注册与释放:每次调用 select,都需要在所有被监控 fd 的等待队列上挂载一次当前进程,退出时再全部拆除。
  3. 内存拷贝开销:每次调用都要通过 copy_from_user 将 3 个位图拷贝到内核,返回时再通过 copy_to_user 拷回用户态。

3. poll 源码级别深度剖析

poll 的内核逻辑与 select 高度相似。其内核调用栈为:sys_poll → \rightarrow do_sys_poll → \rightarrow 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 ) O(N) 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. 多维对比与工程抉择

综合对比一览表

维度selectpollepoll
内核数据结构固定长度位图(Bitmap)结构体动态数组红黑树(RB-Tree) + 双向就绪链表
最大文件描述符限制默认 1024(由 __FD_SETSIZE 决定)无限制无限制(仅受限于系统最大文件句柄数)
用户态与内核态拷贝每次调用都需要全量双向拷贝每次调用都需要全量双向拷贝仅在 epoll_ctl 插入时拷贝,wait 时只返回就绪数据
时间复杂度(常规) O ( N ) O(N) O(N)(全量轮询) O ( N ) O(N) O(N)(全量轮询) O ( 1 ) O(1) O(1)(直接读取就绪链表)
时间复杂度(修改限制) O ( N ) O(N) O(N) O ( N ) O(N) O(N) O ( log ⁡ N ) O(\log N) O(logN)(红黑树增删改)
触发模式仅支持水平触发(LT)仅支持水平触发(LT)支持水平触发(LT)与边缘触发(ET)

优缺点总结与选型建议

select
  • 优点:跨平台移植性极佳,几乎所有主流操作系统都支持;在超时精度控制上(微秒级别),其对历史遗留API的适配较好。
  • 缺点:1024 数量上限限制了高并发可能;高并发下 O ( N ) O(N) O(N) 的全量拷贝与全量遍历导致性能劣化极快。
poll
  • 优点:通过链表形式解决了 1024 个文件描述符的上限问题,接口设计比 select 更清晰(输入输出事件分离)。
  • 缺点:并未解决核心的高并发性能痛点,依然存在频繁的内核态拷贝和全局 O ( N ) O(N) O(N) 遍历问题。
epoll
  • 优点

  • 高并发卓越性能:通过红黑树对 fd 进行常驻管理,避免了每次调用的重复拷贝。

  • 主动通知:借助内核回调机制机制,时间复杂度降到 O ( 1 ) O(1) O(1),即使监控百万连接,性能也不会随着连接总数的增加而线性下降。

  • 支持 ET 模式:边缘触发(Edge Triggered)减少了相同就绪事件的触发次数,配合非阻塞 I/O 能极大压榨网络吞吐量。

  • 缺点:在连接数极少(如数十个连接)且均极度活跃的高并发轻量场景下,由于红黑树维护和内核回调的开销,其整体吞吐量可能反而略逊于直接顺序轮询的 select/poll

工程选型准则

  1. 如果是海量连接且多为长连接/空闲连接(如 Web 服务器、网关),无脑选择 epoll
  2. 如果是连接数极少(如数十个以内)但每个连接都在高频发送数据,可以考虑 pollselect 以规避红黑树及回调带来的额外开销。
  3. 需要跨平台(如兼顾 Windows/BSD/Linux)且连接数规模受控时,优先采用 select 或直接使用更高阶的抽象网络库(如 libuv, libevent)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值