select/poll/epoll

为什么要传到内核态判断就绪状态

  • 判断 fd 是否就绪的唯一依据是内核中的 socket 缓冲区状态,用户态根本访问不到这块内存。所以不管 select/poll 还是 epoll,判断就绪这件事必须在内核里做。区别只是:

    1. select/poll:每次都把 fd 列表拷贝到内核,内核遍历判断

      • select 的流程是:无数据时阻塞 → 数据到达唤醒 → 重新遍历全部 fd 找出就绪者 → 返回。这种“唤醒即全扫”的模式,正是它在大并发下性能崩塌的根源。
    2. epoll:fd 列表常驻内核,数据到达时回调标记就绪

select/poll数据为什么存在用户态,epoll数据为什么存在内核态

  • select/poll数据存在用户态

    • select/poll 是无状态的系统调用,内核不帮你记住任何东西

    • select/poll 设计为一次性查询——调用时传入,返回后就"忘记"了

    • 内核不保存 fd 集合,下次调用你得重新传

    • 所以 fd 数组天然存在用户态,每次调用时拷贝到内核,用完内核就丢弃

  • epoll数据存在内核态

    • epoll 是有状态的,内核帮你维护一个长期存在的 fd 集合
    • epoll 的设计目标就是长期监听大量 fd,避免反复拷贝
    • 红黑树常驻内核,一次注册,持续有效
    • 事件驱动:设备驱动在 fd 就绪时通过回调函数直接把节点挂入就绪链表,这个操作必须在红黑树所在的内核空间完成
    • 补充:一个 EventLoop 线程就会有一个selector,就会有一个对应的epoll对象

select、poll、epoll的区别

┌─────────────────────────────────────────────────────────────────────────────┐
│                         select / poll                                      │
│                                                                             │
│  用户态             每次拷贝整个集合         内核态                          │
│  ┌────────────┐  ────────────────────────►  ┌────────────┐                 │
│  │ fd 集合    │                              │ 内核接收后  │                 │
│  │ [1,2,3,..] │  ◄────────────────────────  │ 线性扫描   │                 │
│  └────────────┘       返回整个集合            └────────────┘                 │
│       │                                                                    │
│       ▼                                                                    │
│  遍历整个集合,找出就绪的                                                   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│                            epoll                                            │
│                                                                             │
│  用户态                                 内核态                              │
│                                          ┌─────────────────────┐           │
│  epoll_create  ────────────────────────► │ eventpoll 对象      │           │
│                                          │  ┌───────┐ ┌──────┐│           │
│  epoll_ctl(ADD) ─── 单个fd信息 ────────►   │  │红黑树 │ │就绪链 ││           │
│  (只传这一个fd)                            │  │管理全部│ │表(就  ││           │
│                                          │  │ fd    │ │绪的)  ││           │
│  epoll_wait    ◄── 只返回就绪的fd ──────   │  └───────┘ └──────┘│           │
│                                          └─────────────────────┘           │
│       │                                                                    │
│       ▼                                                                    │
│  直接处理就绪的 fd,无需遍历全部                                           │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
  • 区别

    • select、poll是:应用程序的一个数组或者列表管理所有的fd,数据在用户区,想要知道哪些就绪,就需要应用程序把所有的fd发给内核,然后内核遍历标记就绪的,然后给到用户态的应用程序再次遍历找出就绪的处理。

    • epoll:一开始epoll_create创建红黑树+就绪链表构,数据本身就在内核区,epoll_ctl增删改红黑树节点(一个节点一个fd),epoll_wait内核直接返回就绪链表,然后给到用户态的应用程序处理。

      • epoll用【epoll_ctl方法+ epoll_wait方法】实现select【select方法】\ poll【poll方法】的功能
    • select / poll 的工作模式

      1. 管理方:应用程序在用户态维护一个数组或列表,存放所有需要监控的 fd。
      2. 每次调用:应用程序必须把整个 fd 集合从用户态拷贝到内核态。
      3. 内核动作:内核线性扫描(轮询) 全部 fd,找出就绪的,标记后返回。
      4. 返回结果:内核把处理后的集合拷贝回用户态。
      5. 用户处理:应用程序遍历整个集合,找出被标记的就绪 fd 进行处理。

      痛点:每次调用都要全量拷贝 + 全量扫描(O(n)),连接数越多性能越差。

      epoll 的工作模式

      1. 初始化(epoll_create:内核创建一个 eventpoll 对象,内部包含红黑树(管理所有 fd)和就绪链表(存放就绪的 fd)。这个对象本身就在内核态。
      2. 增删改(epoll_ctl:在内核的红黑树上增、删、改节点(一个节点 = 一个 fd),只需要把单个 fd 的信息从用户态拷贝到内核态(增量拷贝)。
      3. 等待事件(epoll_wait:内核直接检查就绪链表,如果不为空,就把就绪的 fd 拷贝到用户态返回。
      4. 用户处理:应用程序直接遍历返回的就绪 fd 列表进行处理,无需再扫描全部。

      优势:一次性注册(树),只返回就绪的(链表),全量扫描变增量管理,O(n) 变 O(1)。

  • epoll特点:

    • 红黑树 + 就绪链表
    数据结构 职责 时间复杂度 关键优势 问题背景
    红黑树 管理所有注册的 fd 增删改查 O(log n) 支持海量 fd 的快速管理 应用程序需要随时向 epoll 实例中添加(ADD)、修改(MOD)或删除(DEL)要监控的 fd。
    这个操作是动态且频繁的。
    如果使用简单的数组或链表,在海量连接(比如 10 万个)中查找一个 fd 的时间复杂度是 O(n),这在高并发下是不可接受的。
    就绪链表(双向链表) 存储就绪的 fd 插入/删除 O(1) 避免了全量扫描,只处理活跃连接 selectpoll 最大的性能瓶颈在于:内核不知道哪些 fd 就绪了,所以必须线性扫描全部 fd(O(n))。
    即使 10 万个连接中只有 1 个活跃,也要检查 10 万次。同时,每次都要把全部 fd 集合在用户态和内核态之间复制,开销巨大。

    这种设计使得 epoll 在连接数巨大但活跃连接很少的场景下(比如大多数 Web 服务器),性能远优于 selectpoll

    • 回调驱动 (O(1)):就绪时通过回调加入就绪链表,epoll_wait 直接返回
  • 特性维度 select poll epoll
    底层数据结构 固定大小的位图 (Bitmap) 动态数组 (pollfd 结构体) 红黑树 + 就绪链表
    最大连接数 1024(由 FD_SETSIZE 硬编码限制) 无上限(受系统内存限制) 无上限(受系统内存限制)
    每次调用开销 全部复制:每次调用都需将整个 fd 集合从用户态拷贝到内核态 全部复制:每次调用都需将整个 pollfd 数组拷贝到内核态 增量管理:通过 epoll_ctl 注册时拷贝一次,epoll_wait 只拷贝就绪的 fd
    就绪检查方式 轮询 (O(n)):内核线性扫描所有 fd 轮询 (O(n)):内核线性扫描所有 fd 回调驱动 (O(1)):就绪时通过回调加入就绪链表,epoll_wait 直接返回
    触发模式 仅支持水平触发 (LT) 仅支持水平触发 (LT) 支持水平触发 (LT)边缘触发 (ET)
    性能特点 随 fd 数量增加性能线性下降 随 fd 数量增加性能线性下降 连接数越大,性能优势越明显(活跃连接数少时近乎 O(1))
  • 水平触发 (LT)和边缘触发 (ET)

    • 水平触发 (LT):只要有数据一直通知你读数据,直到读完停止通知

      • 优先:编程复杂度低
      • 缺点:系统调用(CPU 上下文切换多)多,性能不好,
    • 边缘触发 (ET):只有从无数据变成有数据那一下通知,如果没读完可能就永远读不到了

      • 优先:减少系统调用(减少了 CPU 上下文切换),性能好,好个~5%
      • 缺点:编程复杂度高,容易出bug
    • 触发模式 触发条件 触发次数
      LT(水平触发) 电平处于高电平状态 持续触发,直到状态变为低
      ET(边缘触发) 电平发生0→1 的跳变 只触发一次(上升沿)
  • Netty 始终使用水平触发模式 (Netty always uses level-triggered mode)

    • 即使底层是 LT,Netty 也帮你一次性读完了所有数据,达到了类似 ET 的效果,但没有 ET 的编程风险

    • Netty 当前版本已经固定为 LT 模式,无法再使用 ET。这是 Netty 团队做出的工程选择——用不到 5% 的性能损失,换来 100% 的代码安全性和开发便利性。放心用 LT,Netty 已经在应用层帮你把 LT 优化到了接近 ET 的性能水平。

  • 因为select/poll是复制所有fd去轮询检查的,大量fd却只有少量是活跃的说明系统会调用大量资源只为了给那几个fd服务,开销大,性能肯定低。epoll就反过来咯
    image-20260717161953808

posted @ 2026-07-17 18:49  deyang  阅读(8)  评论(0)    收藏  举报