目录

一. 前言

二. 用户空间、内核空间、计算机硬件三者简述

三. 阻塞IO

四. 非阻塞IO

五.  IO多路复用

5.1 Select 函数解析

5.2 poll 函数解析

5.3 epoll 函数解析

5.4 总结概括

六. IO 多路复用 Web 服务流程简述


一. 前言

在《UNIX网络编程》一书中,总结了5种IO模型,分别是 阻塞IO(Blocking IO)、非阻塞IO(Nonblocking IO)、IO多路复用(IO Multiplexing)、信号驱动IO(Signal Driven IO)、异步IO(Asynchronous IO);

以前我们见过或听过的三种网络IO流模型——BIO,NIO,AIO就是上述5种情况的总结概括,还不能与之混为一谈,下面我们就挨个了解这五种IO模型的实现思路。

在此之前,希望各位已经对计算机"用户空间","内核空间"有了简单的了解,不太懂的小伙伴也不必着急,下面我们对它们进行简单总结描述,方便我们进行理解IO,只要懂得这几种IO的阻塞原理即可,了解其原理就知道该如何解决优化它们。

感兴趣的同学,这里也推荐我的上一篇文章 网络模型——用户空间,内存空间详解 进行额外学习了解;

二. 用户空间、内核空间、计算机硬件三者简述

如下图,就是对用户空间、内核空间、计算机硬件三者关系的简单描述,我们需要知道,当操作软件进行数据读写操作时,基本走的都是这一套逻辑。

我们只要先了解了这一层关系,下面再去学习各种IO模型,就会很好理解,因为基本都是围绕这三层数据读取产生的问题来进行解决的,解决思路不同就产生了不同的IO模型。

计算机为了数据安全起见,是不允许用户直接对硬件磁盘、网卡数据进行读写操作的,而是必须走一层内核,几乎所有的用户数据操作,都会先在内核层准备就绪,再由内核调用各种封装的指令调用计算机硬件完成数据读写操作。

三. 阻塞IO

所以对于阻塞IO而言:假设我们操作软件(用户应用层)读取磁盘数据,先到内核缓冲区(内核层)寻找是否有数据;

若有,则直接将数据拷贝读取回用户缓冲区,即完成一次数据读取操作;

若内核缓冲区无数据如上图所示流程,用户进程会阻塞等待,内核会调用指令进一步向硬件磁盘去寻址读取数据(这里的寻址,通俗一点来讲,就是硬件磁盘中的各种文件数据的索引地址已经提前存储在内核缓冲区,只需要通过存储的地址向磁盘读取数据),读取到数据之后,将数据读取至内核缓冲区,然后就和上面一样,内核缓冲区再将读取到的数据拷贝到用户缓冲区供用户继续进行数据处理。

所以我们不难看出,对于阻塞IO来说,主要阻塞有两点

1:用户等待数据,内核缓冲区无数据就需要等待磁盘寻址将数据返回;

2:数据拷贝:内核缓冲区的数据需要拷贝到用户缓冲区才能让用户进行操作;

四. 非阻塞IO

如下图所示,对于非阻塞IO模型,他相比于阻塞IO,核心改动点就是在第一步等待数据操作上的变化,阻塞IO是直接阻塞,一直等到数据加载完毕;

而非阻塞IO,第一步等到数据操作时,他并不会原地一直等待,而是不会断的循环调用方法询问数据是否已经加载完毕,若为加载完毕,则循环不停的调用方法询问,直到数据加载完毕。

所以,就阻塞IO与非阻塞IO二者对比来说,其实本质上并没有太大变化,二者在性能上也没有太大差异。而且,不停的忙等轮询操作,还会导致CPU空转,CPU使用率暴增。

但是!!!非阻塞IO在等待数据时不停的轮询操作,会在接下来讲解其它IO模型时用到,所以也可以说,非阻塞IO为其它更好的IO模型的出现做了铺垫,其轮询机制这种思想,在其它更好的IO模型有所使用。

五.  IO多路复用

通过上述阻塞IO和非阻塞IO的了解,其实我们大致也已经清楚了二者在提升效率这件事上并没有本质的区别,基本是一样的,差别在于读取数据且内核缓冲区无数据时的处理方案,阻塞IO直接等待,非阻塞IO则轮询;二者都没有充分去发挥CPU的作用。

在讲解IO多路复用之前,需要先给小伙伴们提及一个知识点,在Linux 操作系统中,有"文件描述符(File Descriptor)"这一概念,简称FD,是一个从0开始递增的无符号整数,用来关联 Linux 中的一个文件。在 Linux 中,一切皆文件,例如常规文件、视频、硬件设备等、当然也包含网络套接字(Socket)。而IO多路复用,其核心思想就是利用单个线程来同时监听多个FD,并在某个FD可读,可写时得到通知,从而避免无效等待,充分利用CPU资源。

但是,在 Linux 操作系统中,监听FD的方式,以及通知数据已经准备就绪的方式有的有多种方法来进行实现,常见的便是下面要提到的 Select,poll,eopll 函数。

5.1 Select 函数解析

Select 函数是 Linux 操作系统中最早实现IO多路复用的方案;如下图所示,是 Linux 操作系统中 Select 函数的部分源码,对应注解已添加

其中,fd_set 是我们自定义的一个元素集合,在 Select 函数中,又细分为 读、写、异常三个事件集合,这个集合的长度经过非固定计算是32,是底层源码就写死的,但是并不是代表这个集合只存储32个FD文件,最上面定义的 "__fd_mask"是一个 long int 类型,在C语言中,是4个字节,每个字节由八个bit(比特位)组成,所以就是32个bit(比特位);

也就是说,fd_set 集合长度为32,可以存储32个元素,每个元素都可以存储4个字节(32个bit),所以fd_set 一共存储 32 * 32 = 1024 个bit;

重点:Select 函数在存储就绪FD文件时,是按照 bit 位进行保存的,而不是存储每个文件的实际FD编号;

因为1024比较长,所以我们以8个bit位举个简单的例子,现在初始化一个 fd_set 为 00000000,八个bit位;

现在要监听的文件FD 分别为 1,2,5,从右到左赋值,倒数第一个bit标识FD = 1文件; 倒数第二个bit标识FD = 2文件;倒数第五个bit标识FD = 5文件,可以得到一个新的 fd_set = 00010011

如下图所示,执行 Select 函数,并将创建的读任务集合 fd_set 拷贝(用户空间到内核空间第一次拷贝)到内核空间,到了内核空间,遍历(内核空间第一次遍历)任务集合,判断当前任务是否已就绪;

若就绪,则 bit 位上的1值不变,若未就绪,则将 bit 位上的 1 改为 0 代表未就绪,然后,再次(内核空间到用户空间第二此拷贝)将内核空间处理后的读任务数组拷贝回用户空间;

在之后,用户空间第二次遍历内核空间返回的已经就绪的 fd_set ,找到就绪的 FD,读取其中数据;

这只是其中一次循环,在最外层,会再嵌套一层循环,不停轮询执行 Select 函数;

综上所述,就是 Select 函数的基本运行逻辑,其中,我们也不难发现其中的问题

(1)需要进行两次 fd_set 的拷贝,第一次是用户空间到内核空间,然后交给内核空间遍历;第二次是内核空间到用户空间,交给用户空间遍历获取就绪的FD,可以说两次拷贝还是比较耗时耗费性能的;

(2)在用户空间第二次遍历时,虽然知道 fd_set 中含有就绪的FD,却不知道是那些已经就绪,还需要再次进行遍历获取,比较耗时;

(3)由于源代码的限制,fd_set 集合最大数量不能超过1024,或许是处于历史的局限性,现在互联网企业规模庞大,用户数量增多导致出现了很多高并发场景,1024 已然不能满足使用需求;

5.2 poll 函数解析

因为 Select 函数设计的最早,所以存在很多缺点,为此又提出了 poll 函数在 Select 函数的基础上进行改进,但是性能提升并不明显;poll 函数核心源码如下所示,实际上,poll 与 Select 在设计上几乎一样,只解决了Select 函数存在 fd_set 上限为1024这个问题,对于 Select 函数中的两次拷贝和两次遍历,都没有解决并沿用了下来;

IO 流程如下:

第一步:创建 pollfd 数组,向其中添加关注的 fd 信息,数组大小自定义;

第二步:调用 poll 函数,将 pollfd 数组拷贝到内核空间,转链表存储,无上限;

第三步:内核遍历 fd,判断是否就绪;

第四步:数组就绪或超过后,拷贝 pollfd 数组到用户空间,返回就绪 fd 数量 n;

第五步:用户进程判断 n 是否大于0;

第六步:大于0则遍历 pollfd 数组,找到就绪的 fd;

与 Select 相比:

Select 模式中 fd_set 最大值为1024,而 pollfd 在内核中采用链表,理论无上限;

虽然理论上无上限,但是如果监听的 fd 文件数量过多,每次遍历耗时也会更久,性能反而可能会下降;

所以总的来说,Select 和 poll 都没有从根本上去很好的解决用户空间和内核空间在处理数据时两次遍历和两次拷贝这些耗时的操作,下面我们就一起来了解他们的升级版,也是面试热点问题,epoll 函数!!!

5.3 epoll 函数解析

epoll 函数模式,对 Select 和 poll 进行了加大的改进,如下图所示。它提供了三个函数;

底层采用了 红黑树 + 链表 来实现;

第一步:会在内核空间创建一个 eventpoll 结构体,结构体内是一个红黑树和链表。红黑树记录监听的FD文件,而链表则用来存储就绪的FD文件;

第二步:每当接收到一个新的读/写请求操作,就执行 epoll_ctrl 将对应的FD添加到红黑树中,并添加 ep_poll_callback 回调函数,红黑树中存储所有待完成的FD任务,当任务就绪后,执行回调函数,将就绪的FD从红黑树添加到就绪链表中;

第三步:执行 epoll_wait 函数等待FD就绪,内核空间会检查存储就绪FD的链表是否存在数据,若存在数据,则只需要将就绪的数据拷贝到数组返回给用户空间,如果无就绪,则返回0;

此外,epoll 的IO多路复用机制,当FD就绪后,事件通知模式有两种

模式一:LevelTriggered (简称LT),当FD有数据可读时,会重复多次通知,直到数据处理完成。是 epoll 的默认模式;这种方法最为简单直接,数据就绪后,内核空间就绪的FD并不会被删除,而是仍然存留。这种模式只要有数据就通知进程,但是如果为多线程状态,则所有线程都会收到通知,出现惊群现象。

模式二:EdgeTriggered (简称ET),当FD有数据可读时,只会通知一次,不管数据是否处理完成;这种方式,当就绪FD拷贝回用户空间后,内核空间的FD就会被清除,由于没有数据,就不会再唤醒其他线程,不会出现LT模式中的惊群现象,如果再搭配非阻塞IO,则可以完整不重复的读取FD数据;但这种方式实现起来较为复杂,没有LT模式简单直接,但性能效率会比LT模式略高。若不在意则采用默认LT模式最佳;

5.4 总结概括

Select 函数模式存在的问题

  1. 能监听的FD不能超过1024;
  2. 每次 Select 都需要把所有监听的FD都拷贝到内核空间;
  3. 每次都要遍历所有FD来判断有谁就绪;

poll 函数模式的问题

只解决了 Select 函数1024上限的问题,其余均为解决,并且虽然使用链表理论上没有FD数量限制,但如果链表过长反而可能会影响遍历效率;

epoll 函数模式

  1. 基于红黑树存储FD任务而非链表或数组,红黑树从查询复杂度O(logn)天然优于数组链表的复杂度O(n),并且红黑树存储也无上限,增、删、改、查效率都比较均衡且优秀,性能不会随着监听FD数量的增多而降低;
  2. 每个FD仅仅只需要执行一次 epoll_ctl 函数添加到红黑树,以后每次 epoll_wait 函数不需要在传递任何参数,也不需要进行任何拷贝,只需要判断就绪链表是否含有数据即可;
  3. 内核向用户空间拷贝FD的时候,拷贝的都是已经就绪的FD,用户进行无需再次进行遍历确认有谁就绪,因为全部都是就绪的FD;

六. IO 多路复用 Web 服务流程简述

web 服务端流程如下图所示

a:服务端先创建实例,会在对应内核空间创建监听FD的红黑树和记录就绪FD的链表

b:用户空间创建 ServerSocket 得到FD,执行 epoll_ctl 监听FD,如果接受到新的客户端请求,得到对应 socket 的FD,放入红黑树进行监听;

c:用户空间继续执行 epoll_wait 等待FD就绪,若没有就绪,则继续循环执行 epoll_wait,若有就绪FD,则判断以下事件类型

  • 事件类型一:epollin 方法中如果 ServerSocket 的FD可读,则说明来了新的客户端连接,accept() 接收客户端 socket ,得到FD注册到红黑树;
  • 事件类型二:epollin 方法中如果 ServerSocket 的FD不可读,则说明是普通客户端 socket ,此时去读取客户端 socket 的数据,处理业务数据,再返回数据写出响应;
  • 事件类型三:执行过程中出错,发生异常事件,epollerr 方法写出响应错误返回;

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐