文章目录
- 概要
- 一、I/O基础
- 二、阻塞式I/O
- 三、非阻塞式I/O
- 三、I/O多路复用
- 四、信号驱动I/O
- 五、异步I/O
- 六、小结
概要
在工作中,经常使用Nginx、Redis等开源组件,常提到其高性能的原因是网络I/O的实现是基于epoll(多路复用)。这次呢就基于Unix网络编程卷1的第6章【I/O复用:select和poll函数】,总结下Unix五种I/O模型(阻塞、非阻塞、多路复用、信号驱动、异步)。
一、I/O基础
我们在Linux下进行网络编写时,一般是通过Glibc库的Socket API(socket、bind、listen、accept、read、write、connect、close等API)来完成的,如下图:
这只是表面的使用,那socket之下的OSI五层模型是如何体现的呢?见下图:
二、阻塞式I/O
阻塞式I/O是最基本的I/O模型,很好理解,就是每次系统调用(以read、waite API为例)时,如果此时不可读或写,那么调用会被阻塞,让后进程被挂起,直到内核通知进程可读或可写时,进程被唤醒,进行读写操作。
如下图:
ps:以recvfrom函数作为系统调用的代表,其他章节也如此。
阻塞式I/O实战(多进程模型)
三、非阻塞式I/O
非阻塞式I/O模型如下图:
可以明显感觉到阻塞是I/O模型很低效,于是非阻塞式I/O模型诞生了,其进行系统调用(以read、waite API为例)时,如果此时不可读或写,那么就返回一个错误码(EAGAIN或EWOULDBLOCK),而不是阻塞当前进程,这样进程可以处理其他任务后再进行系统调用。
非阻塞式I/O模型实战。
三、I/O多路复用
不管是阻塞I/O还是非阻塞I/O,其同一时刻只能以一个客户端建立连接,如果要同时与多个客户端维持连接,即管理多个socket连接,分化出了两种编程模型:
- 1)多线程或进程;
- 2)通过数组等方式保存socket fd,不断轮询;
在没有多路复用之前,实现与多个客户端维持连接的传统方法是多线程或进程,但是起一个子线程或进程的系统开销过大,所以可同时维持的客户端连接数量非常有限。
之所以传统维持多个客户端连接不常用第二种方式,主要是实现过于复杂,由于社会发展,基于阻塞or非阻塞模型的多进程/线程技术不足以支撑如此多的客户端,于是引入了I/O多路复用,即第二种方式来管理多个socket连接。
I/O多路复用解释如下:
多路:多个socket连接(即多个客户端连接);
复用:允许内核监听多个socket描述符,一旦发现进程指定的一个或多个I/O事件就绪(TCP三次握手成功、可读,可写),就通知该进程。
其模型图如下:
I/O多路复用的API依次有三个select、poll、epoll。其中epoll性能最高,往往与Reactor编程模式结合,是目前常用的高效搭配模式。
select实战;
poll实战;
epoll实战。
本人研究Redis相关源码时,观察到其在Linux下就是通过epoll+非阻塞I/O+Reactor组合来处理I/O事件,是搞起高性能的一个关键点。
四、信号驱动I/O
信号驱动IO模型,顾名思义,其是通过信号来完成的,即当socket描述符准备就绪时,内核发送SIGIO通知进程。
信号驱动IO模型实战。
这种模型的优势在于等待socket就绪期间进程不会被阻塞,主循环依旧可以执行,只需关注来自信号函数的通知即可。但是该模型在实际项目中很少使用,了解理解即可。
五、异步I/O
异步I/O由POSIX规范定义。一般来说其工作机制:告知内核启动某个动作,并让内核将整个动作(包括将数据从内核复制到用户缓冲区)完成后通知进程。
该机制与信号驱动I/O模型的区别是:信号驱动I/O模型是由内核通知进程何时可以开始一个I/O操作,但异步I/O是由内核通知进程I/O操作何时完成。
进程调用系统函数aio_read(POSIX规范下异步I/O函数以aio_或lio_开头)之后,无论内核数据是否准备好,都会立即,然后进程可以去做别的事情。内核会自动处理I/O数据,并将内核直接复制数据给进程,然后向进程发送信号,进程收到信号后直接处理数据即可。
异步I/O模型是最理想的模型,但是自诞生之日起的具体实现方式(glibc aio、libaio、libeio…)就有这样或那样的缺点,2019 年 Linux 5.1 内核首次引入的高性能 异步 I/O 框架io_uring ,能显著加速 I/O 密集型应用的性能,并且使用简单,适应度广,足够稳定,但诞生的太晚了,远没有epoll应用的范围广。
异步I/O模型实战。
六、小结
通过对五种I/O模型的描述,可以发现前四种模型的区别主要在于第一阶段的处理方式(阻塞I/O和多路复用是阻塞的,非阻塞I/O【轮询】和信号驱动【通知】是非阻塞的),第二阶段形同:将数据从内核区复制到用户区,都进程都会阻塞于recvfrom函数。但异步I/O的两个阶段内核都帮我们做了,这与前四种模型是截然不同的。
五种I/O模型比较如下:
最后聊一下阻塞、非阻塞、同步、异步在Unix网络编程语境下的概念:
阻塞:指调用结果返回之前,当前进程会被挂起,只有得到结果之后才会返回。
非阻塞:指如果不能立刻得到结果,当前进程不会被挂起。Unix下是通过立即返回规定错误码来实现的。
同步:I/O操作会阻塞当前进程,直至操作完成。由此可知前四种模型都是同步的(非阻塞I/O如果有数据时依旧需等待数据从内核区复制到用户区,或阻塞当前进程)。
异步:I/O操作不会阻塞当前进程。
我们可以举这样一个场景,小明要喝开水,客厅有其他事情,而烧水壶在卧室。
小明打开热水壶,站在热水壶面前一直等待水开 => 阻塞
小明打开热水壶后回到客厅做其他事情,一会去卧室检查一下水是否烧开,烧开再使用 => 非阻塞
小明觉得一会去卧室检查一下太费劲了,于是换了一个水开报警的热水壶:
现在小明打开热水壶后回到客厅做其他事情,等到热水壶报警再去卧室用水 => 异步
ps:我们举的场景只有一个阶段,而Unix五种I/O模型是有两个阶段的【第一阶段获取I/O就绪事件,第二阶段将I/O数据从内核区复制到用户区】,只有异步I/O模型两个阶段都是异步的