Node的异步I/O

  • Sorzen
  • 5 Minutes
  • April 12, 2020

为什么要异步I/O

Node是面向网络进行设计,web应用已经不再是单台服务器就能胜任的,在现今高并发已经是现代编程经常遇到的问题,网络的访问关系到用户的体验与资源的分配及资源的合理利用。
随着网站或应用的不断膨胀,数据将会分布在多台服务器上,资源分布后将会造成获取资源耗时变长,会更加造成异步与同步在性能上的差异,只有后端能够快速响应资源,才能让前端的体验更好。
单线程同步编程模型会阻塞I/O导致硬件资源得不到更优的使用,多线程编程模型也因为编程中的死锁、状态同步问题都会造成开发的难度,Node在两者之间给出的解决方案:利用单线程原理多线程死锁、状态同步等问题;利用异步I/O,让单线程原理阻塞,以更好地使用CPU。

Node的异步I/O整个过程包括事件循环、观察者和请求对象等,接下来将会从这几方面介绍Node的异步I/O

事件循环

Node自身的执行模型就是事件循环,在进程启动时,Node变回创建一个类似于while(true)的循环,每次执行一次循环体的过程称之为Tick.每个Tick过程就是查看是否有事件待处理的过程,如果有,就祛除事件及其相关的回调函数。如果存在关联的回调函数就执行他们,然后进入下一个循环,如果不再有事件处理,就退出进程。

观察者

在每个Tick的过程中,如何判断是否有事件需要处理呢?
每个时间循环有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。
eg:
这就如同饭店,厨房一轮一轮的制作饭菜,但是要具体制作哪些菜肴取决于收银台收到的客人下的订单,厨房每做完一轮菜肴,就去问收银台接下来有没有要做的菜,如果没有的话,就下班。在这个过程中,收银台就是观察者,收银台收到的客人订单就是关联的回调函数。饭店可能有多个收银员就如同事件循环中有多个观察者一样。收到下单就是一个事件,一个观察者里可能有多个事件。
浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在node中事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等,观察者将事件进行分类。
事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的生产者,源源不断的为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

请求对象

对于Node中的异步I/O调用而言,回调函数却不由开发者来调用。当发出调用后到回调函数被执行,这个过程中间还存在一个中间产物,那就是请求对象。
JavaScript调用立即返回,由JavaScript层面发起的异步调用的第一阶段就此结束。JavaScript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不管它是否阻塞I/O,都不会影响到JavaScript线程的后续执行,这就达到了异步的目的。

请求对象是异步I/O过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。

执行回调

事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素

异步编程解决方案

异步编程的主要解决方案有如下3种:

事件发布/订阅模式

事件监听器模式是一种广泛应用于异步编程的模式,是回调函数的事件化又称发布/订阅模式。
Node自身提供的events模块是发布/订阅模式的一个简单实现,Node中部分模块都继承自它,这个模块比前端浏览器中的大量DOM事件简单,不存在事件冒泡。它具有addListener/on()、once()、removeListener()、removeAllListeners()和emit()等基本的时间监听模式的方法实现。事件发布/订阅模式的操作很简单:
eg:

1
2
3
4
5
6
7
// 订阅
emitter.on('event1', function (message) {
console.log(message)
})

// 发布
emitter.emit('event1', 'event1s message')

事件发布/订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数又称为事件侦听器。通过emit()发布事件后,消息会立即传递给当前事件的所有侦听器执行。侦听器可以很灵活的添加和删除,使得事件和具体处理逻辑之间可以很轻松地关联和解耦。
在一些场景中,可以通过事件发布/订阅模式进行组件封装,将不变的部分封装在组件内部,将容易变化、需自定义的部分通过事件暴露给外部处理,可以做到逻辑分离。在这种事件发布/订阅组件中,事件的设计非常重要,因为它关乎外部调用组件时是否优雅,这种事件的设计也可以看成组件的接口设计。
事件监听器模式也是一种钩子(hook)机制,利用钩子导出内部数据或状态给外部的调用者。Node中很多对象大多具有黑盒的特点,功能点较少,如果不通过事件钩子的形式,我们就无法获取对象在运行期间的中间值或内部状态。这种通过事件钩子的方式,可以使编程者不用关注组件是如何启动和执行的,只需关注在需要的事件点上即可。

Promise/Deferred模式

使用事件的方式时,执行流程需要被预先设定。即便是分支,也需要预先设定,这是由发布/订阅模式的运行机制所决定的。

Promises/A

流程控制库

通过手工调用才能持续执行后续调用的,将此类方法叫做尾触发,常见的关键词-next。尾触发目前应用最多的地方是Connect的中间件。
每个中间件传递请求对象、响应对象和尾触发函数,通过队列形成一个处理流。

中间件机制使得在处理网络请求时,可以像切面编程一样进行过滤、验证、日志等功能,而不与具体业务逻辑产生关联,以致产生耦合。
处理流

小结

异步I/O主要包括:单线程、事件循环、观察者和I/O线程池,这里的单线程与I/O线程池之间看起来有些悖论的样子。由于JavaScript是单线程,所以认为它不能充分利用多核CPU。在Node中除了JavaScript是单线程外,Node自身其实是多线程的,只是I/O线程使用的CPU较少。另外除了用户代码无法并行执行外,所有的I/O则是可以并行执行的。