原本这是上个月在公司内部作的一次关于NIO的分享,发现不少概念可能当时理解的很清楚,过了一段时间就会感到模糊了。在这里整理一下,以备之后查看,同时也将做为另外一个系列的开端。javascript
因为篇幅限制,本文将只包含I/O模型到Reactor的部分,下一篇会继续讲到Netty和Dubbo中的I/O。本文包含如下内容:java
这个部分的内容是理解各类I/O编程的基础,也是网上被讲解的最多的部分,这里将简单介绍一下Unix中5种I/O模型,因为操做系统的理论大可能是相通的,因此大体流行的操做系统基本上都是这5中I/O模型。这一节的图例描述的是从网卡读取UDP数据包的过程,可是其模型放到更高层的系统设计中是一样有效的。node
这一节的图均可以在「Unix网络编程」这本书里找到react
从操做系统层面来看,I/O操做是分不少步骤的,如:等待数据、将数据拷贝到内核空间的PageCache(若是是Buffered I/O的话)、将数据拷贝到用户空间等。下面的几个模型有几个可能看起来很类似(在高级语言的环境中看,这TM不就是换了个概念从新讲一次吗),但从操做系统的角度来看他们是不一样的。web
这是最基础的I/O模型,也有人会叫它「同步阻塞I/O」,以下图(从网卡读取UDP数据)所示,请求数据的进程须要一直阻塞等待读取完成才能返回,同时整个读取的动做(这里是recvfrom
)也是要同步等待I/O操做的完成才返回。编程
可是,有时候咱们必须等待从I/O设备中传入的数据或者要向它写入某些数据,这个时候阻塞I/O每每是最适合的。好比你的项目中有一个配置文件,里边包含了不少关于项目的配置信息,那么在启动项目的时候就必须等待这个文件的内容被所有读取并解析后才能继续启动项目,这种场景下BIO是最合适的。设计模式
//代码1
//在Java中使用同步阻塞I/O实现文件的读取
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(new File(PRO_FILE_PATH));
Properties pro = new Properties();
pro.load(fis);
for (Object key : pro.keySet()) {
System.out.println(key);
System.out.println(pro.getProperty((String)key));
}
}复制代码
以下图所示,它与BIO恰好相反,当数据没有准备好的时候,recvfrom
调用仍然是同步返回结果,只是若是I/O不可用,它会即时返回一个错误结果,而后用户进程不断轮训,那么对于整个用户进程而言,它是非阻塞的。一般状况下,这是一种低效且十分浪费CPU的操做。服务器
以下图所示,在调用recvfrom
以前先调用另一个系统调用select
,当它返回时就表示咱们的数据准备好了,而后再调用recvfrom
就能直接读取到数据了。在这种场景下,整个读取的动做(由两个系统调用组成)是异步的,同时select
动做是会一直阻塞等待I/O事件的到来。网络
这种模式有个优势,这里的select
每每能够监听不少事件,它每每是在多线程的场景下使用,好比在Java的NIO编程中,多个线程能够向同一个Selector注册多个事件,这样就达到了多路复用的效果。多线程
以下图所示,用户进程告诉网卡说,你准备好了叫我一声,而后能够去作别的事情,当网卡来叫的时候就能够继续读操做了。按照上边几种模式的分类方法,很容易就把它一样分到了异步非阻塞模型中。
从操做系统的角度来看,「信号驱动I/O」和#3中介绍的「多路复用」还有下面要介绍的「AIO」是有很大的不一样的。
可是从概念上讲,它们是很类似的,其余两种其实也能够说是由某种信号驱动的I/O。I/O多路复用的信号是select
调用的返回,AIO则是由更底层的实现来传递信号。
固然,还有一个区别是「数据从内核空间拷贝到用户空间」这个动做再也不须要recvfrom
等待,而是在AIO的信号到来时就已经完成。
以下图所示,用户程序使用操做系统提供的异步I/O的系统调用aio_read
,这个调用会即时返回,当整个I/O操做完成后它会通知用户进程。典型的异步非阻塞I/O,与「信号驱动I/O」不一样的是这个信号是等到全部的I/O动做都执行完以后(数据已经被拷贝到用户空间),才被发送给用户进程。
AIO是一个很好的理念,使用起来也更简单,可是其内部实现就没那么简单了,POSIX中定义的AIO是经过多线程来实现的,它最底层的I/O模块调用仍是BIO,而Linux那群人就本身搞了一个真的内核级的异步非阻塞I/O,可是目前仅支持Linux,并且还引入了Direct I/O这个概念。
在有些平台中,AIO是默认实现的,好比nodejs,其底层其实也是使用阻塞I/O实现的异步,可是对于开发者来讲,能够认为它是彻底异步的。下面是nodejs读取文件的一个例子:
//代码2
//node环境下异步读取一个文件
const fs = require('fs')
const file='/Users/lk/Desktop/pro.properties'
fs.readFile(file,'utf-8', (err,data)=>{console.log(data)});复制代码
「Unix网络编程」中说道,按照POSIX标准中的术语,同步指的是I/O动做会致使用户进程阻塞,异步则恰好相反。按照这种分类,上边5种I/O模型中,只有AIO一种是异步的,其余都是同步的。
可是从高级语言的角度看,「I/O多路复用」和「信号驱动I/O」都没有致使用户进程的彻底被阻塞,由于在不少高级语言中,程序大可能是在多线程环境下运行的,一个线程阻塞并不会阻塞整个程序的执行。从这个角度来看,同步&异步、阻塞&非阻塞这两对概念只是从不一样角度对同一个场景的描述。
在Java中,同步异步每每指的是函数是否会等待整个操做处理完成后返回,而阻塞与非阻塞指的每每是用户线程是否须要等待某个事件的到来而阻塞。
把视线从底层的I/O概念中移开,放到普通的应用层实现上,一般基于以上几种I/O模型,能够对应几个编程模式,这里将重点介绍Reactor和Proactor。
简单来讲,Reactor指的是反应器,在这个模式中有一个角色叫分发器(分发器的叫法多种多样,acceptor、selector或者dispatcher),它会分发各类事件给Reactor,Reactor再去根据不一样的事件来作相应的动做。在上图中Reactor进行计算的方式是经过线程池实现的,这是在简单的Reactor模式上又添加了更多的能力,来进一步提升吞吐量,这也是Netty的基本架构。
假设一个初中二年级的班级正在上自习,小红是班上的班花,班上不少男孩子都喜欢她,其中就有小明、小黑和小白,因而他们三我的开始给她写情书,而后经过同窗把纸条传给小红。小红一次只能读一封小纸条,因此她只能顺序地拿到小纸条,读小纸条(让老师
帮忙读并理解小纸条),思考如何回复,最后把想法写在纸条上(假设后桌1
写字好看,小红必须让她来写回信),再发送小纸条发还回去。
这就是普通的BIO(方案#1)。
上个例子中的模式中,后边到来的小纸条每每要好久才能收到回信,形成了很坏的用户体验。
假如小红读(看小纸条,耗时t1)、想(回信的内容,耗时t2)、回(把回信的内容写到纸条上,耗时t3)的每一个步骤都须要1分钟,则第n个小纸条从收到到发回要耗时:
T = n*(t1 + t2 + t3)
,那么第一我的只须要3分钟就能拿到回信,第二我的须要6分钟,第3我的就须要9分钟。
因而小红开始想,能够发动四周的同窗帮本身思考回复的方案,并让本身的同桌小绿帮本身注意着「老师
读完小纸条,后桌1
写完小纸条」这两个事件。当有三个纸条同时到来时,小红都放到老师
那里,老师
顺序的读,每条读完后再交给前桌1
和前桌2
来思考回复策略,而后交给后桌1
写纸条。这样,第n我的拿到回复的时间是T = n*t1 + t2 + t3
,它们分别是3分钟、4分钟、5分钟。用户体验明显提升,并且小红本身还能够空出来不少的时间学习(方案#2)。
小绿
,
前桌1
和
前桌2
分别处理一张小纸条(方案#3),能够达到一样的效果啊(三张小纸条收到回复的时间一样是三、四、5分钟),干吗套路这么多。。。
首先,方案#2和方案#3虽然耗时相同,但它们所浪费的资源是不一样的,在方案#2里除了老师
和后桌1
两个不可或缺的资源外,前桌1
和前桌2
只保留一我的就够了,少一我的帮忙就少一我的分礼物。
其次,在这个例子里恰好t1+t2+t3==3(线程数)*t1
,而实际状况是t1+t2+t3>3(线程数)*t1
,同时,这里的问题规模也不大,若是只有3我的同时给小红写信,这个方案固然是好的,可是小红太popular了,常常会同时有10个小纸条过来,这种状况下方案#3就要比方案#2慢了(具体的计算过程就不放了)。
Reactor带来的好处是显而易见的:
固然也有一些坏处:
话说,老师发现小绿一直守在本身身边,就问了她是什么状况,而后他跟小红说,「你下次不要让小绿来守着我了,我读完纸条后通知你就行啦」。因而,小绿就不用作分发器的角色了,也被解放出来作计算工做了。
能够看到,分发器的角色其实还在,只是集成在了老师身上了。
如上图所示,小红收发小纸条的过程变成了这样:
小红
拿到小纸条放到老师
那里,而且告诉老师
读完后通知本身,而后本身就能够去作别的事情了(好比学习)。小红
,小红在小绿
、前桌1
、前桌2
之中找一我的来思考回信。后桌1
去写回信。Proactor模式相比Reactor明显要更好,但惟一的很差的地方就在于,它有一个前提条件是「老师必须支持传递消息」。它与Reactor是一脉相承的,Reactor的缺点同时也是Proactor的缺点。
道理是死的,人是活的。对于每一种设计模式或者最佳实践,其最有价值的部分实际上是背后的思想。
Proactor相比Reactor更好的地方在于,I/O操做和消息通知的过程被下层实现了,业务程序再也不须要考虑这些,能够将Proactor看作是对Reactor的又一次封装。根据这个思路能够再进一步,在Reactor模式中不阻塞select
,而是在每一个业务逻辑执行完后去处理这些事件,也就是在每次循环结束时去处理当前积攒下来的事件(这个模型里如何定义一个循环是很重要的)。
Reactor和Proactor的思想是同样的,都是要经过「消息通知」和「多路复用」提升整个系统的吞吐量。在I/O以外,其实这两个思想对于咱们平常开发也是颇有用的,好比咱们在某处须要分别执行三个互相不影响(正交)的任务,以后才能作其余事情,根据这两种思想能够写出程序以下:
//代码3
void asyncCall(long millSeconds, Runnable... tasks) {
if (tasks == null || tasks.length < 1) {
return;
}
CountDownLatch latch = new CountDownLatch(tasks.length);
for (Runnable task : tasks) {
Runnable t = () -> {
task.run();
latch.countDown();
};
new Thread(t).start();
}
try {
latch.await(millSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}复制代码
这是一个很普通的多线程应用,也能够经过NIO的思想进行解释。这里经过CountDownLatch来进行消息传递,而多个正交的任务复用这一个消息。固然这个例子存在不少问题,每一个任务都开一个线程明显形成了资源的浪费,但这些不在这里的考虑范围以内。
还有一个明显的例子是Dubbo的客户端调用,这个下次再说吧。
看了不少概念以后,有时候会忽然发现,这不就是以前的某某某概念从新包装了一下吗,如享元模式和单例模式,SOA和微服务,,可能原本就是这样的,咱们搞这么多的设计模式,最佳实践,各类花哨的术语和概念,最根本的目的仍是要写出更好的代码。或者……也有例外?