IO模型
一次网络IO会涉及两个系统对象:
- 等待数据准备好
- 将数据从内核空间的buffer拷贝到用户空间进程的buffer
而这五种IO模型的特点就在于以怎样的方式来处理这两个系统对象和两个阶段.
Unix 有五种 I/O 模型:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 复用(select 和 poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O(AIO)
1. 阻塞式IO
应用进程被阻塞,直到数据从内核buffer复制到应用进程buffer中才返回。
特点:
- 在准备数据阶段:被阻塞
- 数据从内核buffer复制到用户态buffer阶段:被阻塞
recvfrom
执行结束之后才能之后后面的程序2. 非阻塞式IO
用户态程序执行IO调用后,无论IO是否完成,都会立刻返回结果,应用程序需要不断的执行这个系统调用去获知IO是否完成。(注意,这里会返回IO是否已经完成的状态,而不是数据是否准备好)
特点:
- 在数据准备阶段:非阻塞式(会立刻返回一个错误码)
- 数据从内核buffer复制到用户态buffer阶段:阻塞式
recvfrom
会立刻返回结果,一般会用一个循环来不停的去判断IO是否完成。- 实时性会比较好,但CPU利用率比较低
3. IO复用
让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。
如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。
特点:
- 数据准备阶段:阻塞,并会返回一个“事件已经发生的信号”(这里的事件就是数据已经准备好了)
- 数据从内核buffer复制到用户态buffer阶段:阻塞
IO复用的实现有select/poll/epoll,后面会详细说
4. 信号驱动 I/O
个人理解 信号驱动IO = 事件驱动机制 + 非阻塞式IO
信号驱动IO是指:进程预先告知内核,使得 当某个socketfd有events(事件)发生时,内核使用信号通知相关进程。
因此通知完了之后,并不会被阻塞。
当内核通知相关进程,它感兴趣的事件发生了的时候(这里就是数据已经准备好了),然后再去做recvfrom,将数据从内核态复制到用户态。
特点:
- 数据准备阶段:非阻塞式。(因为只是向OS发送一个通知,立刻就返回了)
- 数据从内核buffer复制到用户态buffer阶段:阻塞式。
- 相比于前面的非阻塞式IO的轮询,信号驱动IO的CPU利用率更高
5. 异步IO
理解异步IO,先要理解异步,通俗来说就是,只要我触发了一IO调用,那么这个IO在这之后的任何一个时刻完成对我的程序都不会有影响,因此我就没必要非等你IO完了才继续往下执行,而是利用“委派”的思想,让内核去帮我完成.
因此,在调用完异步IO的系统调用(例如aio_read)之后,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
注意,异步IO也是 “事件驱动 + 非阻塞” , 但它和信号驱动IO的区别是, 异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。
特点:
- 数据准备阶段:非阻塞
- 数据从内核buffer复制到用户态buffer阶段:非阻塞
- 相当于把IO操作给委派出去了,所以自己完全不会被阻塞
五大 I/O 模型比较
- 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
- 异步 I/O:第二阶段应用进程不会阻塞。
同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,
它们的主要区别在第一个阶段。
非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。
IO复用——select/poll/epoll
这三种对IO复用的实现方式的区别主要在于:
- 对socketfd的存储方式
- 以怎样的方式去通知用户级进程去获取已经发生的事件(或者说用户级进程用怎样的方式去获取已经放发生的事件)
1. select
1 | int main() |
优点:
- 每个操作系统都实现了select模式,可移植性较强
- 在一些监听事件个数比较少的情况下,也比较优秀
缺点:
- 因为对文件描述符的存储是一个1024的bitmap, 所以一个进程最多监听的事件个数被限制。
- 因为select每次都会对传入的readset进行一个修改,所以在每次轮询的时候,都要重新进行一次初始化,这也是比较低效的
- 事件监听的种类是通过传入fd_set类型的参数来进行的,因此不太好扩展.
- 每次都需要遍历所有的文件描述符才能知道事件发生的是哪一个socket,如果监听的事件比较多,且只有一个事件发生,那么也需要遍历全部,这样显然是很低效的。
- 在进行IO的时候,需要将存储文件描述符状态信息的bitmap从内核态复制到用户态,这一复制过程开销也比较大.
2. poll
poll的实现大体上和select类似,只是改变了对事件的存储形式,不再使用fd_set, 而是使用一个结构体来保存. 这个结构体中保存的数据主要有:
- 对应的文件描述符
- 被监听的事件类型
- 事件是否发生的一个标志位
1
2
3
4
5struct pollfd {
int fd; // 对应的文件描述符
short events; // 被监听的事件类型
short revents; // 事件是否发生的一个标志位
};
1 | for (i=0;i<5;i++) |
优点:
- 解决了select的监听事件个数存在上限问题,和每处理一轮请求需要重新初始化的问题。
缺点:
- 并不是所有系统都实现了poll模型,因此跨平台性较差
- 依然存在遍历所有事件 和 内核态数据复制到用户态的问题(用户态到内核态复制的数据,之前在select里面是一个bitmap,而在poll里面,是一个结构体数组)
3. epoll
3.1 epoll_create
1 | int epoll_create(int size); |
epoll_create() 该 函数生成一个epoll专用的文件描述符。它其实是在内核申请一空间,用来存放你想关注的socket fd上是否发生以及发生了什么事件。
3.1 epoll_ctl
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
epoll_ctl() ⽤于向内核注册新的描述符或者是改变某个文件描述符的状态。
3.1 epoll_wait
1 | int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); |
已注册的描述符在内核中会被维护在 ⼀棵红⿊树上,通过回调函数内核会将 I/O 准备好的描述符加入到⼀个链表中管理(这个链表就是传入的第二个指针参数events),进程调⽤ epoll_wait() 便可 以得到事件完成的描述符。
1 | struct epoll_event events[5]; |
优点:
- 不需要每次遍历所有监听的events,而只用去遍历已经发生的events
- 因为申请的内存是内核态用户态共享的一块内存,所以在注册需要监听的socketfd信息的时候,省去了一次用户态->内核态的开销
缺点:
- 使用epoll_create申请一块用户态和用户态共享的一块内存
- 使用epoll_ctl来注册一个socket文件描述符,或是更改一个socket文件描述的状态,注册的文件描述符信息保存在epoll_create申请的那块内存中,并且文件描述符是被维护在一个红黑树上。因为这块内存是用户态和内核态共享的,所以才能够省去一次用户态复制到内核态的开销。
- socket文件描述符被注册到了共享内存中了之后,当事件来临时,例如数据已经准备完毕了,会触发一个中断,会将这个已经发生了的事件(数据已经准备好了)对应的socketfd给加入到一个链表中尽心管理
- 在调用epoll_wait的时候,实际上就是从这个链表中,获取事件完成的描述符。 因此,epoll_wait将返回事件完成的个数(假设为k, k>0),并且传入的events数组的前k个就是事件已经发生了的对应的描述符,因此能够减少O(N)的遍历,从而变成O(K)的遍历.
select/poll/epoll的应用场景
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
1. select 应用场景
select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。
select 可移植性更好,几乎被所有主流平台所支持。
2. poll 应用场景
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
3. epoll 应用场景
只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。
需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。 因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。