深入分析I/O模型


这篇文章我为什么叫做深入分析I/O模型呢,并不是因为我分析的有多深入,而是因为为了研究清楚这个I/O模型,我确实花了很多时间,查了很多资料。我想大多数人对这一块儿也有疑问,而且网上那些文章看过一遍又一遍之后又会有新的问题,甚至有些文章讲的都是错的还被抄来抄去。我也不确定我写的能否给大家讲明白,姑且先记下来吧。以下都是基于我个人的理解,不代表权威观点,如有疑问可以讨论。

我们要讲I/O模型,我不像网上的一些文章,上来就讲 阻塞IO、非阻塞IO、信号驱动IO、多路复用IO、异步IO等的原理,也不讲 同步、异步、阻塞、非阻塞 的区别及联系, 更不想讲 多路复用IO中 select、poll、epoll 的三种实现,虽然后面都会讲到。这里我用我写文章惯用的手法,先从字面意思入手,逐渐逐渐引出一些新的概念来。

啥叫I/O

先上维基百科

I/O(英语:Input/Output),即输入/输出,通常指数据在存储器(内部和外部)或其他周边设备之间的输入和输出,

I/O 指的就是数据在设备上的读写。那这里的设备都有哪些呢?我们初步想一下,磁盘、内存、显卡、网卡、声卡、CPU 等等很多设备我们都可以进行数据的读写或者叫存取。这些设备都或多或少都有一块存储空间可以存取数据,也就是存取我们的0或者1组成的序列。

那研究如何在这些设备上进行读写,就是我们的I/O模型要讲的东西。I/O 模型就是研究I/O操作具体实现方案的理论。换句话说,I/O模型就是对设备读写的不同方式的实现,不同的实现就对应不同的IO模型。

设备不是想调,想调就能调

我们从什么设备进行读写就叫什么IO,例如 从磁盘读写数据我们就叫磁盘IO,磁盘上存的大部分都是以文件的形式,所以我们也叫文件IO.从内存读写数据我们就叫内存IO,从网卡设备读写数据我们就叫做网卡IO,说的更通俗一点也叫网络IO,也就是我们研究的那个Socket这个东东。

但是这些设备不是你想使用你就能使用的,对于计算机来说想 cpu啊、内存、磁盘、显卡啊这些计算机统统视其为资源,这些资源是计算机的宝贵财富,所以要好好保护起来才行,不是你想使用就能使用的。

要想使计算机运行起来,我们必须要安装一个超大型的软件,叫做操作系统才行,所以操作系统其实是一个软件,这个软件内部有一个核心模块,我们简称为内核(内部的核心代码嘛)。

一开始我想的很简单,无非就是直接往某个设备的地址里面写入0或1,或者把某个设备的存储空间里面的0或1搬移到另一个设备的存储空间,这不就实现了数据的读写么。但是我这个理解从宏观上看是没错的,但是深入到系统底层来看就行不通了。首先,前面说的 直接 往某个设备的地址里面写0或1 是实现不了的,因为操作系统不允许,或者说操作系统的内核不允许。像 磁盘、网卡、显卡、声卡、CPU 这些硬件设备,不是说你想调就能调用的,我们把这些都看做是操作系统的资源,这些资源之间通过总线连接,通过总线传输数据,数据从一个位置转移(拷贝)到另一个位置,总要有一定的通路才行,不然隔空是传输不了数据的,在计算机内部,通路就是导线,这些导线可能是铜的的也可能是金的,但这些线总起来我们就叫做总线。总线分为 数据总线(主管数据的),控制总线(主管命令的),地址总线(主管位置的),不在本篇的讨论范畴。

上面说的这些硬件资源不是你想控制就能控制的,所以说是受保护的。那是受谁保护的呢?谁有这么大的权力呢?这个家伙叫内核,内核是操作系统内的核心(简称内核,哈哈),而操作系统是一个软件,内核更是一个软件,操作系统可以理解为一个大大的进程,或者说第一个进程,0号进程或者1号进程,随便你怎么叫吧。内核怎么保护这些设备呢,如果你自己要控制某个硬件,比如使蜂鸣器响3下,比如关闭键盘背光灯,至少到目前按照我的理解,必须调用内核提供的接口才可以操作,就跟你调用一个http接口post数据一样,你得调用内核的接口,内核在调用具体的硬件的驱动去控制具体的硬件。那你调用内核的时候,cpu就开始执行内核的代码了,这其中我们就叫做陷落到了内核,或者叫内核切换,内核执行自己的接口的时候,肯定也是需要占用一定空间的,他调用cpu执行方法的时候也是需要保存一些中间数据的,所以内核也是需要一部分内存空间的。这部分内存空间,我们叫做内核空间,注意内核空间依然是受保护的,因为除了内核这部分内存地址谁也访问不了,这里的谁特指的是内核之外的用户,我们叫用户进程,用户进程用的空间我们叫用户空间。这里特意说一点我的理解,我认为,用户进程调用内核接口发起内核调用之后并没有发生进程的切换,还应该是在一个进程上下文当中,内核依然可以访问用户空间的数据,cpu并没有让出进程的控制权,也没有保存进程上下问,更没有进程上下文数据的切换。但发起系统调用有一点比较麻烦,就是内核会校验传入数据和操作的安全性,例如你发起一个系统调用,告诉内核给我把操作系统删除掉,汗,这其实是不允许的。然后每发起一次系统调用就进行一次数据校验等巴拉啦一大推校验工作(具体我也不知道是啥)。所以,用户进程其实都很讨厌发起系统调用,因为发起系统调用后,要么等,要么等,要么等,等他一坨没有必要的校验工作(当然这是用户进程的想法),还不如我直接控制来的爽。就好像你写了个函数,里面会校验入参是不是为空,但我调用的时候明明知道自己不会给函数传空,但是每次调用时候又不得不浪费一点判断参数是否为空的时间,一次两次还好,如果我调用10000次这个函数呢?

我们怎才能读设备里面的数据

这里我们以读数据为例子,这个设备我假设为磁盘,而我要读取的数据静静的躺在磁盘上的某个文件中。我要把这个文件的内容赋值给我定义个一个字符串变量。如果要让我自己设计一套实现方案,我可能想到的就是直接把磁盘上的数据通过cpu一点一点地搬移到我的用户空间的内存中,然后继续执行后面的代码。 这样何其简单啊。

但是可是可但是,从磁盘读取数据到内存特别的慢,而且如果一个文件要读取多次,则慢上加慢。所以操作系统并不是这么干的,一般遇到这种存取速度不匹配的情况,例如 cpu比内存快,内存比磁盘快的情况,我们一般是搞一个中间层缓存,类比如 redis 和mysql . mysql 很慢(相对于redis来说),我们就用redis来屏蔽这种差异。那操作系统怎么做的呢?

第一种方式叫 缓存IO,又叫Buffer IO. 可以理解为在内核空间中申请一部分空间,作为底层磁盘这种慢介质的缓存,有了缓存,我们先从缓存读取,如果有则很快返回,如果没有我们再去磁盘读取,注意这里和redis+mysql 不同,redis+mysql 是先读取到用户空间,在回写redis,因为是用户进程去读mysql而不是redis去读mysql的. 但是在操作系统层面,是内核发起磁盘读取而不是用户发起磁盘读取的,所以数据是先到内核空间,然后用户进程才来读取数据的。所以但凡是这这种形式,在用户进程和存储介质中内核明目张胆的加了一个缓存层的都可以叫做缓存IO,这个阶段你还拿内核没有办法,这是最开始操作系统读写文件方式,因而已经成为一种标准,故这种方式也叫标准IO. 所以说缓存IO,Buffer IO,标准IO 都是一个意思。我们要把数据从一个地方搬移到另一个地方,中间是需要经过cpu一点一点搬移的,你可以理解为,cpu一会执行一点指令,把数据拷贝到cpu的缓存,在把cpu缓存中的数据转移到指定的位置,就完成了一次文件的读写操作。

注意这里说的缓存IO或者Buffer IO 或者标准IO,并不是Linux 的IO模型。IO模型要从同步、异步、阻塞和非阻塞的角度去说,这中只能叫读取设备的方式,方式就是加了一个缓存层。那加了这个缓存层肯定有好有坏,好就好在它是一份缓存嘛,屏蔽了内存和磁盘操作的速度差异嘛。为什么加了一层缓存就屏蔽了这个速度差异呢?如果第一次查询没有命中缓存,你是不是还得去底层磁盘查询,底层磁盘还是很慢,所以第一次查询没有命中缓存这个速度差异是屏蔽不掉的,该慢还是慢。但是下一次查询呢,不就快了么。不过我既然已经读到我用户空间了,为什么还要费劲再去磁盘查询一下呢,这不是多此一举么。所以这个二次查询的优势其实也不算太优势,如果你在程序中把一个文件的内容读到了一个file中,你每次读取的时候拿难道都从磁盘在读取一遍么。那样太蠢了。那它肯定还有另外的显著的优势,就在于内核读取磁盘缓存的时候其实并不是只读你想要读取的那个空间的内容,而是把你想读的空间的内容附近的内容也都读出来,这样下次你读取该空间附近的空间的时候就可以直接命中缓存了,这样做是根据了一个叫做局部性原理的原理来设计的。所以才能显著提高访问速度,其底层就是把多次内核向磁盘读取数据的IO变成了一次磁盘IO。

但是我觉得这根本不成立,因为用户进程和内核的速度差异是屏蔽了,但是内核和磁盘读写还是有速度差异啊,还是很慢吧,所以我认为这个好处并不成立。我理解他的好处就和redis一样,你之后读取的时候会很快,但是这一点我也觉得不算是太大的有点,因为我既然都已经读取到用户空间了,我干嘛还要在单独读一遍内核空间中的缓存呢,我直接操作我用户空间内存不行么。

缓存 I/O 使用了操作系统内核缓冲区,在一定程度上分离了应用程序空间和实际的物理设备。 我也不觉得这种区分有什么好的。
缓存 I/O 可以减少读盘的次数,从而提高性能。这一点我还是同意的,下次在调用的时候确实不用再读取一遍磁盘了。

未完待续,明天再写…

鉴于buffer io ,还要搞一个内核缓冲区,数据需要从磁盘先拷贝到内核缓冲区,然后在拷贝到用户缓冲区,这其中就要经历两次拷贝,而且两次拷贝中存储的数据都是一样的。

DMA (直接存储器访问)

百度百科

DMA(Direct Memory Access,直接存储器访问) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。否则,CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU 对于其他的工作来说就无法使用。

可以想象MDA方式中有另一个cpu,专门负责数据的拷贝转移,这样cpu就可以空闲出来了嘛

Linux IO 与 Java IO

Linux IO 其实指的是更为底层的IO, 而Java IO更多的是指应用层的IO,所以说Java 的IO 模型的实现其实是依赖底层的Linux IO的实现的。
Linux IO 有5种,分别叫 阻塞IO、非阻塞IO、多路复用IO、信号驱动IO、异步IO. 但java只支持其中的三种,Java IO的分类分别叫 BIO、NIO、AIO
其中 BIO 对应 Linux IO 中的阻塞IO实现; NIO对应的其实是 Linux IO中的多路复用IO;AIO 则对应LINUX IO中的异步IO,这一点一定要记清楚,否则后面的概念容易混淆。

Linux IO 模型

上面说到 Linux IO 有5种,分别叫 阻塞IO、非阻塞IO、多路复用IO、信号驱动IO、异步IO。我们思考一下几个问题
服务器如何读取网络上传递过来的一条消息的->首先两台服务器之间进行3次握手建立连接,你可以想象,你的计算机上运行着一个叫tcp的软件,这个软件呢可以打开某个端口,所谓打开端口,就是说可以通过这个端口接收到网络上定向发送给该端口的0101的数据。当网络上的数据通过该端口发送过来消息的时候,其实就是当第一个高电平传输过来的时候,这个tcp软件就会被cpu叫醒,cpu为什么去叫醒tcp软件是因为高电平传过来的时候给了操作系统一个信号中断,然后这个tcp软件发现有数据到达,于是在计算机上申请一块儿内存用于记录通过该端口传过来的数据,这块儿内存其实就是一个socket,他像个插座一样连接你的应用程序和网络上的数据,同时为了表示一个socket我们像上面提到的文件系统申请了一个文件描述符,文件描述符表面上像一个数字,其实背地里它代表一种数据结构的。而我们的应用程序要读取到网路中传输过来的数据,其实就是从这个socket中读取数据。但是我们在向socket读取数据读不到的时候采取什么样的策略决定了我们属于什么模型。

阻塞IO

我们从头捋一遍我们应用程序从启动到接收数据的过程,首先我们应用程序先调用系统方法去监听某个接口,所谓监听其实就是告诉操作系统当有数据从我监听的端口经过的时候,你通知我这个应用程序而不是其他的应用程序,这就是为什么我们一个应用占用一个端口的时候其他应用是无法再继续监听该端口的,会报出端口冲突的异常。当有连接进来的时候,操作系统会响应中断,然后进而去通知我们的server,说有链接来了,你可以继续执行了。于是我们的应用server就会开始准备去读数据,这时候你会发现从流中读数据的时候,又发生了一次阻塞,我们的应用程序就阻塞在哪里等着数据的到来,其实也是跟操作系统说,我现在要读数据了,不过系统说好的那你等着吧,我准备好了告诉你,这时候客户端的数据一点一点到达服务器,等最后一个数据包到达后,我们的操作系统就把数据返回给我们的server,然后server就可以继续执行了。 这种模式就叫阻塞IO, 在阻塞IO中我们的server阻塞了两次,一次在listen的时候(accept 的时候方法内部最后会调用litsent)等着链接的到来,一次在 read数据的时候,从流中读数据的时候会发起系统调用也会阻塞一次。

阻塞IO最符合我们人类的思维习惯。这里的阻塞IO全称应该叫做同步阻塞IO。

非阻塞IO

非阻塞IO ,这里指的非阻塞是第二次我们从InputSteam里面读数据的时候,不阻塞。也就是我们发起系统调用用InputStream中读取数据的时候,操作系统说好的,我知道了,但是现在没办法给你数据,你在后面慢慢准备,你该干啥干啥去,至于什么时候准备好,你自己一会来看吧。于是我么应用程序每隔一会儿就得询问一下操作系统,数据准备好了没有,如果好了,那就直接取到了,如果没有准备好,我们就再做会儿其他的事情,过一会儿再来看看,但是这个间隔隔多久呢,就要你自己负责了,隔得久了取数据不及时,隔得短了,数据还没准备好,总之很纠结,要是为了这个场景在开发出一个动态获取数据的时间间隔的算法又有点得不偿失了。
这就是非阻塞IO,非阻塞IO,就是告诉操作系统你去给我准备数据吧,然后自己可以干一点其他的事情,利用率稍微有点提高,但是不够及时。全称应该叫同步非阻塞IO。

信号驱动IO

信号驱动IO,其实应该是非阻塞IO的变动版本,就是获取数据的时候,同样是非阻塞,但是但是不用时不时的取轮询获取是否准备好了,因为一旦数据准备好了,就会通过信号的方式通知应用线程,应用线程下次切换回来的时候就会发现自己信号列表有个信号,先响应这个信号,发现是数据准备好了,于是就去取数据,把轮询交给了操作系统的系统调度的时候做。这就是信号驱动的读取数据的过程。信号驱动也是同步的,全称应该叫同步信号驱动IO

多路复用IO

上面无论是阻塞IO还是非阻塞IO,都是一个线程既负责管理链接到来的事情,又要负责数据读取的事情,一条道走到黑,为了多连接,那就得多个线程。但是我们知道线程可不是越多越好。于是人们先要我们能不能让一个线程来处理多个链接呢?也就是能不能复用这线程,这里的多路复用指的就是多路数据读取线程复用一个链接管理线程。因为好多时候,我们的链接后并不一定就发送数据,用阻塞IO还是非阻塞IO,都得等。要是能在数据准备好的时候或者链接准备好的时候,在告诉我们好了,那就好了,这样处理问题就连起来了,你告诉操作系统,有链接的时候你叫我,我先去干别的事情,有数据的时候教我,我先去干别的事情,我很忙的。于是就变成了,这样一种方式,我们有一个线程,每来一个链接我们往我的一个数组里面放,然后我会在数据准备好的时候,扫描一下所有的数组,把所有的准备好的链接或者数据返给应用,应有就可以继续处理了,这样就用一个线程管理了多个线程,其实就是有链接的时候先链接,然后记录下来,在后面某个时刻触发一下一次性吧准备好的数据返回,规避了应用反复发起系统调用的情况。但是多路复用还是会有阻塞的,会阻塞在selector的select方法。只不过这个select可以返回多个还准备好的链接,对于没有准备好的,是不会返回的。

跟传统的阻塞IO不同,IO复用可以阻塞在多个socket文件描述符上。当其中任何一个socket有数据可读的时候(或者超时),IO复用的函数(select, poll,epoll)才会返回。然后进程可以逐一处理可读的socket文件描述符。

多路复用也是同步的,所以全称应该叫做 同步多路复用IO。多路复用

异步IO

异步IO才是真正的异步,因为发起系统调用后,告诉操作系统,你给我准备数据,准备好之后,你调用的某某方法处理数据。自己完全不管了。这就是真正的异步IO,全称应该叫异步非阻塞IO。 那异步阻塞IO呢?没有人会有异步阻塞IO这种方式的,那不是傻么。明明可以去干点其他时候,愣子在那儿等着。数据也不需要你自己处理,你等着干啥,还不如直接退出呢。

多路复用的 select、poll、epoll 实现

select 对文件描述符的大小有限制,poll打破了这种限制的上线,但是文件描述符需要来回在用户空间和内核空间拷贝,epoll更是没有上限,但是比poll多的功能就是把感兴趣的事情给你分好了类,而且减少了拷贝。

Java IO 模型 与Linux IO 模型的对应关系

Java 的IO 模型肯定不是自己独创的,他一定依赖操作系统提供的能力!
JavaIO 模型只有三种
BIO 阻塞IO,对应linux的阻塞IO实现
NIO 对应Linux 的多路复用IO
AIO 对应Linux 的异步IO

同步、异步、阻塞、非阻塞

上面说了 linux IO 里面除了 异步IO 是真正的异步外,其他都是同步,因为请求完成后,数据是有应用线程自己取的,这就是同步的概念,只要数据不是操作系统自己送过来,那就是同步的,只要是操作系统自己主动的送过来,而自己不需要关心,只关心通知操作系统说我要数据,那就是真正的异步了。

阻塞和非阻塞就看自己跟操作系统要数据之后,是不是在哪儿等,无论是因为操作系统没准好无法返回导致的我们等,还是因为选择的在哪儿等,只要有等的动作那就是阻塞的,否则就是非阻塞,无论数据好没有,被调用这立马返回说我知道了,那即是非阻塞的。

都是定义不必死记。

总结


评论