IO操作概念:
在Unix系统中,一切都是文件。文件就是流的概念,在进行信息的交流过程中,对这些流进行数据的收发操作就是IO操作。
我们都知道unix(like)世界里,一切皆文件,而文件是什么呢?文件就是一串二进制流而已,不管socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。在信息交换的过程中,我们都是对这些流进行数据的收发操作,简称为I/O操作(input and output),从流中读出数据,系统调用read,向流中写入数据,系统调用write。不过话说回来,计算机里有这么多的流,它怎么知道要操作哪个流呢?对,就是文件描述符,即通常所说的fd,一个fd就是一个整数,所以,对这个整数的操作,就是对这个文件(流)的操作。我们创建一个socket,通过系统调用会返回一个文件描述符,那么剩下对socket的操作就会转化为对这个描述符的操作。不能不说这又是一种分层和抽象的思想。
1、同步、异步
此处说的同步、异步是IO模型中的概念,并非并发模式中的同步、异步。
对于一个套接字上的输入操作,第一步就是通常涉及等待数据从网络中到达,当所等待的数据到达的时候,它就被复制到内核中的某个缓冲区中。第二步就是把数据从内核的缓冲区拷贝到应用进程的缓冲区中。
所谓的同步在这里指的是,等待数据到达或者用轮询的方式去查看数据是否到达,数据到达之后,然后直到IO操作完成后用户进程才不阻塞,也就是说同步模型中,其中真正的IO操作也就是第二步骤会将进程阻塞。
所谓的异步在这里指的是不导致请求进程阻塞的一种操作,一般工作模式就是由用户告知内核一个动作,然后让内核在整个操作完成后再通过用户指定的告知方式告知用户。比如用户在该IO操作过程中,告诉内核用户指定的缓冲区位置,以及IO操作完成之后通知程序的方式。所以在IO操作的期间,用户进程是不用管IO操作的,完全可以进行其他操作,直到内核操作完后接收结束通知就行。
同步概念: 执行一个操作后,进程触发IO操作(其中要么就是等待数据的到达,也就是阻塞模式;要么通过轮询去查看数据是否到达也就是非阻塞忙轮询模式的)接着数据到达后,便阻塞用户进程一直到IO操作完成。即第二步骤是阻塞的。
异步概念: 执行一个操作后,触发IO操作后不会导致请求进程阻塞。也就是说数据从内核到用户缓冲区的整个过程都是交给内核去完成的,用户进程无需阻塞一直等到IO操作完成,它只要执行一个操作触发IO操作后就可以继续执行其他操作,直到IO操作结束后,等待被通知就可以了。所以从根本来说异步从等待数据到把数据从内核空间拷贝到用户空间的过程中没有阻塞,只有发起该操作,和被通知该操作完成。所以异步是真正的没有阻塞在IO操作上的。
两者之间的区别: 我的理解是这样的:是否有CPU深度的参与、是否将所有IO操作交给了内核。这里的同步和异步是针对用户和内核的交互性来看的。同步IO操作的模型里面,比如阻塞式IO、非阻塞式IO、IO复用、信号驱动式IO中,它们的第一步骤是不相同的,但是都阻塞在第二步将数据从内核拷贝到用户缓冲区的IO操作上,而真正异步IO的过程只有发起和用户进程被等待通知,中间是没有任何阻塞的,它的IO操作需要CPU深度参与。所以按照严格定义的阻塞来看,异步是不存在阻塞的,而同步在真正的IO操作中将阻塞进程,只有异步IO模型与POSIX定义的异步IO匹配。所以区别二者就是在于用户进程是否将所有IO操作交给CPU去完成。若交给CPU之后就可以什么都不用管,只需要等待通知就可以了,那么就是异步;如果需要等待IO操作的完成,没有将IO操作的权利全都托付给CPU的为同步。
CPU是否深度参与,也就是IO操作是否全都由内核来操作,也就是第二步是否阻塞,阻塞为同步,不阻塞为异步。
严格意义上来看,异步是非阻塞的。所以同步才分阻塞和非阻塞,异步是不存在阻塞的。
通过以上可以将Unix的IO宏观上大体分为同步和异步,然后再从同步中分出阻塞和非阻塞。
2、阻塞、非阻塞
在网络编程中,socket在创建的时候默认是阻塞的。但是我们可以通过fcntl系统调用去设置为非阻塞。阻塞和非阻塞的概念应用于所有文件描述符。阻塞和非阻塞会导致一些系统调用出现无法立即完成而被操作系统挂起,一直等到有事件发生(如数据可读,缓冲区可写)为止。socket基础API中,可能被阻塞的系统调用包括accept,send,recv,read,connect等。所以在编程中,要理解并且处理好阻塞和非阻塞的设置。
网上大众的例子就是快递例子,那我也写一个我自己理解的例子——假如你今天制定好了一系列并且有时间顺序的计划,并且没有外界可以打乱你的计划,其中就有一项就是拿到快递,而当你之前的事情都按部就班地做了,就等拿快递的时候,寄存快递的储物箱坏了,但是维修人员还没有到。那么此时你后面还有一堆什么洗衣服的啥计划,第一种选择就类似阻塞,大概就是你就站在储物箱那里,等到维修人员来修理好并且打开储物箱给你取出快递,你才去洗衣服做后面的事情;第二种选择类似非阻塞,你现在为了不耽搁时间先回去洗衣服,等衣服洗好的时候,再回去看看存快递的储物箱修理好了没有,好了的话你就可以取出你的快递再继续做洗衣服后面的事情。没有好的话你再继续做洗衣服后面的另一件事情,然后做完后再去看看快递储物箱可以打开了不,一直以这样的模式轮询,直到放快递的储物箱可以打开了,可以准备取件为止。以下放上定义概念。
阻塞: 无数据准备好,系统调用比如read,recvfrom就会挂起,等到有数据准备好或者有数据了才继续执行系统调用,最后才从系统调用的函数中返回。
非阻塞: 这里的非阻塞是通过忙轮询去检测是否有数据准备好,没有数据准备好就一直轮询,直到有数据准备好了可以进行数据的复制为止。
注意: 这里的阻塞和非阻塞是从第一个步骤来看的,而同步和异步的阻塞是从真正的IO操作也就是第二步来看的。异步是真正意义上的非阻塞,所以异步不分阻塞和非阻塞,只有同步才分阻塞和非阻塞,同步中的阻塞和非阻塞是从第一个步骤来区分的,并非第二步骤,因为第二步骤中同步阻塞和同步非阻塞都将在真正的IO操作上被阻塞。
3、同步IO模型
同步IO模型包括下面四种:
阻塞IO模型
非阻塞IO模型
IO复用模型(select、pool、epool是阻塞的,epool可以设置成非阻塞)
信号驱动IO模型(非阻塞)
一、阻塞IO模型
所有套接字默认都是阻塞的,以recvfrom系统调用为例子,它要等到有数据报到达且被复制到应用进程的缓冲区中或者发生了错误才返回。若没有数据到达那么将一直会阻塞。
二、非阻塞IO模型
进程将一个套接字设置为非阻塞就是通知内核:当前所请求的IO操作在请求的过程不需要把进程投入睡眠,而是返回一个错误。(注意这里是指请求IO操作,不是进行IO操作)
当一个应用进程循环调用recvfrom的时候,这种操作叫做轮询。应用进程轮询内核,检查某个操作是否准备就绪,当IO操作准备就绪可以操作的时候就会进行真正的IO操作,就是将数据从内核写入用户空间的过程。但是这样做会导致CPU的大量耗费。
三、IO复用模型
我们可以通过系统调用select、poll、epoll、kqueue实现IO复用模型。此时进程就会阻塞在这些系统调用上,而不是阻塞在真正的IO操作上,直到有就绪事件了,这些系统调用就会返回哪些套接字可读写,然后就可以进行把数据包复制到应用进程缓冲区了。IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。
从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
其中select是通过不断的轮询,查看是否有就绪事件。如果有的话,再把所有的流遍历一遍看是哪个流准备就绪。而poll也是采用这样的轮询,只不过poll采用的是链表存储,所以没有最大连接数的限制,epoll是even poll,和忙轮询、无差别轮询不一样,它会把哪个流发生了怎样的I/O事件通知我们,不用全都遍历一遍才知道是哪个流发生了。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1)),而select和poll查找复杂度都是O(n)。
四、信号驱动式IO模型
我们也可以用信号让内核在文件描述符准备就绪的时候通知用户进程,即是告知我们什么时候可以启动IO操作。就如数据准备好了,内核就会以一种形式通知用户进程。
这种模型的优势就在于数据到达之前不被阻塞,主循环可以继续执行,用户进程只需要等到着来自信号的处理函数的通知即可,其中既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
4、异步IO模型
异步IO由POSIX规范定义的。一般地说,这些函数的工作机制就是:由用户进程告知内核启动一个操作,并且由内核去操作,操作完后给用户进程发一个通知,通知用户进程操作完了(包括数据从内核缓冲区拷贝到用户缓冲区的过程)。该模型与信号驱动式IO模型不同的就是,异步IO模型中,是由内核通知IO操作什么时候完成,而信号驱动式IO是由内核告知何时启动IO操作。
读写(read write)与阻塞和非阻塞:
阻塞的read: 在我们一般用read中,如果内核的接收缓冲区没有数据到达,那么将会一直阻塞。所以read函数如果在没有数据的时候,是被挂起不返回的,如果有数据了那么就是可以读多少就读多少。
阻塞的write: write如果是在socket阻塞的情况下就是用户进程有多少数据就要将所有数据都写入内核的可写缓冲区中才返回,这时候就是多路复用中为什么要将socket设置为非阻塞的原因。如果是阻塞的,那么写阻塞的时候,此时内核可写缓冲区可以容纳N个字节,而需要发送的数据有N+1个字节的话,那么write是不会返回的,它会一直阻塞直到那多出来的一个字节装到内核缓冲区了才会返回。所以在select中,返回可写条件的时候,要限制将套接字设置为非阻塞,才可以说一次性写操作返回一个正值。
非阻塞的read: 如果没有数据的话,那么read调用不会挂起,就会立即返回。如果有数据的话就是可以读多少就读多少。
非阻塞的write: 内核缓冲区够写多少就写多少,能够写多少要根据网路拥塞情况为标准,当拥塞严重的时候,没有足够的缓冲区去写的话,就会出现写不完的情况。
各种IO模型异同对比:
5、各个IO的区别
这里只考虑两个实体(客户端、服务端),一个事件(客户端向服务端请求数据)。
同步、异步描述的是:客户端在请求数据的过程中,能否做其他事情。
阻塞、非阻塞描述的是:客户端与服务端是否从头到尾始终都有一个持续连接,以至于占用了通道,不让其他客户端成功连接。
那么BIO NIO AIO就可以简单的理解为:
BIO(同步阻塞):客户端在请求数据的过程中,保持一个连接,不能做其他事情。
NIO(同步非阻塞):客户端在请求数据的过程中,不用保持一个连接,不能做其他事情。(不用保持一个连接,而是用许多个小连接,也就是轮询)
AIO(异步非阻塞):客户端在请求数据的过程中,不用保持一个连接,可以做其他事情。(客户端做其他事情,数据来了等服务端来通知。)
再说一下同步与阻塞的语义理解。
同步的意思是:客户端与服务端相同步调。就是说服务端没有把数据给客户端之前,客户端什么都不能做。它们做同样一件事情,就是说它们有相同步调,即同步。
阻塞的意思是:客户端与服务端之间是否始终有个东西占据着它们中间的通道。就是说客户端与服务端中间,始终有一个连接。导致其他客户端不能继续建立新通道连接服务器。
BIO NIO AIO在处理什么问题?为什么会出现NIO AIO?NIO比BIO好在哪?AIO比BIO好在哪?
BIO(同步阻塞)
定义:客户端在请求数据的过程中,保持一个连接,不能做其他事情。
BIO存在两个问题:
由于连接是双向的,“始终保持一个连接”,则说明,对于客户端和服务端而言,都需要一个线程来维护这个连接,如果服务端没有数据给客户端,则客户端需要一直等待,该连接也需要一直维持。假设一个连接需要5MB的内存,不考虑多任务的情况下,客户端总是要花费固定的5MB。那么对服务端,1个客户端建立连接则需要花5MB,10个就要50MB,1000个就要5GB。显然,阻塞给服务器带来的性能负担极大。
客户端不能做其他事情,只能等待该请求的完成,其本身的性能没有得到充分的释放,所以等待就是浪费时间。
NIO(同步非阻塞)
定义:客户端在请求数据的过程中,不用保持一个连接,不能做其他事情。
上面提到BIO,当有很多个客户端同时向服务端请求数据时,其连接所花费的开销就极大。那么NIO就使用了“不用始终保持一个连接”的方式,解决该问题。其过程为:
客户端发送一个请求,并建立一个连接,服务端接收到了。如果服务端没有数据,就告知客户端“没有数据”;如果有数据,则返回数据。客户端接到了服务端回复的“没有数据”就断开连接,过了一段时间后,客户端重新问服务端是否有数据。服务器重复以上步骤。
客户端反复建立连接询问,如果没有数据则断开连接。这个过程称为“轮询”。NIO用轮询代替了始终保持一个连接。
那么这样具体会有什么收益呢?
我们考虑以下问题:假如一个轮询连接只持续1s,服务器需要4s来准备一个数据,客户端在接到“没有数据”的回复后,隔1s再轮询一次。
对于BIO,1000个连接就需要5GB,在4s内,服务器内存消耗都是5GB。
对于NIO,在第1s内,服务器接收1000个连接的请求并花费5GB;在第2s内,服务器没有接收任何请求;在第3s内,服务器再次花费5GB接收1000个连接;第4s内没有请求;第5s开始时,处理所有请求返回结果。
整个流程是:为了接收1000个连接的请求,第1和第3s花费5GB,第2和第4s花费0GB,平均下来则是2.5GB。换个角度实际上是,1000个连接需要花费2.5GB,则2000个请求需要花费5GB。
在该例子中,NIO的容纳量比BIO高了一倍(5GB的容纳量从1000变成2000)。
所以NIO的收益就是,节约了“始终保持一个连接”的内存消耗。
AIO(异步非阻塞)
定义:客户端在请求数据的过程中,不用保持一个连接,可以做其他事情。
AIO也不用始终保持一个连接,但是其处理方式和NIO是不同的。并且这个方式让客户端可以做其他事情。
AIO用了一个通知机制,其流程如下:
客户端向服务端请求数据。服务端若有,则返回数据;若无,则告诉客户端“没有数据”。客户端收到“没有数据”的回复后,就做自己的其他事情。服务端有了数据之后,就主动通知客户端,并把数据返回去。
如此一来,整个请求流程中,不仅维持连接的消耗没了,而且客户端可以做别的事情了,节约了客户端的时间。
需要提的是,这里解决了连接的消耗,但是也必然引入了别的消耗。这里让客户端能先做别的事情,也肯定会带来新的麻烦。
别的消耗是指,服务端需要主动通知客户端,关于“通知”的业务逻辑肯定是需要消耗资源的。新的麻烦是指,客户端本来在做别的事情,突然前面的事情又插过来要做了,必然引入了一个多线程的协调工作。
NIO的三个实体
NIO有3个实体:Buffer(缓冲区),Channel(通道),Selector(多路复用器)。
Buffer是客户端存放服务端信息的一个容器,服务端如果把数据准备好了,就会通过Channel往Buffer里面传。Buffer有7个类型:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer。
Channel是客户端与服务端之间的双工连接通道。所以在请求的过程中,客户端与服务端中间的Channel就在不停的执行“连接、询问、断开”的过程。直到数据准备好,再通过Channel传回来。Channel主要有4个类型:FileChannel(从文件读取数据)、DatagramChannel(读写UDP网络协议数据)、SocketChannel(读写TCP网络协议数据)、ServerSocketChannel(可以监听TCP连接)
Selector是服务端选择Channel的一个复用器。Seletor有两个核心任务:监控数据是否准备好,应答Channel。具体说来,多个Channel反复轮询时,Selector就看该Channel所需的数据是否准备好了;如果准备好了,则将数据通过Channel返回给该客户端的Buffer,该客户端再进行后续其他操作;如果没准备好,则告诉Channel还需要继续轮询;多个Channel反复询问Selector,Selector为这些Channel一一解答。
NIO的实际应用
NIO主要用于分布式、即时通信和中间件Java系统中。
阿里的分布式服务框架Dubbo就默认使用Netty作为基础通信组件,用于实现各进程节点之间的内部通信。
Jetty、Apach的Mina、Jboos的Netty、Zookeeper都是基于NIO实现
理解具体化
上面提到了两个实体:客户端和服务端。但是全文实际上并未明确,实体到底是什么,实际物理表示是什么。
在网络IO场景中,客户端可以理解为我们自己的Client(台式机、手机、平板),服务端可以理解为云上的Server(高性能工作站)。Client向Server请求各种数据(商品详情、游戏角色信息、音乐mp3文件等)
在本地IO场景中,客户端是一个需要数据的程序,服务端是操作系统。客户端向操作系统请求本地磁盘的数据。