操作系统额外内容 - IO 复用常用函数

select

该函数的核心作用是,让程序同时监听多个文件的描述符(File Descriptor FD)的可读/可写/异常事件,当其中任意一个 FD 满足条件时,函数返回并通知程序处理事件。

原型

Linux 中 select 函数的原型为

1
2
3
4
5
6
7
8
9
10
#include <sys/select.h>
#include <sys/time.h>

int select(
int nfds, // 监听的最大 FD 编号 + 1
fd_set *readfds, // 监听“可读”事件的FD集合
fd_set *writefds, // 监听“可写”事件的FD集合
fd_set *exceptfds, // 监听“异常”事件的FD集合
struct timeval *timeout // 超时时间(阻塞/非阻塞/定时)
);

入参中的 timeout 为一个结构体,其原型为

1
2
3
4
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
}

当指定为永久等待时,该参数为 null,根本不等待而是立即返回(也就是轮询)时,该结构的值为 {0,0}。虽然该结构允许微秒级操作,但许多 Unix 内核会把超时值向上舍入成 10ms 的倍数;此外还存在调度延迟问题(定时器超时后,内核需额外时间调度进程运行)。

中间3个集合均为描述符集,通常是一个整数数组,每个整数的每一位对应一个 FD。另外,这三个参数均为值-结果参数。调用该函数时,描述符集内任何未就绪 FD 均清成0。因此,每次重新调用该函数时,都得将所有集合内关心的位置设置为1。

成功时返回就绪的 FD 总数(可读 + 可写 + 异常);若返回 0 则表示超时(无 FD 就绪),若返回 -1 则出错。

FD 就绪条件

满足以下条件时 FD 准备好读

  1. 接收缓冲区数据量大于等于低水位。
  2. 另外一段关闭了链接,发送了 FIN。
  3. 该套接字是一个监听套接字且已完成的连接数不为0。
  4. 套接字错误待处理。

满足以下条件时 FD 准备好写

  1. 发送的缓冲区数据量大于等于低水位。
  2. connect 的已建立链接或连接失败。
  3. 套接字错误待处理。

若一个套接字存在带外数据或仍处于带外标记,那么它有异常条件待处理。

带外数据 的核心是 TCP 紧急数据,特点是只有1字节,优先传输。
带外标记 的作用是标记仅仅数据在正常数据流中的位置,方便定位处理。

由上可以看出,当某个套接字发生错误时,它会被标记为既可读又可写。接收低水位标记合发送低水位标记的目的在于,允许应用进程控制在 select 返回可读或可写前,至少有多少数据可读,或有多大空间可写。

核心宏

该函数需要通过如下宏操作 FD 集合:

1
2
3
4
void FD_ZERO(fd_set *set);        // 清空 FD 集合
void FD_SET(int fd, fd_set *set); // 将 FD 加入集合
void FD_CLR(int fd, fd_set *set); // 将 FD 移出集合
int FD_ISSET(int fd, fd_set *set);// 检查 FD 是否在就绪集合中

特点

该函数的跨平台性好,且简单易懂,但该函数存在以下问题

  1. FD 上限受 FD_SETSIZE 限制(Linux 中默认 1024);
  2. 每次调用需重新初始化 fd_set(因内核会修改);
  3. 返回后需遍历所有 FD 才能确定就绪的具体 FD,效率低;
  4. 内核每次调用都要拷贝 fd_set 到内核态,频繁调用开销大。

poll

pollselect 的改进版,解决了后者数量硬限制等痛点。

原型

1
2
3
4
5
6
7
#include <poll.h>

int poll(
struct pollfd *fds, // 待监听的FD数组(每个元素描述一个FD和要监听的事件)
nfds_t nfds, // FD数组的长度(要监听的FD数量)
int timeout // 超时时间(毫秒)
);

此处的入参使用动态长度的 pollfd 数组代替固定大小的 fd_set,从而突破了 FD_SETSIZE 的限制。

1
2
3
4
5
struct pollfd {
int fd; // 要监听的文件描述符(FD),设为-1则忽略该元素
short events; // 要监听的事件(输入,比如POLLIN/POLLOUT)
short revents; // 实际发生的事件(输出,由内核填充)
};

fd=-1,内核会忽略该 pollfd 元素,返回时 revents 置为 0。

timeout 是一个指定应等待毫秒数的正值。若指定为永远等待,则应该使用常数 INFTIM,即 -1。但该宏已被废弃,直接用 -1 就好。

返回值表示的含义与 select 一致。

成员描述符

以下是指定 events 表示以及测试 revents 标志的一些常值。

poll 函数输入的 events 合返回的 revents

其中,第一部分为处理输入的三个常值,第二部分为处理输出的三个常值,第三部分为处理错误的三个常值。

特点

select 会直接修改传入的 readfds/writefds 集合,因此每次调用 select 前都必须重新初始化集合(因为既然是监听那肯定是放在 while(1) 里面的)。在 select 的基础上,poll 用结构体单独描述每个 FD 的监听需求和改进状态,避免了 select 中修改集合的问题。

poll 识别三类数据,普通(normal)、优先级带(priority band)和高优先级(high priority)。但仅高优先级对 TCP/UDP有意义,优先级带仅适用于 STREAMS 套接字。

且就 TCP 和 UDP 套接字而言,该 reevents 定义有许多漏洞。比如,TCP 链接存在的错误即可认为是普通数据,也可认为是错误,监听新连接可用数据即可认为是普通数据,也可认为是优先级数据(大部分时候是普通数据),等等。

其存在的某些问题其实和 select 是一样的,也就是没有优化大量 FD 复制于用户态和内核态地址空间之间,以及部分描述符就绪就触发整体描述符集合全部遍历的低效问题。

epoll

前两者都要遍历所有 FD,比较低效。而 epoll 则解决了这个问题,专为高并发场景而生。

epoll 创建了一个内核级的“事件表”(红黑树存储已注册的 FD),只需要将 FD 一次性注册到列表中而无需在每次调用后都传递。同时,内核主动记录就绪的 FD,仅返回就绪的 FD 列表,无需遍历所有监听的 FD。除此之外,epoll 同时支持水平触发 LT 和边缘触发 ET

该接口一共有三个函数

epoll_create

1
int epoll_create(int size);

该函数生成一个 epoll 函数专用的 FD。在 Linux 2.6.8 后,size 参数默认被忽略,现在只需要填写任意一个大于0的值就好。其返回值返回 epoll 专用的 FD,失败则为 -1。

epoll_ctl

1
2
3
4
5
6
int epoll_ctl(
int epfd, // epoll_create返回的epoll_fd
int op, // 操作类型
int fd, // 要监听的FD
struct epoll_event *ev // 监听的事件和FD关联数据
);

op 参数表示动作,用三个宏表示

  • EPOLL_CTL_ADD(添加)
  • EPOLL_CTL_MOD(修改)
  • EPOLL_CTL_DEL(删除)

epoll 事件注册函数,用于注册/修改/删除监听事件。它不同于 select 是在监听事件时告诉内核,要监听什么类型的事件,而是在这里要先注册要监听的事件类型。

核心结构体 epoll_event

1
2
3
4
5
6
7
8
9
10
11
12
struct epoll_event {
uint32_t events; // 监听的事件
epoll_data_t data; // 关联数据(通常存FD)
};

// 联合体,存放FD或自定义指针
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

常用监听的事件有以下几种

  • EPOLLIN FD 可读。这里面也包括 socket 正常关闭。
  • EPOLLOUT FD 可写
  • EPOLLET FD 的边缘触发 ET 模式,默认是 LT
  • EPOLLONESHOT FD 只监听一次事件,出发后需要重新注册

返回0表示成功,返回1表示失败。

epoll_wait

1
2
3
4
5
6
int epoll_wait(
int epfd, // epoll_fd
struct epoll_event *events, // 输出参数:存放就绪的事件
int maxevents, // events数组的最大长度
int timeout // 超时时间(毫秒,-1=永久阻塞,0=非阻塞)
);

epoll 等待事件就绪函数。等待事件的产生,收集在 epoll 监控事件中已经发送的事件。成功时返回需要处理的事件数目,0时表示超时,-1时表示失败。

工作原理

  1. 内核通过红黑树存储所有已经注册的 FD。
  2. 内核检测到 FD 就绪时,将该 FD 事件信息拷贝到就绪链表。此时红黑树中的 FD 还没有删除。
  3. 进程调用 epoll_wait 时,若就绪链表非空,内核直接将就绪事件拷贝到用户态 events 数组;若为空则进程睡眠,直到有事件就绪或超时。
  4. epoll 仅在 epoll_ctl 时拷贝少量元数据到内核态,epoll_wait 时拷贝就绪 FD 信息,大幅减少内存拷贝开销。

如果并发数只有几个或十几个,那么其实 poll 更有优势。但在高并发场景下一般用 epollepoll 是 Linux 特有接口,无跨平台性。

LT/ET

  • 水平触发 LT 时,只要 FD 处于就绪状态,每次调用 epoll_wait 都会触发事件。
  • 边缘触发 ET 时,仅当 FD 从非就绪变为就绪时触发一次事件,后续不再触发。

协程

有了线程这个概念为什么要需要协程?先看看线程的痛点。

线程的核心痛点

线程虽然实现了并发,但它是内核态的调度单位,因此也就导致了以下几个问题

  1. 切换/调度开销极大。在调度时需要做上下文切换,每次切换时耗时为微妙级,在高并发场景下会被无限放大。
  2. 内存开销高。每个线程会默认分配几 MB 的空间(Linux 下默认为 8MB),在多个线程时占用空间巨大。
  3. 线程是抢占式调度,操作系统随时可能打断线程。多个线程操作共享数据时容易产生死锁等问题。
  4. 实际业务中,大部分并发场景都是 IO 密集型。线程在执行这些任务时,绝大部分时间都在等待,但线程在等待时,仍然会占用大量资源。

协程的概念

协程是用户态的轻量级的线程,可以通俗理解为“可以暂停和恢复的函数”。

  1. 其切换开销极小,仅需要保存函数暂停点,可以达到纳秒级。
  2. 内存开销低,由于其可以看作是一种函数,因此它占用的是栈空间,初始仅几 KB。
  3. 协程是非抢占式调度。
  4. 对于 IO 密集型任务,协程在遇到等待时会主动暂停并交出控制权,让其他协程执行。

协程的作用,实在执行函数 A 时,可以随时中断,去执行另一个函数 B,然后再次中断继续执行函数 A。但这一过程并不是函数调用,因为没有调用语句,这一整个过程看似像多线程,然而协程只有一个线程执行。

虚拟线程

在 Java 中,协程的概念也叫做虚拟线程。详情看并发的文章


操作系统额外内容 - IO 复用常用函数
https://ivanclf.github.io/2026/03/17/os-ex3/
作者
Ivan Chan
发布于
2026年3月17日
许可协议