名词解释

fd

fd(File Descriptor,文件描述符)

fd 是一个整数,用来代表一个“已打开的文件/IO 资源”

在 Linux / Unix 中,一切皆文件,内核不直接让你操作这些对象本身,而是给你一个整数编号 —— fd

fd 是一个整数句柄,代表一个 IO 资源;fds 是多个 fd 组成的集合,用于统一监听和管理

fd_set

fd_set 是一个“文件描述符集合”的位图数据结构,用来告诉操作系统:我关心哪些 fd 的 IO 状态(可读 / 可写 / 异常)。

fd_set 的作用就是:
把“我要监听的 fd 列表”传给内核
接收内核返回的“就绪 fd 列表”

fd_set是在select模式下使用的

pollfd

pollfd 是 Linux / POSIX 系统中 poll() 系统调用的核心数据结构,用来告诉内核你想监听哪些文件描述符,以及关心哪些事件。

它类似于 selectfd_set,但比 select 更灵活,而且没有 FD_SETSIZE 限制。

<poll.h> 里定义(Linux 为例):
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 用户关心的事件(可读/可写/异常)
short revents; // 内核返回的事件(就绪的事件)
};

内核

内核是“管理者”,内存是“被管理的资源”。
内核本身也住在内存里,但它住在“受保护的那一部分”。

内核空间/用户空间

内核是“程序”,内核空间/用户空间是“内存区域(地址空间)”。

内核空间是操作系统为内核及其相关数据保留的内存地址区域。

用户空间是普通应用程序运行和使用的内存地址区域。

同一个线程,在不同时间、不同权限级别,访问不同空间。

理解

IO多路复用技术是操作系统的技术,redis和nginx等一些中间件,使用IO多路复用技术就是去调用操作系统所提供的接口。

select

概念

select 模式是 IO 多路复用的一种实现方式,它允许一个线程同时监控多个文件描述符(fd)的读、写、异常状态,从而实现单线程处理多个 IO 连接的能力。

流程

工作流程:(指的是用户程序调用操作系统实现的select模式IO多路复用)
1、用户态准备 fd_set
2、调用 select 系统调用
3、内核处理
4、用户态处理就绪 fd

内核处理:
1、拷贝 fd_set 到内核空间
2、遍历 fd,检查就绪状态
3、如果没有 fd 就绪 → 阻塞线程
4、IO 就绪 → 内核唤醒线程
5、拷贝回用户态
6、用户态继续处理

详细过程:
1、拷贝 fd_set 到内核空间
在select模式下
在大多数 Unix / Linux系统中:一个 fd_set 最多只能同时监听 1024 个文件描述符
可监听的最大 fd 值必须 < FD_SETSIZE
不是“放多少个 fd”,而是 fd 的编号不能超过 1023
因为fd_set的数据结构是定长位图(bitmap)

2、遍历 fd,检查就绪状态
fd_set不管有多少数据,都是全部扫描一遍,扫描1024个

3、如果没有 fd 就绪 → 阻塞线程
把线程挂起 → 被事件或超时唤醒后,再重新扫描一整遍。
唤醒时,不会传递fd,所以需要重新扫描

细节

select 返回用户态之后,内核空间里“不会保留任何 fd_set”。
下一次调用 select,不管上次有没有就绪 fd,用户都必须重新把“完整的 fd_set”传给内核。

内核把“就绪结果”写回 fd_set 后返回;
用户态必须再从 0 ~ maxfd 自己遍历一遍,才能找出哪些 fd 就绪。

poll

概念

poll 是 IO 多路复用的一种实现方式,用数组而不是位图(fd_set)来描述要监听的 fd,消除了 fd 数量上限,但内核处理模型仍是 O(n)。

解决的问题

select 的核心问题有 3 个:
1、fd 数量有上限;FD_SETSIZE = 1024
2、fd_set 是位图fd 必须是连续编号
3、每次都要全量扫描 fd

poll 的目标是:
解决 1 和 2,但不彻底解决 3

流程

pollfd结构:
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 用户关心的事件(可读/可写/异常)
short revents; // 内核返回的事件(就绪的事件)
};

1、用户态准备 pollfd 数组
2、用户态poll() 系统调用
3、内核态copy_from_user 拷贝 pollfd 数组
4、遍历 pollfd,调用每个 fd 的 poll 回调
5、如果无就绪 fd, 把线程挂起(TASK_INTERRUPTIBLE)
6、IO 中断到来, 唤醒等待队列
7、再次扫描 pollfd
8、填充 revents
9、copy_to_user 返回结果
10、用户态遍历 fds[i].revents,处理就绪 fd

4、遍历 pollfd,调用每个 fd 的 poll 回调
每一种 fd 类型
都可以实现自己的 poll() 方法

	poll方法的作用
		注册等待关系(如果之后要阻塞)
		检查当前是否就绪

“注册等待关系”指的是:
在真正阻塞之前,把“当前线程”登记到各个 fd 对应的等待队列里,
告诉内核:如果这些 fd 的状态发生变化,请唤醒我。

6、IO 中断到来, 唤醒等待队列
“IO 中断” 指的是 硬件或内核子系统在“IO 状态发生变化”时,向 CPU 发出的中断/事件通知。

epoll

解决的问题

epoll 的核心目标:
把「唤醒线程」和「具体是哪个 fd 就绪」绑定在一起,
从而避免“被唤醒后再全量扫描 fd”。

eventpoll

eventpoll 对象是 epoll 的核心

eventpoll
├── rbr (红黑树:所有监听的 fd)
├── rdllist (就绪链表:ready list)
├── wq (等待队列:epoll_wait 睡在这)
└── lock (自旋锁)

epoll_event

epoll_event就是:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event)里的event的类型
epoll_wait(epfd, events, maxevents, timeout)events[i] 的类型

在 Linux 里(<sys/epoll.h>),定义本质是下面这样(略去兼容宏)(c语言结构):

typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;   // 事件类型(EPOLLIN / EPOLLOUT / EPOLLERR / ...)
    epoll_data_t data;     // 用户自定义数据
};

流程

1、epoll_create:创建 epoll 内核对象
用户态创建
int epoll_create(int size);
epoll_create 只需要传一个参数:size,表示“你打算监控的 fd 数量的一个提示值(hint)”。
早期是预分配数据结构,减少动态扩容开销
在现代 Linux 内核中,这个参数已经基本被忽略。
成功:返回一个 epoll 文件描述符(epfd)
失败:返回 -1,并设置 errno
epfd 是一个文件描述符,它指向内核中创建的 epoll 实例对象(eventpoll)。

2、epoll_ctl(ADD):注册 fd(最关键步骤)
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
epoll_ctl(epoll 内核对象的 fd, 操作类型:添加, 要监听的目标 fd(socket / pipe / 等), 你关心的事件 + 用户数据);
&event 是用户告诉内核:“我关心这个 fd 的这些事件,并且请在返回时给我这个数据。”
&event 包含events和data
events → 关心哪些事件(可读、可写、错误、ET、ONESHOT…)
data → 用户自定义标识/数据,方便 epoll_wait 返回时快速定位 fd 或上下文

① 用户态 → 内核态,进入内核态

② 找到 epoll 内核对象(eventpoll)

③ 找到被监听 fd 对应的内核对象

④ 创建一个 epoll 监听节点(关键结构)

内核会创建一个结构体,把 epoll 和 fd 连接起来:

struct epitem {
    struct rb_node rbn;     // 红黑树节点
    struct list_head rdllink; // 就绪链表节点
    struct file *file;      // 被监听的 fd
    struct eventpoll *ep;   // 所属 epoll
    struct epoll_event event; // 你传进来的 event
};

epitem 是 epoll 的“灵魂节点”

⑤ 把 epitem 插入 epoll 的红黑树(核心)

⑥ 在“被监听 fd”上注册回调(最关键的一步)

ep_poll_callback() 会做几件事:

  1. 判断 fd 是否满足你关心的事件(EPOLLIN 等)
  2. 如果满足:
    • 把对应的 epitem 挂到 epoll 的 就绪链表
  3. 唤醒:
    • 正在 epoll_wait 上睡眠的线程

⑦ epoll_ctl(ADD) 返回用户态

此时:

  • fd 已经被 epoll “长期监听”
  • 监听关系 常驻内核
  • 不再需要每次传 fd 列表

3、epoll_wait()(用户态进入内核态)
int n = epoll_wait(epfd, events, maxevents, timeout);
epfd → 找到 struct eventpoll
events → 用户态 buffer,用于返回就绪事件;epoll_ctl参数&event 里的events
maxevents → 最大事件数
timeout → 阻塞时长,超时后,代码继续执行,后续fd会存到就绪队列,等待下一次wait

返回event的个数,超时返回0,错误返回-1。
events会被赋值。
int n = epoll_wait(epfd, events, maxevents, timeout);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
上面的events和&event格式一样,都是包含event和data,一个是单个对象,一个是对象数组

如果 ready list 为空:
把当前线程挂到 epoll 的等待队列
设置线程状态 TASK_INTERRUPTIBLE / TASK_UNINTERRUPTIBLE
线程 sleep

4、 fd 状态变化(IO 事件到来)

内核态事件触发
触发 fd 自己的 poll 方法 / 回调
epoll 注册的回调被调用:
检查 fd 是否已经在 ready list
如果不在 → 加入 ready list
wake_up(epoll_wait 的等待线程)

5、epoll_wait 被唤醒

  • 内核态:
    • 线程状态改为 RUNNABLE
    • 被调度 CPU 执行
    • 根据 ready list 中的就绪 epitem,
      构造 epoll_event,拷贝到用户态的 events 数组中,
      并根据触发模式(LT / ET)决定是否将其从 ready list 移除
  • 系统调用返回,线程切回用户态
  • 用户态拿到就绪 fd 数组,处理业务逻辑

没有再次扫描所有 fd,只返回已经准备好的 fd。

6、再次等待 / ET / LT 模式

  • LT(水平触发)
    • fd 只要仍然就绪,每次 epoll_wait 都会返回
  • ET(边缘触发)
    • fd 只有从不就绪 → 就绪时触发
    • 用户必须用非阻塞 IO 读干净,否则不会再次触发

补充

LT(Level Triggered)模式

  • 默认模式
  • 逻辑:
  1. fd 可读/可写 → 加入 ready list
  2. epoll_wait 返回事件
  3. ready list 中的 epitem 被移除(已经消费)
    • 只要 fd 仍然可读/可写
    • 下一次 epoll_wait 会再次把它加入 ready list
      → 用户没有完全读/写时,会被重复通知
  • 特性:
    • 类似 select/poll
    • “只要条件满足就会被通知”
    • 不丢事件,但可能重复通知

ET(Edge Triggered)模式

  • 边沿触发模式
  • 逻辑:
  1. fd 从不可读 → 可读,或者不可写 → 可写
    → 内核将 epitem 加入 ready list
  2. epoll_wait 返回事件
  3. ready list 中的 epitem 移除
    • 下一次 epoll_wait 不会自动加入,除非 fd 状态发生新的边沿变化
  4. 用户必须一次性读/写完
    • 否则事件丢失(不会再触发)
  • 特性:
    • 高效,减少系统调用次数
    • 但要求 非阻塞 I/O + 一次性处理完所有数据
    • nginx、redis 等高性能框架常用

细节

selete和poll就是一有返回,就把内核态的fd_set或 pollfd 数组删了,epoll是注册了,就一直存在,需要用户态手动删除,才能删除。epoll是一个一个的注册fd。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐