1。1什么是IO 我们都知道在UNIX世界里一切皆文件,而文件是什么呢?文件就是一串二进制流而已,其实不管是Socket,还是FIFO(FirstInputFirstOutput,先进先出队列))、管道、终端。对计算机来说,一切都是文件,一切都是流。在信息交换的过程中,计算机都是对这些流进行数据的收发操作,简称为IO操作(InputandOutput),包括往流中读出数据、系统调用Read、写入数据、系统调用Write。不过计算机里有那么多流,怎么知道要操作哪个流呢?实际上是由操作系统内核创建文件描述符(FileDescriptor,FD)来标识的,一个FD就是一个非负整数,所以对这个整数的操作就是对这个文件(流)的操作。我们创建一个Socket,通过系统调用会返回一个FD,那么剩下的对Socket的操作就会转化为对这个描述符的操作,这又是一种分层和抽象的思想。1。2IO交互流程 通常用户进程中的一次完整IO交互流程分为两阶段,首先是经过内核空间,也就是由操作系统处理;紧接着就是到用户空间,也就是交由应用程序。具体交互流程如下图所示。 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中,Linux使用两级保护机制:0级供内核(Kernel)使用,3级供用户程序使用。每个进程都有各自的私有用户空间(03G),这个空间对系统中的其他进程是不可见的。最高的1G字节虚拟内核空间则为所有进程及内核共享。 操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能简单地使用指针传递数据。因为Linux使用的虚拟内存机制,必须通过系统调用请求Kernel来协助完成IO操作,内核会为每个IO设备维护一个缓冲区,用户空间的数据可能被换出,所以当内核空间使用用户空间的指针时,对应的数据可能不在内存中。 对于一个输入操作来说,进程IO系统调用后,内核会先看缓冲区中有没有相应的缓存数据,如果没有再到设备中读取。因为设备IO一般速度较慢,需要等待,内核缓冲区有数据则直接复制到进程空间。所以,一个网络输入操作通常包括两个不同阶段。 (1)等待网络数据到达网卡,然后将数据读取到内核缓冲区。 (2)从内核缓冲区复制数据,然后拷贝到用户空间。 IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。如下图所示是IO通信过程的调度示意。 2。2五种IO通信模型 在网络环境下,通俗地讲,将IO分为两步:第一步是等待;第二步是数据搬迁。 如果想要提高IO效率,需要将等待时间降低。因此发展出来五种IO模型,分别是:阻塞IO模型、非阻塞IO模型、多路复用IO模型、信号驱动IO模型、异步IO模型。其中,前四种被称为同步IO,下面对每一种IO模型进行详细分析。2。1阻塞IO模型 阻塞IO模型的通信过程示意如下图所示。 当用户进程调用了recvfrom这个系统调用,内核就开始了IO的第一个阶段:准备数据。对于网络IO来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞,当数据准备好时,它就会将数据从内核拷贝到用户内存,然后返回结果,用户进程才解除阻塞的状态,重新运行起来。几乎所有的开发者第一次接触到的网络编程都是从listen()、send()、recv()等接口开始的,这些接口都是阻塞型的。阻塞IO模型的特性总结如下表所示。 2。2。2非阻塞IO模型 非阻塞IO模型的通信过程示意如下图所示。 当用户进程发出read操作时,如果内核中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果,用户进程判断结果是一个error时,它就知道数据还没有准备好。于是它可以再次发送read操作,一旦内核中的数据准备好了,并且再次收到了用户进程的系统调用,那么它会马上将数据拷贝到用户内存,然后返回,非阻塞型接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。非阻塞IO模型的特性总结如下表所示。 非阻塞模式套接字与阻塞模式套接字相比,不容易使用。使用非阻塞模式套接字,需要编写更多的代码,但是,非阻塞模式套接字在控制建立多个连接、数据的收发量不均、时间不定时,具有明显优势。2。2。3多路复用IO模型 多路复用IO模型的通信过程示意如下图所示。 多个进程的IO可以注册到一个复用器(Selector)上,当用户进程调用该Selector,Selector会监听注册进来的所有IO,如果Selector监听的所有IO在内核缓冲区都没有可读数据,select调用进程会被阻塞,而当任一IO在内核缓冲区中有可读数据时,select调用就会返回,而后select调用进程可以自己或通知另外的进程(注册进程)再次发起读取IO,读取内核中准备好的数据,多个进程注册IO后,只有一个select调用进程被阻塞。 多路复用IO相对阻塞和非阻塞更难简单说明,所以额外解释一段,其实多路复用IO模型和阻塞IO模型并没有太大的不同,事实上,还更差一些,因为这里需要使用两个系统调用(select和recvfrom),而阻塞IO模型只有一次系统调用(recvfrom)。但是,用Selector的优势在于它可以同时处理多个连接,所以如果处理的连接数不是很多,使用selectepoll的WebServer不一定比使用多线程加阻塞IO的WebServer性能更好,可能延迟还更大,selectepoll的优势并不是对于单个连接能处理得更快,而是能处理更多的连接。多路复用IO模型的特性总结如下表所示。 2。2。4信号驱动IO模型 信号驱动IO模型的通信过程示意如下图所示。 信号驱动IO是指进程预先告知内核,向内核注册一个信号处理函数,然后用户进程返回不阻塞,当内核数据就绪时会发送一个信号给进程,用户进程便在信号处理函数中调用IO读取数据。从上图可以看出,实际上IO内核拷贝到用户进程的过程还是阻塞的,信号驱动IO并没有实现真正的异步,因为通知到进程之后,依然由进程来完成IO操作。这和后面的异步IO模型很容易混淆,需要理解IO交互并结合五种IO模型进行比较阅读。信号驱动IO模型的特性总结如下表所示。 2。2。5异步IO模型 异步IO模型的通信过程示意如下图所示。 用户进程发起aioread操作后,给内核传递与read相同的描述符、缓冲区指针、缓冲区大小三个参数及文件偏移,告诉内核当整个操作完成时,如何通知我们立刻就可以开始去做其他的事;而另一方面,从内核的角度,当它收到一个aioread之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个信号,告诉它aioread操作完成。 异步IO的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们,这种模型与信号驱动IO模型的区别在于,信号驱动IO模型是由内核通知我们何时可以启动一个IO操作,这个IO操作由用户自定义的信号函数来实现,而异步IO模型由内核告知我们IO操作何时完成。 异步IO模型的特性总结如下表所示。 2。2。6易混淆的概念澄清 在实际开发中,我们经常会听到同步、异步、阻塞、非阻塞这些概念,每次遇到的时候都会蒙圈,然后就查网上各种资料,结果越查越迷糊。大部分文章都千篇一律,没有说到本质上的区别,所以下次再碰到这些概念,印象还是比较模糊,尤其是在一些场景下觉得同步与阻塞、异步与非阻塞没什么区别,但其实这四个术语描述的还真不是一回事。 下面我们来慢慢探讨它们之间的区别与联系,在这之前,我们还会经常看到下面的组合术语。 (1)同步阻塞。 (2)同步非阻塞。 (3)异步阻塞。 (4)异步非阻塞。 在什么是同步和异步、阻塞和非阻塞的概念还没弄清楚之前,更别提上面这些组合术语了,只会让你更加困惑。 1同步和异步 同步和异步其实是指CPU时间片的利用,主要看请求发起方对消息结果的获取是主动发起的,还是被动通知的,如下图所示。如果是请求方主动发起的,一直在等待应答结果(同步阻塞),或者可以先去处理其他事情,但要不断轮询查看发起的请求是否有应答结果(同步非阻塞),因为不管如何都要发起方主动获取消息结果,所以形式上还是同步操作。如果是由服务方通知的,也就是请求方发出请求后,要么一直等待通知(异步阻塞),要么先去干自己的事(异步非阻塞)。当事情处理完成后,服务方会主动通知请求方,它的请求已经完成,这就是异步。异步通知的方式一般通过状态改变、消息通知或者回调函数来完成,大多数时候采用的都是回调函数。 2阻塞和非阻塞 阻塞和非阻塞在计算机的世界里,通常指针对IO的操作,如网络IO和磁盘IO等。那么什么是阻塞和非阻塞呢?简单地说,就是我们调用了一个函数后,在等待这个函数返回结果之前,当前的线程是处于挂起状态还是运行状态。如果是挂起状态,就意味着当前线程什么都不能干,就等着获取结果,这就是同步阻塞;如果仍然是运行状态,就意味着当前线程是可以继续处理其他任务的,但要时不时地看一下是否有结果了,这就是同步非阻塞。具体如下图所示。 3实际生活场景 同步、异步、阻塞和非阻塞可以组合成上面提到过的四种结果。 举个例子,比如我们去照相馆拍照,拍完照片之后,商家说需要30min左右才能洗出来照片。 (1)这个时候,如果我们一直在店里面什么都不干,一直等待直到洗完照片,这个过程就叫同步阻塞。 (2)当然,大部分人很少这么干,更多的是大家拿起手机开始看电视,看一会儿就会问老板洗完没,老板说没洗完,然后接着看,再过一会儿接着问,直到照片洗完,这个过程就叫同步非阻塞。 (3)由于店里生意太好了,越来越多的人过来拍,店里面快没地方坐了,老板说你把手机号留下,我一会儿洗好了就打电话告诉你过来取,然后你去外面找了一个长凳开始躺着睡觉等待老板打电话,什么都不干,这个过程就叫异步阻塞(实际不应用)。 (4)当然实际情况是,大家可能会先去逛街或者吃饭,或者做其他活动,这样一来,两不耽误,这个过程就叫异步非阻塞(效率最高)。 4小结 从上面的描述中,我们能够看到阻塞和非阻塞通常是指在客户端发出请求后,在服务端处理这个请求的过程中,客户端本身是直接挂起等待结果,还是继续做其他的任务。而异步和同步则是对于请求结果的获取是客户端主动获取结果,还是由服务端来通知结果。从这一点来看,同步和阻塞其实描述的是两个不同角度的事情,阻塞和非阻塞指的是客户端等待消息处理时本身的状态,是挂起还是继续干别的。同步和异步指的是对于消息结果是客户端主动获取的,还是由服务端间接推送的。记住这两点关键的区别将有助于我们更好地区分和理解它们。2。2。7各IO模型的对比与总结 其实前四种IO模型都是同步IO操作,它们的区别在于第一阶段,而第二阶段是一样的:在数据从内核拷贝到应用缓冲区期间(用户空间),进程阻塞于recvfrom调用。 有人可能会说,NIO(NonBlockingIO)并没有被阻塞。这里有个非常狡猾的地方,定义中所指的IOOperation是指真实的IO操作。NIO在执行recvfrom的时候,如果内核(Kernel)的数据没有准备好,这时候不会阻塞进程。但是,当内核(Kernel)中数据准备好的时候,recvfrom会将数据从内核(Kernel)拷贝到用户内存中,这个时候进程就被阻塞了。在这段时间内,进程是被阻塞的。下图是各IO模型的阻塞状态对比。 从上图可以看出,阻塞程度:阻塞IO非阻塞IO多路复用IO信号驱动IO异步IO,效率是由低到高的。最后,再看一下下表,从多维度总结了各IO模型之间的差异,可以加深理解。 2。3从BIO到NIO的演进 下表总结了JavaBIO(BlockingIO)和NIO(NonBlockingIO)之间的主要差异。 2。3。1面向流与面向缓冲 JavaNIO和BIO之间第一个最大的区别是,BIO是面向流的,NIO是面向缓冲区的。JavaBIO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。JavaNIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程的灵活性。但是,还需要检查该缓冲区是否包含所有需要处理的数据。而且,要确保当更多的数据读入缓冲区时,不能覆盖缓冲区里尚未处理的数据。2。3。2阻塞与非阻塞 JavaBIO的各种流是阻塞的。这意味着,当一个线程调用read()或write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情。JavaNIO的非阻塞模式,是一个线程从某通道(Channel)发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用,就什么都不会获取,而不是保持线程阻塞,所以直到数据变成可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此。一个线程请求写入某通道一些数据,但不需要等待它完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞IO的空闲时间用于在其他通道上执行IO操作,所以一个单独的线程现在可以管理多个IO通道。2。3。3选择器在IO中的应用 JavaNIO的选择器(Selector)允许一个单独的线程监视多个输入通道,可以注册多个通道使用一个选择器,然后使用一个单独的线程来选择通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制使一个单独的线程很容易管理多个通道。2。3。4NIO和BIO如何影响应用程序的设计 无论选择BIO还是NIO工具箱,都可能会影响应用程序设计的以下几个方面。 (1)对NIO或BIO类的API调用。 (2)数据处理逻辑。 (3)用来处理数据的线程数。 1API调用 当然,使用NIO的API调用看起来与使用BIO时有所不同,但这并不意外,因为并不是仅从一个InputStream逐字节读取,而是数据必须先读入缓冲区再处理。 2数据处理 使用纯粹的NIO设计相较BIO设计,数据处理也会受到影响。 在BIO设计中,我们从InputStream或Reader逐字节读取数据。假设你正在处理一个基于行的文本数据流,有如下一段文本。 该文本行的流可以这样处理。 请注意处理状态由程序执行多久决定。换句话说,一旦reader。readLine()方法返回,你就知道文本行肯定已读完,readline()阻塞直到整行读完,这就是原因。你也知道此行包含名称;同样,第二个readline()调用返回的时候,你知道这行包含年龄。正如你可以看到,该处理程序仅在有新数据读入时运行,并知道每步的数据是什么。一旦正在运行的线程已处理过读入的某些数据,该线程不会再回退数据(大多如此)。下图也说明了这条原则。 JavaBIO从一个阻塞的流中读数据,而一个NIO的实现会有所不同,下面是一个简单的例子。 注意第二行,从通道读取字节到ByteBuffer。当这个方法调用返回时,你不知道你所需的所有数据是否在缓冲区内。你所知道的是,该缓冲区包含一些字节,这使得处理有点困难。 假设第一次read(buffer)调用后,读入缓冲区的数据只有半行,例如,Name:An,你能处理数据吗?显然不能,需要等待,直到整行数据读入缓存。在此之前,对数据的任何处理都毫无意义。 所以,你怎么知道是否该缓冲区包含足够的数据可以处理呢?好了,你不知道。发现的方法只能查看缓冲区中的数据。其结果是,在你知道所有数据都在缓冲区里之前,你必须检查几次缓冲区的数据。这不仅效率低下,而且会使程序设计方案杂乱不堪。例如: bufferFull()方法必须跟踪有多少数据读入缓冲区,并返回真或假,这取决于缓冲区是否已满。换句话说,如果缓冲区准备好被处理,那么表示缓冲区已满。 bufferFull()方法扫描缓冲区,但必须保持与bufferFull()方法被调用之前状态相同。如果没有,下一个读入缓冲区的数据可能无法读到正确的位置。虽然这是不可能的,但却是需要注意的又一个问题。 如果缓冲区已满,它可以被处理。如果它不满,并且在实际案例中有意义,或许能处理其中的部分数据,但是许多情况下并非如此。下图展示了缓冲区数据循环就绪。 3设置处理线程数 NIO可以只使用一个(或几个)单线程管理多个通道(网络连接或文件),但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂。 如果需要管理同时打开的成千上万个连接,这些连接每次只是发送少量的数据,例如聊天服务器,实现NIO的服务器可能是一个优势。同样,如果需要维持许多打开的连接,如P2P网络中,使用一个单独的线程来管理所有出站连接,可能是一个优势。一个线程有多个连接的设计方案如下图所示。 (1)JavaNIO:单线程管理多个连接。如果有少量的连接使用非常高的带宽,一次发送大量的数据,也许用典型的IO服务器实现可能非常契合。下图说明了一个典型的IO服务器设计。 (2)JavaBIO:一个典型的IO服务器设计。一个连接只用一个线程来处理。2。4JavaAIO详解 JDK1。7(NIO2)才是实现真正的异步AIO(AsynchronousIO)、把IO读写操作完全交给操作系统,学习了LinuxEpoll模式,下面我们来做一些演示。2。4。1AIO基本原理 JavaAIO处理API中,重要的三个类分别是:AsynchronousServerSocketChannel(服务端)、AsynchronousSocketChannel(客户端)及CompletionHandler(用户处理器)。CompletionHandler接口实现应用程序向操作系统发起IO请求,当完成后处理具体逻辑,否则做自己该做的事情,真正的异步IO需要操作系统更强的支持。 在多路复用IO模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。异步IO模型使用Proactor设计模式实现这一机制,如下图所示。 2。4。2AIO初体验 我们基于AIO先写一段简单的代码,来感受一下服务端和客户端的交互过程,同时也体验一下API的使用。先来看服务端代码。 上述代码的主要功能就是开启一个监听端口,然后在CompletionHandler中处理接收到消息以后的逻辑,将接收到的信息再输出到客户端。下面来看客户端的代码。 客户端的代码的主要功能是发送一串字符到服务端。同时,在CompletionHandler接口处理服务端发送过来的结果。 服务端执行结果如下图所示。 客户端执行结果如下图所示。 运行代码后,我们会发现不管是客户端还是服务端,其处理接收消息的逻辑都是异步操作,和BIO、NIO的API使用有根本上的区别。