操作系统的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(file, tmp_buf, len);

  1. 用户程序需要读取数据,调用read方法,把读取数据的指令交给CPU执行。
  2. CPU发出指令给DMA,告诉DMA需要读取磁盘的哪些数据,然后返回,线程进入阻塞状态
  3. DMA向磁盘控制器发出IO请求,告诉磁盘控制器需要读取哪些数据,然后返回;
  4. 磁盘控制器收到IO请求之后,把数据读取到磁盘缓存区,当磁盘缓存读取完成之后,中断DMA;
  5. DMA收到磁盘的中断信号,将磁盘缓存区的数据读取到PageCache缓存区,然后中断CPU;
  6. CPU响应DMA中断信号,知道数据读取完成,然后将PageCache缓存区中的数据读取到用户缓存中;
  7. 用户程序从内存中读取到数据,可以继续执行后续逻辑。
【操作系统的IO模型】
同步阻塞IO的优缺点
优点:程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用CPU资源。
缺点:每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。
同步非阻塞型IO
类比:老李去火车站买票,隔12小时去火车站问有没有退票,三天后买到一张票。耗费:往返车站6次,路上6小时,其他时间做了好多事。
非阻塞IO就是当调用方发起读取数据申请时,如果内核数据没有准备好会即刻告诉调用方,不需要调用方线程阻塞等待。
以recvfrom方法为例,调用方调用recvfrom读取数据时,如果该缓冲区没有数据的话,就会直接返回一个EWOULDBLOCK错误,不会让应用一直等待中。在没有数据的时候会即刻返回错误标识,那也意味着如果应用要读取数据就需要不断的调用recvfrom请求,直到读取到它数据要的数据为止。其读取步骤如下所示:
  1. 调用方调用recvfrom方法尝试获取数据;
  2. 如果recvfrom方法返回EWOULDBLOCK错误,执行步骤1;如果revifrom方法发现缓存区有数据,那么执行步骤3;
  3. CPU将PageCache缓存区中的数据读取到用户缓存中;
  4. 用户程序从内存中读取到数据,可以继续执行后续逻辑。

种方式在编程中对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的原理基本相同:
  1. 注册待侦听的fd(这里的fd创建时最好使用非阻塞)
  2. 每次调用都去检查这些fd的状态,当有一个或者多个fd就绪的时候返回
  3. 返回结果中包括已就绪和未就绪的fd
相比select,poll解决了单个进程能够打开的文件描述符数量有限制这个问题:select受限于FD_SIZE的限制,如果修改则需要修改这个宏重新编译内核;而poll通过一个pollfd数组向内核传递需要关注的事件,避开了文件描述符数量限制。
此外,select和poll共同具有的一个很大的缺点就是包含大量fd的数组被整体复制于用户态和内核态地址空间之间,开销会随着fd数量增多而线性增大。
epoll
老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。耗费:往返车站2次,路上2小时,黄牛手续费100元,无需打电话
epoll是poll的一种改进:
  1. 基于事件驱动的方式,避免了每次都要把所有fd都扫描一遍。
  2. epoll_wait只返回就绪的fd。
  3. epoll使用nmap内存映射技术避免了内存复制的开销。
  4. 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信号进行捕捉,并且调用我的信号处理函数来获取数据报。流程如下:
  1. 开启套接字信号驱动IO功能;
  2. 系统调用sigaction执行信号处理函数(非阻塞,立刻返回),告诉系统数据就绪式调用哪个函数;
  3. 数据就绪,生成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,效率是由低到高的。
欢迎关注御狐神的微信公众号
操作系统的IO模型
文章图片

参考文档 IO和零拷贝
异步IO、epoll、零拷贝
IO概念和五种IO模型
本文由博客一文多发平台 OpenWrite 发布!

    推荐阅读