IO复用
IO模型,复用原理以及select/poll/epoll函数
IO模型
Unix操作系统重,一般分为用户空间和内核空间。内核空间由操作系统内核进行控制管理,比如内存系统,文件系统等,用户程序不能直接访问内核空间,需要通过系统调用(syscall)访问内核系统的功能;用户空间则一般是用户可以直接访问的,应用程序执行于用户空间。
一般来说,unix中包含四种常见的IO模型:
- 阻塞IO - Blocking I/O;
- 非阻塞IO - Non-Blocking I/O;
- IO复用(
select
,poll
,epoll
) - I/O Multiplexin; - 信号驱动IO - Signal Driven I/O;
- 异步 I/O(aio_ 系列函数)- Asynchronous I/O;
io_uring
严格来说不属于以上哪一种IO模型,因为其应用的复用的思想,但是使用了共享缓冲区,不存在内核到用户空间的复制过程。
在socket编程中,经常需要对比和选择合适的IO模型和方法进行使用。
阻塞IO
调用阻塞的接口时,这个进程一直阻塞在 recvfrom 调用,直到
- 内核把数据准备好;
- 从内核空间拷贝数据到用户空间缓冲区; 然后返回数据。
非阻塞IO
把套接字接口设置成非阻塞方式,就是告诉内核,请求I/O操作没有数据时,不要使进程阻塞(进程睡眠),而是返回一个错误。即:
- 内核把数据准备好前,调用直接返回错误;
- 内核准备好数据后,调用阻塞等待数据被拷贝到应用程序缓冲区,程序返回成功。
IO多路复用
把多个fd注册到多路复用器上,然后使用一个进程监听。只要有一个文件描述符准备好,就返回该fd。即:
- select调用将多个文件描述符注册,同时监听是否可读;当存在fd完成内核的数据准备时,select返回;或者阻塞时长超过timeout返回(也可设置无限等待);
- I/O 多路复用内部使用非阻塞 I/O 检查每个描述符的就绪状态;
- 获取返回的fd,可以使用接口将数据复制到用户空间;
IO复用是最常用的IO模型,对比其他模式,IO复用模型避免了文件描述符较多的场景下频繁的用户态和内核态的切换,减少了系统调用的开销。I/O 多路复用引入了一些额外的操作和开销,性能更差。但是好处是减少线程开销。如果不采用 I/O 多路复用,则必须通过多线程的方式,每个线程处理一个 I/O 请求。后者线程切换也是有一定的开销的。
信号驱动IO
信号驱动就是内核在 FD(文件描述符)准备好时用信号 SIGIO 通知应用程序。即:
- 通过系统调用安装一个信号处理。此系统调用立即返回,进程继续工作,它是非阻塞的;
- 当内核数据报准备好时,就为该进程生成一个信号,给进程发信号,可以立即在信号处理程序中调用接口读取数据报。
异步IO
异步IO让内核来操作整个数据过程,在内核把数据准备完成后,从内核拷贝到应用进程缓冲区,然后通知应用进程。接口函数一般以 aio_ 开头。与信号驱动IO的区别在于:异步IO不仅将数据在内核中准备好,且将数据拷贝到用户空间之后在向用户发送信号。
五种IO模型的异同
可以看出,这几种IO模型都包含两个阶段:
- 第一阶段:等待内核准备好数据;
- 第二阶段:将数据从内核拷贝到用户空间(应用进程缓冲区);
除了异步IO之外,其他四种IO模型都会在内核数据准备好之后阻塞于数据从内核复制到用户空间。
IO复用
select
1
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中:
- nfds: 要检测的文件描述符的范围,为文件最大描述符+1
- readfds: 包含所有可能因状态变成可读而触发select函数返回的文件描述符
- writefds: 包含所有可能因状态变成可写而触发select函数返回的文件描述符
- exceptfds: 包含所有可能因状态发生异常而触发select函数返回的文件描述符
select的缺点:
- 性能开销:
- 调用select的时候需要内核执行,需要将参数拷贝到内核空间;
- 内核需要遍历参数中监听的所有fd,不管其是否就绪;
- 同时可以监听的fd太少,收到
sizeof(fd_set)
的限制,一般内核限制是1024。
poll
原理于select基本类似,性能也基本类似。但其没有最大数量的限制。在用户态通过数组方式传递文件描述符,在内核会转为链表方式存储。
1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中:
- fds:一个pollfd结构体类型的数组;
- nfds:指出数组大小;
epoll
epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。epoll 模型使用三个函数:
epoll_create
: 创建一个 epoll 实例。epoll_ctl
:监听文件描述符 fd 上发生的 event 事件epoll_wait
:epoll模型的主要函数,功能相当于 select。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int epoll_create(int size);
// epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
// fd 表示要监听的目标文件描述符
// op 表示要对 fd 执行的操作,包括添加监听事件,改变监听方式,删除监听事件等
// event 表示要监听的事件(可读、可写、发送错误…)
// 返回值0/-1表示是否成功
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
// events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
// maxevents 指定 events 的大小,events
// timeout 类似于 select 中的 timeout。epoll_wait 会阻塞 timeout 毫秒或者一直阻塞(timeout==-1)或者直接返回(timeout==0)
// 返回值为就绪数量,不超过maxevents
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
epoll_wait
的工作流程:
- epoll_wait调用ep_poll,当rdlist(就绪列表)为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒;
- 文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用;
- ep_poll_callback将相应fd对应epitem加入rdlist,导致rdlist不空,进程被唤醒,epoll_wait得以继续执行;
- ep_events_transfer函数将rdlist中的epitem拷贝到txlist(本次调用返回列表)中,并将rdlist清空;
- ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对用的poll方法。此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。
epoll
有以下几个特点:
- 使用红黑树存储文件描述符集合;
- 使用链表存储就绪的文件描述符;
- 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态;
使用mmap减少了复制?
其中:
- 特点3使得
epoll
无需每次调用都向内核复制文件描述符,从而减少了开销; - 特点1、2,结合对应事件就绪时触发的回调函数,将就绪fd添加到就绪队列,以及就绪队列唤醒进程从而避免遍历,实现了
epoll_wait
O(1)的复杂度。
为什么使用红黑树?
- 红黑树需要支持文件描述符的添加或删除,本身插入和删除性能比较好,时间复杂度O(logN);
- 防止了重复添加;
- 虽然哈希表也能满足优点 1 & 2,但是相比之下:
- 红黑树处理大规模数据效率高,哈希表插入删除效率为O(logN),大规模数据情况下优于哈希表,小规模数据下可能还是哈希表更优;
- 红黑树处理完大规模数据后更容易缩容,哈希表容易产生哈希碰撞,需要重新分配哈希空间等问题;且哈希表不容易缩容;
epoll并不是在所有的应用场景都会比select和poll高很多。尤其是当活动连接比较多的时候,回调函数被触发得过于频繁的时候,epoll的效率也会受到显著影响!所以,epoll特别适用于连接数量多,但活动连接较少的情况。
- 水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知;
- 边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知;
区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符;边缘触发必须使用非阻塞 I/O,因为需要多次读取,知道读取完数据未知,否则可能出现数据漏读。
如果使用 epoll
的边缘触发模式,在收到通知时,必须使用非阻塞 I/O,并且必须循环调用 read
或 write
多次,直到返回 EWOULDBLOCK
为止,然后再调用 epoll_wait
等待操作系统的下一次通知。
Ps. 以前Redis有过单线程的时候,可能就是因为网络IO比较慢,用IO复用已经足够,更多的是防止竞争充分利用CPU的性能。
三者对比
select
,poll
均只支持LT,epoll
支持LT和ET;- 对比
epoll
,select
,poll
调用开销大,需要额外的拷贝和遍历;
io_uring
另外可以了解io_uring
,用以替代epoll。其在存储应用程序中非常高效,但在网络任务中的研究较少。