当前位置: 首页 > backend >正文

select, poll, epoll

文章目录

  • **IO 多路复用**机制
  • select
    • return val :
    • 什么是 fd_set?
  • poll
    • pollfd
    • return value
  • select 与poll 的区别
    • poll 的 timeout 以毫秒计 与select的timeout 对比 ,poll的timeout有什么改进
    • 如何理解select接口返回0, poll接口返回0
    • poll 与内核有什么关系?
  • Epoll
    • eventpoll
  • epoll_wait(从内核角度理解)
      • 完整流程:从 `epoll_wait` 阻塞到进程唤醒
        • 阶段 1:`epoll_wait` 调用 → 进程进入等待队列(阻塞)
        • 阶段 2:事件就绪(软中断)→ 通过等待队列唤醒进程
  • epoll_ctl
  • eventpoll && epitem
  • epoll_wait的timeout参数和select , poll 中的timeout参数设计
  • I/O 多路复用的其他设计范式
        • 1. 异步 I/O(AIO):完全无阻塞,内核 “包办一切”
        • 2. 多线程 / 多进程 + 阻塞 I/O:分摊阻塞压力
    • ET && LT
  • Reactor 模式(基于 I/O 多路复用)
        • Reactor 模式的核心逻辑
  • Proactor
  • 总结

IO 多路复用机制

假设一个进程保持了 10000 条tcp 连接 , 如何在10000条 tcp 连接中的某条上有 IO 事件发生的时候直接快速把它找出来

解决方案 :IO 多路复用机制。这里的复用指的就是对进程的复用,在 Linux 上多路复用方案有 select、poll、epoll

select

return val :

  • 就绪 fd 的总数;
    出错返回 -1
    超时返回 0:
    select 返回 0 表示“时间到了,但没有任何 fd 就绪”。
    这正是 timeout != NULL 时才可能出现的情况;
    例如:
    触发条件:把一个有限时间(如 5 秒)塞进 struct timeval,5 秒内没有任何 fd 可读/可写/异常。
    语义:不是错误,只是“超时”事件,让你有机会干点别的事。
    常见处理:记录日志、发心跳、统计、检查配置热更新、关闭空闲连接

什么是 fd_set?

fd_set 不是普通 C 数组,而是 按位压缩的 1024 位位图
一个 1024 位的数组,数组下标就是 fd 号,数组元素 0/1 表示是否关心/就绪
fd_set 是一个 位图结构(内部实现通常是 long int 数组),每一位对应一个 fd。

  • 你把关心的 fd “按位” 写入 fd_set,告诉 select 你要监听谁。
  • FD_ZERO 清零、FD_SET 置位、FD_ISSET 检查置位

timeout 决定 select 愿意等多长时间才“醒”过来告诉你结果

  • timeout == NULL → 无限阻塞,直到至少有一个 fd 就绪。
    只要没有任何套接字/文件描述符达到“可读、可写或异常”状态,线程就一直睡着;一旦其中任何一个就绪,内核立刻把线程唤醒,select 返回正值

  • timeout->tv_sec ==5 && timeout->tv_usec == 0 → 线程最多阻塞 5 秒,时间到就立即返回,即使没有 fd 就绪
    把 timeout 设为 {5,0} 时,这 5 秒内线程并没有“溜号”去干别的事——它仍然阻塞在 select() 内部,只是这次最长只肯睡 5 秒;时间一到,内核就把它唤醒,返回 0,告诉你“超时了,没人就绪”。

  • timeout->tv_sec == 0 && timeout->tv_usec == 0 → 纯轮询,立即返回,告诉你当前谁已经就绪。

  • timeout->tv_sec > 0 || timeout->tv_usec > 0 → 最多等这么久,时间到就返回 0,表示“超时”。

select 返回 >0 只告诉“有几个 fd 就绪了”,具体是哪几个,必须用 FD_ISSET 逐一判断;
判断清楚后,必须立刻进行相应的 I/O 操作(读、写、accept、close),否则就浪费了这一次就绪通知,导致漏数据、阻塞或连接堆积
在 select 模型里,“IO 业务处理”并不是指 select 本身去读写数据,而是指 在 select 返回“就绪”之后,你在用户态对那个就绪 fd 做的“下一步系统调用 + 业务逻辑”
最常见的动作就是下面 4 类,90 % 的 TCP 服务器都是这 4 件事的反复循环:


  1. accept —— 监听套接字可读
    场景:listen_fd 就绪
    动作:conn = accept(...)
    业务:把新连接设为非阻塞,塞进 连接表,后续继续 select。

  2. recv/read —— 已连接套接字可读
    场景:conn_fd 就绪
    动作:n = recv(conn, buf, len, 0)
    业务:
    • 把收到的字节流做协议解析(HTTP、Redis、自定义包头)。
    • 拆包、拼包、校验、生成响应。
    • 若一次没读完,把剩余数据留在应用层 buffer,等待下一次可读。

  3. send/write —— 已连接套接字可写
    场景:内核发送缓冲区空出来了
    动作:m = send(conn, outbuf, outlen, 0)
    业务:把应用层 输出队列 里的剩余数据继续发;发完就移除写关注,避免忙轮询。

  4. close —— 连接结束或出错
    场景:recv 返回 0(对端 FIN)或 <0(错误)
    动作:close(conn)
    业务:从连接表、定时器、输出队列里全部清理,防止 fd 泄漏。


一句话归纳
select 只负责“通知”;真正的 IO 业务处理 就是
accept → recv → 处理协议 → send → close” 这 5 步在用户态的循环。

fcntl :
fcntl 和 select 没有“功能耦合”,它只是 配合 select 做“前置配置”
fcntl把套接字设成非阻塞,防止在 select 返回就绪后,真正的 I/O 调用又阻塞住线程。
为什么要“非阻塞”?
select 告诉你“fd 可读”,但实际去读时,可能只剩 1 字节;
如果你用 阻塞读,而业务代码却想一次读 4 KB,就会再次阻塞。
select 告诉你“fd 可写”,但内核缓冲区只剩 100 B;
若一次想写 1 MB,同样会阻塞。
于是经典套路:
“select 负责等,fcntl 负责把 fd 设成非阻塞,这样后续 read/write 永远不会阻塞。”
总结:
fcntl 把 fd 设成非阻塞,select 负责“什么时候”可以 I/O;
二者组合成“非阻塞 + 事件驱动”的经典单线程高并发模型

poll

pollfd

struct pollfd {
int fd; /* 要监听的文件描述符(如套接字、标准输入等) /
short events; /
我们关注的事件(输入参数,由用户设置) /
short revents; /
实际发生的事件(输出参数,由内核填充) */
};

events:关注的事件(输入参数)
由用户设置,指定希望内核监控的事件类型(通过宏定义,如 POLLIN、POLLOUT 等)。
常用事件宏:
POLLIN:表示 “可读事件”(如套接字有新数据、新连接,标准输入有输入)。
POLLOUT:表示 “可写事件”(如套接字缓冲区可写入数据)。
POLLERR:关注错误事件(通常由内核自动设置,无需用户指定)。

revents:实际发生的事件(输出参数)
由内核填充,告知用户该 fd 上实际发生的事件(是 events 中被触发的子集,或额外的错误事件)。
例如:若用户设置 events = POLLIN,当 fd 有数据可读时,内核会将 revents 设为 POLLIN。
可能包含错误事件(如 POLLERR 表示连接出错,POLLHUP 表示连接被关闭),即使 events 中未指定,内核也会在 revents 中返回。

总结:
fd 和 events 是 用户填好的“请求参数”,内核只读不改。
revents 才是 内核回写的“结果标志位”,告诉用户这个 fd 上实际发生了什么事件(如 POLLIN、POLLOUT、POLLERR 等)。

return value

  • 0:超时

  • -1:出错

  • 0:就绪文件描述符个数(即 revents 非0的个数)

无论是 select 还是 poll,当返回值为正整数时,其本质含义就是:
“你告诉内核要监听的那些文件描述符(fd)中,有至少一个 fd 发生了「你关心的 I/O 事件」,
但不会直接告诉你 “具体是哪个 fd 发生了什么事件”,需要你遍历 fd_set/pollfd 数组,检查每个****fd 的 revents,才能知道具体是哪个 fd 发生了什么事件”

后续只需要通过 FD_ISSET(select)或检查 pollfd.revents(poll),就能精准定位到 “哪个 fd 就绪了什么事件”,再执行对应的处理逻辑(如 read/accept)即可
比如:

若你监听的是 STDIN_FILENO(标准输入)的 “读事件”,返回值 > 0 可能意味着用户输入了字符(有数据可读);
若你监听的是 listen_fd(TCP 监听套接字)的 “读事件”,返回值 > 0 可能意味着有新客户端发起连接(可调用 accept);
若你监听的是 conn_fd(已连接套接字)的 “读事件”,返回值 > 0 可能意味着客户端发送了数据(可调用 read)。

系统调用 返回值 > 0 时的具体含义
select 返回 “就绪的文件描述符的总数”(但注意:若一个 fd 同时就绪多个事件,仍只算 1 个
poll 返回 “就绪的 pollfd 结构体的个数”(每个 pollfd 对应一个 fd,逻辑与 select 类似

select 与poll 的区别

1 select 通过三个 fd_set(读、写、异常)管理事件,而 poll 用 struct pollfd 数组

2 早期 select 的 FD_SETSIZE 限制(1024)在高并发场景(如大量客户端连接的服务器)中严重不足。pollfd 采用动态数组,理论上可支持任意多的 FD(仅受系统资源限制)

3 select 的 fd_set 每次调用都要重置(因为内核会修改它),而 pollfd 的 events 是输入参数,内核不会修改,只需在处理后重置 revents 即可重复使用,减少了初始化开销

例如,用实际场景理解“清空并重建 fd_set”的必要性

  • 场景起点(第1轮 select 前)

    • 活跃连接:listenfd=3,客户端A=4,客户端B=5
    • 你构建监控集:FD_ZERO→FD_SET(3/4/5)
    • select 返回:假设本轮只有A(4)可读,内核把 rfds 改成只剩下4
  • 第2轮,如果你不清空/不重建

    • 你直接把“上轮被改写后的 rfds”再次传给 select,此时 rfds 里只有4
    • 结果:你只监控A,完全丢掉了监听fd=3和客户端B=5
    • 后果:
      • 新连接来了(listenfd=3 可读),你收不到
      • 客户端B发数据(fd=5 可读),你也收不到
      • 程序像“瞎了”一样只盯着A
  • 第2轮,正确做法(清空并重建)

    • FD_ZERO(&rfds)
    • 遍历当前活跃表(fd_array):再 FD_SET(3/4/5)
    • 结果:本轮你继续同时监控 3、4、5;谁就绪都能收到
  • 更动场景(连接动态变化)

    • 第2轮前,客户端A断开(你在 Recver 里 close(4),并将 fd_array[pos]=defaultfd)
    • 如果不清空 rfds,可能遗留“旧的4位”或错过“新的6位”(新连接)
    • 清空并重建能确保:移除已关闭的4,加入新接入的6,集合始终和 fd_array 同步

一句话:每轮先清空,再按“当前真实活跃fd列表”重建,才能保证既不会遗忘新老连接,又不会监控已死的fd。

struct pollfd 通过 “FD + 关注事件 + 实际事件” 的绑定设计,解决了 select 在事件管理

poll 的 timeout 以毫秒计 与select的timeout 对比 ,poll的timeout有什么改进

select: timeval 会被内核改写为“剩余时间”,每轮都要重新赋值,否则下一轮超时会异常变短
select 中需要每轮设置 timeval(且它是输入输出型参数)
poll: 传入的毫秒整数一般不被内核修改,便于复用同一个变量
功能等价的取值:
select: NULL 无限阻塞;{0,0} 立即返回;{sec,usec} 指定超时(但会被改写)
poll: -1 无限阻塞;0 立即返回;>0 毫秒超时。

如何理解select接口返回0, poll接口返回0

select 返回 0:超时,没有任何被监控的 fd 就绪(前提:传入的 timeout 非 NULL 且非 0)。
poll 返回 0:超时,没有任何被监控的 fd 就绪(前提:timeout > 0)

三个实际运行场景
场景A:poll 设 3000ms,无 I/O 发生
3 秒后 poll 返回 0 → 进入 “time out…” 分支。
常用于触发周期任务(扫描空闲连接、心跳等)。

场景B:select 设 {1,0}(1秒),无 I/O 发生
1 秒后 select 返回 0 → 进入 “time out, 1.0” 分支。
注意:每轮必须重置 timeval,因为 select 会改写它。

场景C:超时设为“立即返回”
select 传 {0,0} 或 poll 传 0 → 立刻返回 0,表示“当前无就绪事件”,常用于“非阻塞轮询一次”的检查

如果 poll/select的timeout参数传 -1 或 NULL,线程会一直阻塞到有 I/O 事件 ,线程才会停止阻塞,timeout设置>0的作用就是防止一直阻塞 ,就算此时没有IO事件 , 让线程只在timeout时间内阻塞

实际运行对比
场景1:无 timeout(-1 或 NULL)

时间 0ms: poll/select 开始等待
时间 100ms: 无I/O事件 → 继续阻塞
时间 1000ms: 无I/O事件 → 继续阻塞  
时间 10000ms: 无I/O事件 → 继续阻塞
时间 60000ms: 无I/O事件 → 继续阻塞
... 无限等待,直到有I/O事件

场景2:有 timeout(3000ms)

时间 0ms: poll/select 开始等待
时间 100ms: 无I/O事件 → 继续阻塞
时间 1000ms: 无I/O事件 → 继续阻塞
时间 3000ms: 无I/O事件 → 超时唤醒,执行timeout逻辑
时间 3000ms: 重新开始等待
时间 6000ms: 无I/O事件 → 再次超时唤醒
... 每3秒至少唤醒一次

在 poll/select执行timeout逻辑中执行非紧急、可批量的任务,是一种常见的 “轮询 + 额外任务” 设计,能在不浪费阻塞时间的前提下提升效率,但需要注意超时任务不会干扰 I/O 事件的响应。
超时后执行的任务必须是 “短耗时” 的,不能阻塞太久,会导致下一次 I/O 监听的 “间隔被拉长”,可能错过紧急的 I/O 事件(比如客户端连接请求被延迟处理)。

timeout = -1 或 NULL:无限阻塞,线程"睡死"在内核
timeout > 0:有限阻塞,最多等待 timeout 时间,然后强制唤醒
timeout = 0:立即返回,不阻塞(非阻塞模式)

poll 与内核有什么关系?

select 用 位图(fd_set),有 1024 上限;
poll 用 数组,理论上无上限;

内核遍历每个 pollfd:
如果该 fd 已就绪,直接设置 revents 并累加计数。
如果未就绪,把当前进程挂到 每个 fd 的等待队列 上,然后让进程睡眠。
当任意 fd 产生事件(数据到达、缓冲区可写、连接到达等),对应的 驱动/子系统 唤醒等待队列里的进程。
进程醒来后再次扫描整份列表,把就绪的 revents 填好,返回用户态。

Epoll

在这里插入图片描述

socket 数据到达 , 从硬件层面:网卡接收数据,再到内核硬件中断处理硬件中断处理会触发软中断,再到软中断处理,软中断处理就涉及到了epoll, 具体来说就是将网卡缓冲区中的数据拷贝到对应 socket 的接收缓冲区,并标记该 socket 为 “可读状态”(即触发 EPOLLIN 事件) ,再通过 socket 的文件描述符(fd),在内核中epoll 的红黑树 rbr查找该 fd 是否被 epoll 监听(即检查 epoll 的红黑树 rbr 中是否存在该 fd 的节点),此时需要判断是否存在 ,若存在(即该 fd 已通过 epoll_ctl 注册到 epoll),则生成对应的 epoll_event 结构,将其插入 epoll 的就绪链表 rdllist,同时,内核会唤醒阻塞在该 epoll 上的用户进程(通过 epoll 的等待队列 wq),让 epoll_wait 有机会处理就绪事件

例如:

int main(){listen(lfd, ...);cfd1 = accept(...);cfd2 = accept(...);efd = epoll_create(...);epoll_ctl(efd, EPOLL_CTL_ADD, cfd1, ...);epoll_ctl(efd, EPOLL_CTL_ADD, cfd2, ...);epoll_wait(efd, ...)
}
  • epoll_create:创建一个 epoll 对象
  • epoll_ctl:向 epoll 对象中添加要管理的连接
  • epoll_wait:等待其管理的连接上的 IO 事件

用户进程调用 epoll_create 时,内核会创建一个 struct eventpoll 的内核对象

在这里插入图片描述

eventpoll

struct eventpoll {//sys_epoll_wait用到的等待队列wait_queue_head_t wq;//接收就绪的描述符都会放到这里struct list_head rdllist;//每个epoll对象中都有一颗红黑树struct rb_root rbr;......
}

eventpoll

  • wq: 等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
  • rbr: 一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
  • rdllist: 就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树

当被监听的 fd 触发事件(如数据到达),内核会通过红黑树rbr找到该 fd 对应的监听信息,然后生成一个 epoll_event 结构,将其插入 rdllist

rdllist 中的事件(如果此时事件就绪)会被 epoll_wait 读取并返回给用户进程,之后从rdllist链表中移除(避免重复处理)

epoll_wait(从内核角度理解)

epoll_wait 仅负责 “等待就绪事件” 和 “返回就绪事件”,不负责将事件添加到 rdllist

已就绪的事件由内核自己放入内核的就绪的描述符的链表 (rdllist

当被监听的 fd 有事件真正就绪时(如 socket 接收到数据),内核的软中断处理函数会将该事件对应的 epoll_event 结构添加到 epoll 的就绪链表(rdllist)中

在这里插入图片描述

完整流程:从 epoll_wait 阻塞到进程唤醒

整个过程分为 “进程阻塞”“事件就绪与唤醒” 两个阶段,epoll_wait 和等待队列(wq)在其中扮演不同角色:

阶段 1:epoll_wait 调用 → 进程进入等待队列(阻塞)

当用户进程调用 epoll_wait 时,内核会执行以下操作:

  1. 检查就绪事件:内核先检查 epoll 对象的 “就绪事件列表”(rdllist,另一个核心结构,存储已就绪的事件)。

    • 若已有就绪事件(如之前监听的 socket 已有数据),epoll_wait 直接将事件拷贝到用户空间的 events 数组,返回就绪事件数量,不阻塞
    • 若没有就绪事件,进程需要进入阻塞状态。
  2. 进程加入等待队列
    内核会为当前进程创建一个 “等待队列项(wait_queue_entry_t)”,并将其添加到 epoll 对象的 wq(等待队列链表)中。
    此时进程会从 “运行状态” 切换为 “阻塞状态”,释放 CPU 资源,由内核调度其他进程运行。

    这一步的本质是:epoll_wait 是进程 “挂到” 等待队列上的触发入口,而非直接操作等待队列的 “拿事件” 动作。

阶段 2:事件就绪(软中断)→ 通过等待队列唤醒进程

当被监听的文件描述符(如 socket)有事件就绪时(如客户端发送数据触发 TCP 接收软中断),内核会执行以下操作:

  1. 事件就绪处理
    以 socket 为例,数据到达后,网卡触发硬中断,内核处理硬中断后会触发 软中断(如 NET_RX_SOFTIRQ,在软中断处理函数中:
    • 将数据存入 socket 的接收缓冲区;
    • 标记该 socket 的 “可读事件” 就绪,并将其对应的 epoll 事件(epoll_event)添加到 epoll 对象的 就绪事件列表(rdllist) 中。
  2. 通过等待队列唤醒进程
    内核会遍历 epoll 对象的 wq(等待队列链表),找到所有阻塞在该 epoll 上的用户进程,将它们从 “阻塞状态” 切换为 “就绪状态”,并加入内核的 “就绪进程队列”,等待 CPU 调度。
  3. epoll_wait 恢复执行 → 拷贝就绪事件
    当进程被 CPU 调度重新运行时,epoll_wait 会从阻塞处恢复执行,此时内核已将就绪事件存入 rdllist
    epoll_wait 会将 rdllist 中的就绪事件拷贝到用户传入的 events 数组中,然后清空 rdllist,最终返回就绪事件的数量

epoll_ctl

epoll_ctl 不负责向 rdllist 添加事件,它的作用是 注册 “待监听的事件”(而非 “已就绪的事件”)。

  • 例如:调用 epoll_ctl(EPOLL_CTL_ADD, sock, &ev) 时,epoll_ctl 会将 sockev(如 EPOLLIN)添加到 epoll 的 “监听事件列表”(内核内部的 红黑树 结构,用于高效管理监听的 fd);
  • 只有当 sockEPOLLIN 事件真正就绪时(如数据到达),内核才会将其对应的 epoll_event 加入 rdllist

**epoll_ctl 仅操作红黑树(rbr)epoll_ctl不直接操作 rdllist,**负责将 “待监听的事件”(如某个 socket 的 EPOLLIN 事件)注册到 epoll 内部的红黑树(rbr)中,红黑树本质是 “监听事件的索引表”,用于高效管理 epoll 正在监控的所有文件描述符(fd)

eventpoll && epitem

下面给出 Linux 5.15 内核源码eventpollepitem完整展开版本,并一次说清二者的关系与字段含义,可直接拿去和本地源码对照。


  1. 顶层对象:eventpoll
    (每个 epoll 实例对应一个,存在 file->f_private 中)
/* include/linux/eventpoll.h + fs/eventpoll.c */
struct eventpoll {/* 1. 并发控制 */rwlock_t        lock;          // 读写锁:保护整棵红黑树/就绪链表struct mutex    mtx;           // 互斥锁:epoll_ctl 路径串行化/* 2. 等待队列 */wait_queue_head_t wq;          // 阻塞在 epoll_wait 的进程队列wait_queue_head_t poll_wait;   // epoll fd 自身被 poll/select/epoll 监控时使用/* 3. 核心数据结构 */struct list_head rdllist;      // 就绪链表头(双向循环链表)struct rb_root_cached rbr;     // 红黑树根(带最左缓存,加速最小节点访问)/* 4. 事件溢出处理 */struct epitem *ovflist;        // epoll_wait 正在扫描时,新事件挂这里struct user_struct *user;      // 所属 user 的引用计数struct file *file;             // 指向 epoll 实例自身的 struct filewait_queue_entry_t poll_wait_entry;
};

  1. 事件节点:epitem (每注册一个 fd 就分配一个,同时挂在红黑树 + 就绪链表)
struct epitem {/* 2.1 红黑树节点(插入到 eventpoll.rbr) */union {struct rb_node rbn;        // 红黑树节点struct rcu_head rcu;       // 用于延迟释放};/* 2.2 就绪链表节点(插入到 eventpoll.rdllist) */struct list_head rdllink;/* 2.3 文件描述符信息 */struct epoll_filefd ffd;       // { struct file *file; int fd; }/* 2.4 所属 epoll 实例 */struct eventpoll *ep;/* 2.5 用户注册事件 & 当前结果 */struct epoll_event event;      // 用户注册时填的 events/dataunsigned int revents;          // 当前实际发生的事件(内部用)/* 2.6 等待队列钩子 */wait_queue_entry_t wait;       // 挂在目标文件 poll 队列struct list_head pwqlist;      // 同一文件上的所有 epitem 链表struct list_head fllink;       // 反向挂到 struct file->f_ep_linksstruct list_head txlink;       // 传输链路(内部调试/日志用)/* 2.7 引用计数 & 标志位 */atomic_t usecnt;unsigned long flags;           // EP_PRIVATE_BITS(内部状态位)
};

  1. 关联关系用一张图表示
eventpoll (epoll 实例)├── rbr (红黑树根)│     └── epitem[0].rbn│     └── epitem[1].rbn│     └── …│└── rdllist (就绪链表头)└── epitem[A].rdllink└── epitem[B].rdllink└── …
  • “一实例” ↔ “多事件”
    每个 eventpoll 通过 rbr 管理 N 个 epitem(红黑树节点),通过 rdllist 收集“就绪”的 epitem(链表节点)。
  • “一事件” ↔ “一文件”
    每个 epitem 通过 ffd 指向具体 (struct file *, fd),并把 wait 钩子插到该文件的等待队列,实现事件回调。

  1. 实际代码定位(5.15 源码)
# 下载并切到对应版本
git clone --depth 1 -b v5.15 git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
cd linux
# 查看原始定义
vim fs/eventpoll.c +141
vim include/linux/eventpoll.h +40

epoll_wait的timeout参数和select , poll 中的timeout参数设计

三者的 timeout 参数本质都是 “最大阻塞时长”,而非 “固定阻塞时长”,核心行为逻辑完全一致,具体表现为:

  1. 有事件就绪时立即返回:若在 timeout 设定的时间内,监控的文件描述符(fd)有事件就绪(如可读、可写),函数会立即终止阻塞,返回就绪事件的数量(或相关标识),优先处理事件。
  2. 超时无事件时返回 “无事件” 标识:若 timeout 时间耗尽仍无事件就绪,函数会返回 0, 程序进入超时逻辑(如执行轮询任务、打印日志等),之后再次调用 epoll_wait,重新开始计时。
  3. 支持非阻塞模式:若 timeout 设为 0,函数会立即返回(不阻塞),仅检查当前是否有事件就绪(相当于 “轮询一次”)。
    void Start(){// 将listensock添加到epoll中 -> listensock和他关心的事件,添加到内核epoll模型中rb_tree._epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, _listsocket_ptr->Fd(), EVENT_IN);struct epoll_event revs[num];for (;;){int n = _epoller_ptr->EpollerWait(revs, num);if (n > 0){// 处理就绪事件(连接/数据)lg(Debug, "event happened, fd is : %d", revs[0].data.fd);Dispatcher(revs, n);}//将epoll_wait的timeout设置为正数,可让程序在等待事件的间隙定期进入该分支,执行额外任务else if (n == 0){// 超时返回(无事件),此处可添加轻量级任务lg(Info, "time out ...");// TODO: 执行其他任务(如定期清理、状态上报等)}else{lg(Error, "epll wait error");}}}
对比维度selecttimeoutpolltimeoutepoll_waittimeout
参数类型struct timeval*(包含秒和微秒)int(毫秒,直接传递数值)int(毫秒,直接传递数值)
传递方式值 - 结果参数(value-result):内核会修改该结构体的数值,下次调用前需重新初始化(否则会被上次的剩余时间影响)。输入参数:仅传递超时毫秒数,内核不修改该值,下次调用无需重新设置。输入参数:与 poll 一致,仅作为输入,内核不修改,复用性更强。
使用便利性低:需手动维护 struct timeval 结构体,容易因忘记重置导致逻辑错误。高:直接传递整数,使用简单。高:与 poll 一致,使用简单。
内核处理效率select 整体低效有关(需轮询所有 fd),但 timeout 本身不是效率瓶颈。poll 整体低效有关(需轮询所有 fd),timeout 设计无额外开销。epoll 高效机制匹配(红黑树 + 就绪链表),timeout 仅控制阻塞时长,不影响事件检测效率。

selectpoll 也遵循完全相同的模式,仅在代码细节上略有差异(如 select 需要重置 timeval 结构体):

// select 的超时处理示例
struct timeval tv;
fd_set readfds;while (true) {// 重置 timeout(select 的 timeout 是值-结果参数,必须每次重置)tv.tv_sec = 1;  // 1秒超时tv.tv_usec = 0;FD_ZERO(&readfds);FD_SET(listen_fd, &readfds);  // 监控监听套接字int n = select(listen_fd + 1, &readfds, NULL, NULL, &tv);if (n > 0) {// 处理事件handle_select_events(&readfds);} else if (n == 0) {// 超时任务do_timeout_task();// 下一次循环会重新设置 tv,无需手动修改}
}

“超时无事件返回 0 → 执行超时逻辑 → 重新调用等待函数” 是 I/O 多路复用机制的标准设计,其本质是通过 “阻塞等待事件 + 超时间隙处理任务” 的组合,在 “及时响应事件” 和 “高效利用资源” 之间取得平衡,这也是所有主流服务器(如 Nginx、Redis)的事件循环核心模式

阻塞等待事件这样不就影响效率了吗,必须阻塞等待吗 ,只能这样设计吗?

这里的 “阻塞” 并非指程序完全 “卡死” 无法响应,而是进程 / 线程在等待 I/O 事件时,主动放弃 CPU 使用权,被内核挂起—— 这恰恰是 “高效” 的关键。我们通过对比两种极端情况来理解:

反面案例:非阻塞轮询(无阻塞等待)

如果不使用阻塞等待,最直接的替代是 “非阻塞轮询”:程序用 while(1) 循环不断检查是否有 I/O 事件(比如每秒检查 1000 次)。这种方式的问题是:

  • CPU 空转严重:即使没有任何 I/O 事件,CPU 也在不停执行循环检查,导致单个进程可能占用 100% 的 CPU 核心,浪费宝贵的计算资源。
  • 响应延迟不可控:如果轮询间隔设为 1ms,CPU 占用高;如果设为 100ms,事件响应可能延迟 100ms,无法满足实时性需求。

正面案例:阻塞等待(epoll_wait/select)

当调用 epoll_wait(timeout) 时,进程会被内核 “挂起”,不再占用 CPU;直到以下两种情况发生时,内核才会 “唤醒” 进程:

  • 有 I/O 事件就绪(如客户端发数据):内核立即唤醒进程处理事件,响应延迟极低(微秒级)。

  • 超时时间耗尽:内核唤醒进程执行超时逻辑(如清理过期连接),之后重新进入阻塞等待。

阻塞等待的 “阻塞” 是 “有意义的等待”—— 放弃无用的 CPU 空转,只在需要处理事件时才占用 CPU,这正是它比非阻塞轮询高效的根本原因

阻塞等待是主流方案,但并非唯一选择。根据场景需求,还可以选择以下模式:

模式核心逻辑优点缺点适用场景
阻塞等待(主流)epoll_wait (timeout>0) 挂起进程,内核唤醒CPU 占用极低,实现简单,响应实时性好进程被挂起时,无法处理非 I/O 相关的紧急任务绝大多数服务器(Nginx、Redis、MySQL)
非阻塞等待epoll_wait (timeout=0) 立即返回不阻塞进程,可随时穿插处理其他任务CPU 占用高(需循环调用),效率低实时性要求极高的短任务(如实时数据采集)
阻塞 + 信号中断epoll_wait 阻塞时,用信号(如 SIGUSR1)唤醒兼顾阻塞的低 CPU 占用,又能处理紧急任务实现复杂(信号安全、竞态条件处理)需处理紧急事件的服务器(如强制关闭连接)

举例:非阻塞等待的代码(timeout=0)

epoll_waittimeout=0 时,它会立即返回(不阻塞),无论是否有事件:

while (true) {// timeout=0:不阻塞,立即返回int n = epoll_wait(epfd, events, 1024, 0); if (n > 0) {handle_events(events, n);  // 有事件则处理}// 无论是否有事件,都执行其他任务(如计算、日志)do_other_tasks();  // 这里可能占用大量 CPU
}

这种模式下,程序不会被阻塞,但 while(true) 会疯狂循环调用 epoll_wait,导致 CPU 占用飙升 —— 除非 do_other_tasks() 本身是耗时任务(如 heavy computation),否则不推荐用于服务器主循环。

I/O 多路复用的其他设计范式

1. 异步 I/O(AIO):完全无阻塞,内核 “包办一切”
  • 程序发起 I/O 请求后(如 aio_read),立即返回,继续执行其他任务;
  • 内核会在后台完成整个 I/O 操作(包括等待数据到达、拷贝数据到用户空间);
  • 操作完成后,内核通过信号或回调通知程序处理结果。

优点:完全不阻塞进程,CPU 利用率理论上最高;
缺点

  • 实现复杂(回调嵌套、错误处理繁琐);
  • 兼容性差(Linux 的 AIO 仅支持 O_DIRECT 模式,不支持标准文件缓存,实际使用受限);
  • 调试困难(异步流程难以追踪)。

目前主流服务器(Nginx、Redis)均未采用 AIO,而是坚持用 epoll 的阻塞等待模式 —— 因为 “够用且简单”

2. 多线程 / 多进程 + 阻塞 I/O:分摊阻塞压力

早期服务器(如 Apache 的 prefork 模式)采用 “一个连接一个进程 / 线程” 的设计:

  • 主线程监听端口,收到连接后 fork 子进程,子进程用阻塞 I/O(如 read)处理该连接;
  • 多个连接对应多个进程,某个进程阻塞时,不影响其他进程处理请求。

epoll 等 I/O 多路复用,用单进程 / 线程阻塞等待多个 I/O 事件,避免了进程切换的开销。

ET && LT

在 ET 模式下,只有 fd 状态从“不就绪”变为“就绪”的那一刻才会通知一次,之后即使缓冲区里还有数据,也不会再次触发,直到下一次状态变化。因此要求应用层必须一次性把数据读完使用非阻塞 I/O,否则容易漏事件或阻塞线程 。

LT:只要 fd 缓冲区里还有数据/空间,就每次** epoll_wait 都告诉你;**

维度LT(Level Triggered)ET(Edge Triggered)
触发时机条件持续满足就反复触发条件刚成立时触发一次
通知次数可能多次最多一次(直到状态再次翻转)
读/写要求可以只处理一部分数据必须一次性把数据读完/写完,否则剩余数据就“饿死”
I/O 模式阻塞/非阻塞均可必须非阻塞,否则最后一次读/写会阻塞整个事件循环
代码复杂度简单,不易漏事件复杂,需循环读/写直到 EAGAIN
默认模式默认就是 LT需手动设置 EPOLLET
适用场景通用、快速开发高并发、追求极限性能(Nginx、Netty 等)

Reactor 模式(基于 I/O 多路复用)

Reactor 模式的核心逻辑

Reactor 模式的本质是:用 “单线程(或少量线程)阻塞监听多个 I/O 事件”(依赖 epoll/select/poll),一旦某个事件就绪,就 “分发” 给对应的处理器处理,避免为每个连接创建线程

Proactor

  • Reactor 模式:基于同步 I/O。epoll 通知 “事件就绪” 后,需要用户线程主动调用read()/write()完成 I/O(如 “socket 可读了,你快自己读”)。
  • Proactor 模式:基于异步 I/O(如 Linux 的aio_*系列函数)。用户线程直接发起异步 I/O 请求,内核会自动完成 “读 / 写” 并将数据拷贝到用户缓冲区,完成后再通知用户线程(如 “我已经把数据读好了,你直接用”)。

总结

epoll 只做一件事:“fd 已经就绪”这一瞬间的通知
通知出去以后:

  • 读不读、写不写、做不做业务处理,epoll 一概不管;
  • 通知一次还是多次,取决于你选 LT(Edge Triggered) 还是 ET(Edge Triggered)、以及你怎么处理 fd;
  • 让哪个线程/进程去等(epoll_wait),也完全由开发者决定。
http://www.xdnf.cn/news/19894.html

相关文章:

  • PyTorch 损失函数与优化器全面指南:从理论到实践
  • 论文理解:Reflexion: Language Agents with Verbal Reinforcement Learning
  • 【正则表达式】 正则表达式运算法优先级的先后是怎么排序的?
  • 【Pytest】解决Pytest中Teardown钩子的TypeError:实例方法与类方法的调用差异
  • Java中最常用的设计模式
  • Mysql主从复制之延时同步
  • 【Linux基础】Linux系统管理:深入理解Linux运行级别及其应用
  • 面经分享二:Kafka、RabbitMQ 、RocketMQ 这三中消息中间件实现原理、区别与适用场景
  • 笔记:卷积神经网络(CNN)
  • VS2015+QT编译protobuf库
  • 【倒计时2个月】好•真题资源+专业•练习平台=高效备赛2025初中古诗文大会
  • 达人数据导出:小青苔如何让达人数据管理效率飙升?
  • 海康摄像头开发---JSON数据与图片分离
  • 近期刷题总结
  • ChartView的基本介绍与使用
  • 江协科技STM32学习笔记补充之004 基于XC6206P332MR(Torex)的5V到3.3V的电压转换电路分析
  • 2025年中国GEO优化服务机构官方信息汇总与能力概览
  • 《增广贤文》读书笔记(四)
  • 热烈庆祝 | 一二三物联网携这款产品入选2025年度山东省首台(套)技术装备生产企业及产品名单
  • “硬件初始化配置,包括芯片选型、时钟树设计、GPIO/外设参数设置”一般都是哪些需要配置
  • 腾讯云《意愿核身移动 H5》 快速完成身份验证接入
  • 【GitOps】初始Argo CD
  • Unity学习----【进阶】Addressables(一)--概述与简单的使用
  • 小企业环境-流水线管理
  • vue2头部布局示例
  • 基于https+域名的Frp内网穿透教程(Linux+Nginx反向代理)
  • c语言程序之魂——算法(练习题,流程图,程序源码)
  • 2025 年国内外十大顶尖低代码开发平台排行榜
  • 【C++】控制台输入与输出
  • 机器学习实战:逻辑回归算法深度解析与案例应用