操作系统的 IO 模型
IO 操作根据设备类型一般分为内存 IO,网络 IO,和磁盘 IO。其中内存 IO 的速度大大快于后两者,计算机的性能瓶颈一般不在于内存 IO. 尽管网络 IO 可通过购买独享带宽和高速网卡来提升速度,可以使用 RAID 磁盘阵列来提升磁盘 IO 的速度,但是由于 IO 操作都是由系统内核调用来完成,而系统调用是通过 cpu 来调度的,而 cpu 的速度远远快于 IO 操作,导致会浪费 cpu 的宝贵时间来等待慢速的 IO 操作。为了让 cpu 和慢速的 IO 设备更好的协调工作,减少 CPU 在 IO 调用上的消耗,逐渐发展出各种 IO 模型。
IO 模型
IO 步骤
I/O 主要为:网络 IO(本质是 socket 文件读取)、磁盘 IO 每次 IO,对于一次 IO 访问,数据会先被拷贝到内核的缓冲区中,然后才会从内核的缓冲区拷贝到应用程序的地址空间。需要经历两个阶段:
第一步:将数据从文件先加载至内核内存空间(缓冲区),等待数据准备完成,时间较长
第二步:将数据从内核缓冲区复制到用户空间的进程的内存中,时间较短
阻塞/非阻塞和同步/异步
IO 模型总是离不开阻塞/非阻塞、同步/异步这些概念。
阻塞/非阻塞:阻塞和非阻塞是对调用方线程状态的描述,如果一次 IO 过程中,调用方线程需要阻塞线程等待数据的到达,那么说这次 IO 是阻塞式 IO。
同步/异步:同步和异步是对调用方获取数据方式的描述,如果调用方主动去查询并复制数据,那么称 IO 是同步的。如果是操作系统在数据准备完成(复制到用户缓存区)之后告诉调用方有数据准备好了,那么称 IO 是异步的。
IO 模型分类
发起系统调用的是运行在系统上的某个应用的进程、对象是磁盘上的数据、获取数据需要通过 I/O、整个过程就是应用等待获取磁盘数据。针对整个过程中应用进程的状态不同,可以分为:同步阻塞型,同步非阻塞型,同步复用型,信号驱动型,异步。
同步阻塞型 IO
类比:老李去火车站买票,排队三天买到一张退票。耗费:在车站吃喝拉撒睡 3 天,其他事一件没干。
同步阻塞 IO 模型是最简单的 IO 模型,用户线程在内核进行 IO 操作时被阻塞,等到数据读取完成之后在继续处理后续逻辑,其步骤如下所示(以 read()接口为例):
用户程序需要读取数据,调用 read 方法,把读取数据的指令交给 CPU 执行。
CPU 发出指令给 DMA,告诉 DMA 需要读取磁盘的哪些数据,然后返回,线程进入阻塞状态
DMA 向磁盘控制器发出 IO 请求,告诉磁盘控制器需要读取哪些数据,然后返回;
磁盘控制器收到 IO 请求之后,把数据读取到磁盘缓存区,当磁盘缓存读取完成之后,中断 DMA;
DMA 收到磁盘的中断信号,将磁盘缓存区的数据读取到 PageCache 缓存区,然后中断 CPU;
CPU 响应 DMA 中断信号,知道数据读取完成,然后将 PageCache 缓存区中的数据读取到用户缓存中;
用户程序从内存中读取到数据,可以继续执行后续逻辑。
同步阻塞 IO 的优缺点
优点:程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。缺点:每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。
同步非阻塞型 IO
类比:老李去火车站买票,隔 12 小时去火车站问有没有退票,三天后买到一张票。耗费:往返车站 6 次,路上 6 小时,其他时间做了好多事。
非阻塞 IO 就是当调用方发起读取数据申请时,如果内核数据没有准备好会即刻告诉调用方,不需要调用方线程阻塞等待。
以 recvfrom 方法为例,调用方调用 recvfrom 读取数据时,如果该缓冲区没有数据的话,就会直接返回一个 EWOULDBLOCK 错误,不会让应用一直等待中。在没有数据的时候会即刻返回错误标识,那也意味着如果应用要读取数据就需要不断的调用 recvfrom 请求,直到读取到它数据要的数据为止。其读取步骤如下所示:
调用方调用 recvfrom 方法尝试获取数据;
如果 recvfrom 方法返回 EWOULDBLOCK 错误,执行步骤 1;如果 revifrom 方法发现缓存区有数据,那么执行步骤 3;
CPU 将 PageCache 缓存区中的数据读取到用户缓存中;
用户程序从内存中读取到数据,可以继续执行后续逻辑。
种方式在编程中对 socket 设置 O_NONBLOCK 即可。但此方式仅仅针对网络 IO 有效,对磁盘 IO 并没有作用。因为本地文件 IO 默认是阻塞,我们所说的网络 IO 的阻塞是因为网路 IO 有无限阻塞的可能,而本地文件除非是被锁住,否则是不可能无限阻塞的,因此只有锁这种情况下,O_NONBLOCK 才会有作用。而且,磁盘 IO 时要么数据在内核缓冲区中直接可以返回,要么需要调用物理设备去读取,这时候进程的其他工作都需要等待。因此,后续的 IO 复用和信号驱动 IO 对文件 IO 也是没有意义的。
IO 复用模型
IO 复用,也叫多路 IO 就绪通知。这是一种进程预先告知内核的能力,让内核发现进程指定的一个或多个 IO 条件就绪了,就通知进程。使得一个进程能在一连串的事件上等待。IO 复用的实现方式目前主要有 select、poll 和 epoll。
select/poll
类比:老李去火车站买票,委托黄牛,然后每隔 6 小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票。耗费:往返车站 2 次,路上 2 小时,黄牛手续费 100 元,打电话 17 次
select 和 poll 的原理基本相同:
注册待侦听的 fd(这里的 fd 创建时最好使用非阻塞)
每次调用都去检查这些 fd 的状态,当有一个或者多个 fd 就绪的时候返回
返回结果中包括已就绪和未就绪的 fd
相比 select,poll 解决了单个进程能够打开的文件描述符数量有限制这个问题:select 受限于 FD_SIZE 的限制,如果修改则需要修改这个宏重新编译内核;而 poll 通过一个 pollfd 数组向内核传递需要关注的事件,避开了文件描述符数量限制。
此外,select 和 poll 共同具有的一个很大的缺点就是包含大量 fd 的数组被整体复制于用户态和内核态地址空间之间,开销会随着 fd 数量增多而线性增大。
epoll
老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。耗费:往返车站 2 次,路上 2 小时,黄牛手续费 100 元,无需打电话
epoll 是 poll 的一种改进:
基于事件驱动的方式,避免了每次都要把所有 fd 都扫描一遍。
epoll_wait 只返回就绪的 fd。
epoll 使用 nmap 内存映射技术避免了内存复制的开销。
epoll 的 fd 数量上限是操作系统的最大文件句柄数目,这个数目一般和内存有关,通常远大于 1024。
目前,epoll 是 Linux2.6 下最高效的 IO 复用方式,也是 Nginx、Node 的 IO 实现方式。而在 freeBSD 下,kqueue 是另一种类似于 epoll 的 IO 复用方式。
此外,对于 IO 复用还有一个水平触发和边缘触发的概念:
水平触发:当就绪的 fd 未被用户进程处理后,下一次查询依旧会返回,这是 select 和 poll 的触发方式。
边缘触发:无论就绪的 fd 是否被处理,下一次不再返回。理论上性能更高,但是实现相当复杂,并且任何意外的丢失事件都会造成请求处理错误。epoll 默认使用水平触发,通过相应选项可以使用边缘触发。
由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的 CPU 时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是进程的用户态,而是有人帮忙就好了。那么这就是所谓的 “IO 多路复用”。UNIX/Linux 下的 select、poll、epoll 就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的)。
IO 多路复用有两个特别的系统调用 select、poll、epoll 函数。select 调用是内核级别的,select 轮询相对非阻塞的轮询的区别在于---前者可以等待多个 socket,能实现同时对多个 IO 端口进行监听,当其中任何一个 socket 的数据准好了,就能返回进行可读,然后进程再进行 recvform 系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。select 或 poll 调用之后,会阻塞进程,与 blocking IO 阻塞不同在于,此时的 select 不是等到 socket 数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。如何知道有一部分数据到达了呢?监视的事情交给了内核,内核负责数据到达的处理。也可以理解为"非阻塞"吧。
I/O 复用模型会用到 select、poll、epoll 函数,这几个函数也会使进程阻塞,但是和阻塞 I/O 所不同的的,这两个函数可以同时阻塞多个 I/O 操作。而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时(注意不是全部数据可读或可写),才真正调用 I/O 操作函数。
对于多路复用,也就是轮询多个 socket。多路复用既然可以处理多个 IO,也就带来了新的问题,多个 IO 之间的顺序变得不确定了,当然也可以针对不同的编号。具体流程,如下图所示:
信号驱动模型
类比:老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李,然后老李去火车站交钱领票。耗费:往返车站 2 次,路上 2 小时,免黄牛费 100 元,无需打电话
信号驱动 IO 模型,应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对 SIGIO 信号进行捕捉,并且调用我的信号处理函数来获取数据报。流程如下:
开启套接字信号驱动 IO 功能;
系统调用 sigaction 执行信号处理函数(非阻塞,立刻返回),告诉系统数据就绪式调用哪个函数;
数据就绪,生成 sigio 信号,通过信号回调通知应用来读取数据。
此种 io 方式存在的一个很大的问题:Linux 中信号队列是有限制的,如果超过这个数字问题就无法读取数据。
Linux 信号的处理:如果这个进程正在用户态忙着做别的事(例如在计算两个矩阵的乘积),那就强行打断之,调用事先注册的信号处理函数,这个函数可以决定何时以及如何处理这个异步任务。由于信号处理函数是突然闯进来的,因此跟中断处理程序一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进队列,然后返回该进程原来在做的事。如果这个进程正在内核态忙着做别的事,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到内核态的事情忙完了,快要回到用户态的时候,再触发信号通知。如果这个进程现在被挂起了,例如无事可做 sleep 了,那就把这个进程唤醒,下次有 CPU 空闲的时候,就会调度到这个进程,触发信号通知。
异步 API 说来轻巧,做来难,这主要是对 API 的实现者而言的。Linux 的异步 IO(AIO)支持是 2.6.22 才引入的,还有很多系统调用不支持异步 IO。Linux 的异步 IO 最初是为数据库设计的,因此通过异步 IO 的读写操作不会被缓存或缓冲,这就无法利用操作系统的缓存与缓冲机制。
很多人把 Linux 的 O_NONBLOCK 认为是异步方式,但事实上这是前面讲的同步非阻塞方式。需要指出的是,虽然 Linux 上的 IO API 略显粗糙,但每种编程框架都有封装好的异步 IO 实现。操作系统少做事,把更多的自由留给用户,正是 UNIX 的设计哲学,也是 Linux 上编程框架百花齐放的一个原因。
从前面 IO 模型的分类中,我们可以看出 AIO 的动机:
同步阻塞模型需要在 IO 操作开始时阻塞应用程序。这意味着不可能同时重叠进行处理和 IO 操作。
同步非阻塞模型允许处理和 IO 操作重叠进行,但是这需要应用程序根据重现的规则来检查 IO 操作的状态。
这样就剩下异步非阻塞 IO 了,它允许处理和 IO 操作重叠进行,包括 IO 操作完成的通知。
异步 IO
类比:老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门。耗费:往返车站 1 次,路上 1 小时,免黄牛费 100 元,无需打电话
当应用程序调用 aio_read 时,内核一方面去取数据报内容返回,另一方面将程序控制权还给应用进程,应用进程继续处理其他事情,是一种非阻塞的状态。
当内核中有数据报就绪时,由内核将数据报拷贝到应用程序中,返回 aio_read 中定义好的函数处理程序。
很少有 Linux 系统支持,Windows 的 IOCP 就是该模型。可以看出,阻塞程度:阻塞 IO>非阻塞 IO>多路转接 IO>信号驱动 IO>异步 IO,效率是由低到高的。
来源:https://www.cnblogs.com/yuhushen/p/15239022.html
评论