操作系统额外内容 - IO 复用常用函数
select
该函数的核心作用是,让程序同时监听多个文件的描述符(File Descriptor FD)的可读/可写/异常事件,当其中任意一个 FD 满足条件时,函数返回并通知程序处理事件。
原型
Linux 中 select 函数的原型为
1 | |
入参中的 timeout 为一个结构体,其原型为
1 | |
当指定为永久等待时,该参数为 null,根本不等待而是立即返回(也就是轮询)时,该结构的值为 {0,0}。虽然该结构允许微秒级操作,但许多 Unix 内核会把超时值向上舍入成 10ms 的倍数;此外还存在调度延迟问题(定时器超时后,内核需额外时间调度进程运行)。
中间3个集合均为描述符集,通常是一个整数数组,每个整数的每一位对应一个 FD。另外,这三个参数均为值-结果参数。调用该函数时,描述符集内任何未就绪 FD 均清成0。因此,每次重新调用该函数时,都得将所有集合内关心的位置设置为1。
成功时返回就绪的 FD 总数(可读 + 可写 + 异常);若返回 0 则表示超时(无 FD 就绪),若返回 -1 则出错。
FD 就绪条件
满足以下条件时 FD 准备好读
- 接收缓冲区数据量大于等于低水位。
- 另外一段关闭了链接,发送了 FIN。
- 该套接字是一个监听套接字且已完成的连接数不为0。
- 套接字错误待处理。
满足以下条件时 FD 准备好写
- 发送的缓冲区数据量大于等于低水位。
connect的已建立链接或连接失败。- 套接字错误待处理。
若一个套接字存在带外数据或仍处于带外标记,那么它有异常条件待处理。
带外数据 的核心是 TCP 紧急数据,特点是只有1字节,优先传输。
带外标记 的作用是标记仅仅数据在正常数据流中的位置,方便定位处理。
由上可以看出,当某个套接字发生错误时,它会被标记为既可读又可写。接收低水位标记合发送低水位标记的目的在于,允许应用进程控制在 select 返回可读或可写前,至少有多少数据可读,或有多大空间可写。
核心宏
该函数需要通过如下宏操作 FD 集合:
1 | |
特点
该函数的跨平台性好,且简单易懂,但该函数存在以下问题
- FD 上限受
FD_SETSIZE限制(Linux 中默认 1024); - 每次调用需重新初始化
fd_set(因内核会修改); - 返回后需遍历所有 FD 才能确定就绪的具体 FD,效率低;
- 内核每次调用都要拷贝
fd_set到内核态,频繁调用开销大。
poll
poll 是 select 的改进版,解决了后者数量硬限制等痛点。
原型
1 | |
此处的入参使用动态长度的 pollfd 数组代替固定大小的 fd_set,从而突破了 FD_SETSIZE 的限制。
1 | |
若 fd=-1,内核会忽略该 pollfd 元素,返回时 revents 置为 0。
timeout 是一个指定应等待毫秒数的正值。若指定为永远等待,则应该使用常数 INFTIM,即 -1。但该宏已被废弃,直接用 -1 就好。
返回值表示的含义与 select 一致。
成员描述符
以下是指定 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 | |
该函数生成一个 epoll 函数专用的 FD。在 Linux 2.6.8 后,size 参数默认被忽略,现在只需要填写任意一个大于0的值就好。其返回值返回 epoll 专用的 FD,失败则为 -1。
epoll_ctl
1 | |
op 参数表示动作,用三个宏表示
EPOLL_CTL_ADD(添加)EPOLL_CTL_MOD(修改)EPOLL_CTL_DEL(删除)
epoll 事件注册函数,用于注册/修改/删除监听事件。它不同于 select 是在监听事件时告诉内核,要监听什么类型的事件,而是在这里要先注册要监听的事件类型。
核心结构体 epoll_event:
1 | |
常用监听的事件有以下几种
EPOLLINFD 可读。这里面也包括 socket 正常关闭。EPOLLOUTFD 可写EPOLLETFD 的边缘触发 ET 模式,默认是 LTEPOLLONESHOTFD 只监听一次事件,出发后需要重新注册
返回0表示成功,返回1表示失败。
epoll_wait
1 | |
epoll 等待事件就绪函数。等待事件的产生,收集在 epoll 监控事件中已经发送的事件。成功时返回需要处理的事件数目,0时表示超时,-1时表示失败。
工作原理
- 内核通过红黑树存储所有已经注册的 FD。
- 内核检测到 FD 就绪时,将该 FD 事件信息拷贝到就绪链表。此时红黑树中的 FD 还没有删除。
- 进程调用
epoll_wait时,若就绪链表非空,内核直接将就绪事件拷贝到用户态events数组;若为空则进程睡眠,直到有事件就绪或超时。 epoll仅在epoll_ctl时拷贝少量元数据到内核态,epoll_wait时拷贝就绪 FD 信息,大幅减少内存拷贝开销。
如果并发数只有几个或十几个,那么其实 poll 更有优势。但在高并发场景下一般用 epoll。epoll 是 Linux 特有接口,无跨平台性。
LT/ET
- 水平触发 LT 时,只要 FD 处于就绪状态,每次调用
epoll_wait都会触发事件。 - 边缘触发 ET 时,仅当 FD 从非就绪变为就绪时触发一次事件,后续不再触发。
协程
有了线程这个概念为什么要需要协程?先看看线程的痛点。
线程的核心痛点
线程虽然实现了并发,但它是内核态的调度单位,因此也就导致了以下几个问题
- 切换/调度开销极大。在调度时需要做上下文切换,每次切换时耗时为微妙级,在高并发场景下会被无限放大。
- 内存开销高。每个线程会默认分配几 MB 的空间(Linux 下默认为 8MB),在多个线程时占用空间巨大。
- 线程是抢占式调度,操作系统随时可能打断线程。多个线程操作共享数据时容易产生死锁等问题。
- 实际业务中,大部分并发场景都是 IO 密集型。线程在执行这些任务时,绝大部分时间都在等待,但线程在等待时,仍然会占用大量资源。
协程的概念
协程是用户态的轻量级的线程,可以通俗理解为“可以暂停和恢复的函数”。
- 其切换开销极小,仅需要保存函数暂停点,可以达到纳秒级。
- 内存开销低,由于其可以看作是一种函数,因此它占用的是栈空间,初始仅几 KB。
- 协程是非抢占式调度。
- 对于 IO 密集型任务,协程在遇到等待时会主动暂停并交出控制权,让其他协程执行。
协程的作用,实在执行函数 A 时,可以随时中断,去执行另一个函数 B,然后再次中断继续执行函数 A。但这一过程并不是函数调用,因为没有调用语句,这一整个过程看似像多线程,然而协程只有一个线程执行。
虚拟线程
在 Java 中,协程的概念也叫做虚拟线程。详情看并发的文章