从I/O模型到Netty(一)

I/O是任何一个程序设计者都无法忽略的存在,很多高级编程语言都在尝试使用巧妙的设计屏蔽I/O的实际存在,减小它对程序的影响,但是要真正的理解并更好运用这些语言,还是要搞清楚I/O的一些基本理念。本文将从最基本的I/O概念开始,试图理清当前I/O处理存在的问题和与之对应一些手段及背后的思想。
本来这是上个月在公司内部做的一次关于NIO的分享,发现很多概念可能当时理解的很清楚,过了一段时间就会感到模糊了。在这里整理一下,以备以后查看,同时也将作为另一个系列的开端。
由于篇幅限制,本文将只包含I/O模型到Reactor的部分,下一篇会继续讲到Netty和Dubbo中的I/O。本文包含以下内容:
- 五种典型的I/O模型
- 同步&异步、阻塞&非阻塞的概念
- Reactor & Proactor
- Reactor的启发
五种经典的I/O模型
这个部分的内容是理解各种I/O编程的基础,也是网上被讲解的最多的部分,这里将简单介绍一下Unix中5种I/O模型,由于操作系统的理论大多是相通的,所以大致流行的操作系统基本上都是这5中I/O模型。这一节的图例描述的是从网卡读取UDP数据包的过程,但是其模型放到更高层的系统设计中是同样有效的。
这一节的图都可以在「Unix网络编程」这本书里找到
0. 写在前面
从操作系统层面来看,I/O操作是分很多步骤的,如:等待数据、将数据拷贝到内核空间的PageCache(如果是Buffered I/O的话)、将数据拷贝到用户空间等。下面的几个模型有几个可能看起来很相似(在高级语言的环境中看,这TM不就是换了个概念重新讲一次吗),但从操作系统的角度来看他们是不同的。
1. Blocking I/O(阻塞I/O)
这是最基础的I/O模型,也有人会叫它「同步阻塞I/O」,如下图(从网卡读取UDP数据)所示,请求数据的进程需要一直阻塞等待读取完成才能返回,同时整个读取的动作(这里是recvfrom
)也是要同步等待I/O操作的完成才返回。

可能有人已经看到问题了,小红可以直接让
小绿
,前桌1
和前桌2
分别处理一张小纸条(方案#3),可以达到同样的效果啊(三张小纸条收到回复的时间同样是3、4、5分钟),干嘛套路这么多。。。
首先,方案#2和方案#3虽然耗时相同,但它们所浪费的资源是不同的,在方案#2里除了老师
和后桌1
两个不可或缺的资源外,前桌1
和前桌2
只保留一个人就够了,少一个人帮忙就少一个人分礼物。
其次,在这个例子里刚好t1+t2+t3==3(线程数)*t1
,而实际情况是t1+t2+t3>3(线程数)*t1
,同时,这里的问题规模也不大,如果只有3个人同时给小红写信,这个方案当然是好的,但是小红太popular了,经常会同时有10个小纸条过来,这种情况下方案#3就要比方案#2慢了(具体的计算过程就不放了)。
Reactor的好处和坏处
Reactor带来的好处是显而易见的:
- 吞吐量大
对小红来说,同样的资源可以传递更多的小纸条 - 对计算资源(CPU)更充分的利用
当然也有一些坏处:
- 系统设计更复杂了
- 由于系统更复杂,导致调试很困难
- 不适合传输大量数据的场景
举个栗子-Proactor
话说,老师发现小绿一直守在自己身边,就问了她是什么情况,然后他跟小红说,「你下次不要让小绿来守着我了,我读完纸条后通知你就行啦」。于是,小绿就不用做分发器的角色了,也被解放出来做计算工作了。
可以看到,分发器的角色其实还在,只是集成在了老师身上了。

如上图所示,小红收发小纸条的过程变成了这样:
-
小红
拿到小纸条放到老师
那里,并且告诉老师
读完后通知自己,然后自己就可以去做别的事情了(比如学习)。 - 老师读完后通知
小红
,小红在小绿
、前桌1
、前桌2
之中找一个人来思考回信。 - 思考完之后告诉
后桌1
去写回信。
Proactor模式相比Reactor明显要更好,但唯一的不好的地方就在于,它有一个前提条件是「老师必须支持传递消息」。它与Reactor是一脉相承的,Reactor的缺点同时也是Proactor的缺点。
Reactor的启发
道理是死的,人是活的。对于每一种设计模式或者最佳实践,其最有价值的部分其实是背后的思想。
启发一,事件处理循环
Proactor相比Reactor更好的地方在于,I/O操作和消息通知的过程被下层实现了,业务程序不再需要考虑这些,可以将Proactor看做是对Reactor的又一次封装。根据这个思路可以再进一步,在Reactor模式中不阻塞select
,而是在每个业务逻辑执行完后去处理这些事件,也就是在每次循环结束时去处理当前积攒下来的事件(这个模型里如何定义一个循环是很重要的)。

假设在某种场景下,整个程序的目的都是处理单一的事情(比如一个web服务器的目的只是处理请求),我们可以将「与处理请求无关」的逻辑封装到一个框架内,在每次请求处理完后,都执行一次事件的分发和处理,这就是event loop了。很多语言中都有这种概念,如nodejs中的event loop,iOS中的run loop。
启发二,消息通知&多路复用
Reactor和Proactor的思想是一样的,都是要通过「消息通知」和「多路复用」提高整个系统的吞吐量。在I/O之外,其实这两个思想对于我们日常开发也是很有用的,比如我们在某处需要分别执行三个互相不影响(正交)的任务,之后才能做其他事情,根据这两种思想可以写出程序如下:
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和微服务,,可能本来就是这样的,我们搞这么多的设计模式,最佳实践,各种花哨的术语和概念,最根本的目的还是要写出更好的代码。或者……也有例外?
