一步步分析 Node.js 的异步I/O机制

3,577 阅读8分钟

它的优秀之处并非原创,它的原创之处并非优秀。

《深入浅出Node》

本文章节如下图所示,阅读时间大约为10分钟~15分钟,图少字多,建议仔细阅读。

-w201

背景

在计算机资源中,I/OCPU计算在硬件支持上是可以并行进行的。所以,同步编程中的I/O引起的阻塞导致后续任务(可能是CPU计算,也可能是其他I/O)的等待会造成资源的不必要浪费

说白了明明就是硬件支持,但是软件上不支持,就是浪费。所以要做的是尽最大可能不让阻塞造成没必要的等待

问题引入

假设我们现在拿到一组任务,其中既有I/O又有CPU计算,同时假设我们的计算机是多核的但计算机资源有限的,为了减少上述的资源浪费情况你会怎么做?

-w400

第一种方案:多线程。

通过创建多个线程来分别执行CPU计算和I/O,这样CPU计算不会被I/O阻塞了。

它有如下的缺点:

  • 硬件上:创建线程和线程上下文切换有时间开销。
  • 软件上:多线程编程模型的死锁、状态同步等问题让开发者头疼。

第二种方案:单线程 + 异步I/O

首先它可以规避上述方案的缺点。

通过事件驱动的方式,当单线程执行CPU计算,I/O通过异步来进行调用和返回结果。这样也能使I/O不阻塞CPU计算。

但是它也有缺点:

  • 单线程没法利用多核CPU的优点。(一个线程肯定没法运行在多个CPU上)
  • 线程一崩,整个程序就崩溃了。(多线程这个问题的影响很小)
  • 非阻塞I/O通过轮询实现的,轮询会消耗额外的CPU资源。

问题分解

我们将上述描述的问题进行分解,梳理思路:

  • T1:减少I/O阻塞CPU计算的时间。
  • T2:不要带来锁、状态同步等问题。
  • T3:能利用多核CPU的优点。
  • T4:不要带来更多的额外消耗。

解决问题

Node通过异步调用+维护I/O线程池+事件循环机制来减少或避免I/O阻塞CPU计算的时间。后面我逐步解释上述三者:

异步调用

一图以蔽之。

-w400

这里我们要把异步调用处理过程抽象到操作系统层面,我们可知:异步调用是当应用程序发起I/O调用的时候,将调用信号发给操作系统,这时应用程序继续往下执行,直到操作系统完成任务之后,将数据返回,应用程序通过回调获取返回数据并在程序中执行相应的回调函数。

维护I/O线程池

我们将上述的操作系统进行剖析,其实内部是由Node维护了一个I/O线程池

当JavaScript线程(JavaScript是单线程的我就不解释了吧)执行过程中遇到了I/O任务的地方,会进行异步调用,封装参数和请求对象并将其放入线程池等待队列中等待执行。

当线程池有空余线程的时候,会让空余线程执行该I/O任务,执行完成之后,归还所占用的线程,同时我们拿到了I/O任务的执行结果

此时异步I/O进行的流程如下图所示:

-w400

IOCP是输入输出完成端口(Input/Output Completion Port,IOCP), 是支持多个同时发生的异步I/O操作的应用程序编程接口,是一个Windows内核对象。

事件循环机制

异步任务完成了,那JavaScript线程是怎么知道的呢?

最暴力也是最直接的方式就是让CPU去轮询,即创建一个无限循环一直去检查I/O的完成状态。所以现在为了解决**问题T1(减少I/O阻塞CPU计算的时间。)而导致了问题T4(不要带来更多的额外消耗。)**的产生,因为CPU会花费额外的资源去处理状态判断和不必要的“空转”。

这里我们可抽象地理解为CPU去轮询线程池中的各线程的状态。

所以我们要通过优化问题T4来尽可能地减少消耗。

一个著名的优化思路就是设定一个不可能达到的理想情况,然后设计具体方法来无限逼近理想目标。这里我们要优化问题T4使其趋近于问题T4不存在。

刚刚说了一直去检查I/O的状态是性能最低的方案(这叫read方案)。除此之外还有如下几种方案:

  • 轮询文件描述符上的事件状态(select方案)。但是由于它采用的是1024长度的数组来存储状态,所以最多检查1024个文件描述符,这里产生了限制性。

文件描述符是一个简单的整数,用以标明每一个被进程所打开的文件和socket。不要觉得1024很大了,在海量请求面前,真的是很小的数字。

  • 基于上述采用链表存储状态(poll方案)。但是在文件描述符较多的时候性能低下。
  • 在进入轮询的时候如果没有检查到I/O事件的完成,则轮询进行休眠,直到事件发生将它唤醒(epoll方案)。这是Linux下效率最高的I/O事件通知机制,不会造成CPU的浪费,毕竟轮询线程(其实就是JavaScript线程)已经休眠了。

下面我们通过描述生产者/消费者模型来梳理基于epoll的整个方案:

线程池中各线程中I/O事件的完成是事件的生产者

JavaScript线程中的事件的回调函数则是事件的消费者

Step1: Node的轮询机制在轮询I/O事件完成队列时,发现为空(即没有任何线程完成I/O),则Node的轮询机制进入休眠。

Step2: I/O线程池中有部分线程完成了,发送信号(操作系统完成)唤醒Node的轮询机制,从I/O事件完成队列里取出各完成的I/O对象,并执行相应的回调函数。

Step3: 如果在某次轮询时发现I/O事件完成队列为空,则又进入休眠直到再次被唤醒。

上述的Node的轮询机制则为事件循环即Event Loop,而I/O事件完成队列也为我们常说的事件观察者

关于这部分的更多内容可细读《深入浅出Node》第三章的3.3.2~3.3.5节。

经过事件循环,我们可以得出整个异步I/O的过程了。如图所示:

-w600

结论:Node通过异步调用+维护I/O线程池+事件循环机制解决了T1问题(即减少I/O阻塞CPU计算的时间),同时也将T4问题(即不要带来更多的额外消耗)的影响降至最低,由于JavaScript执行部分始终是单线程的,所以也不存在需要锁机制和各状态同步,T2问题(即不要带来锁、状态同步等问题)也不存在了。

所以这里我们可以得知,虽然JavaScript是单线程的,但是Node是多线程的,因为要维护一个I/O线程池啊。

这里我们只讲了异步I/O的情况,当然还有非I/O的异步任务,比如setTimeout。如果你看懂了上述的事件循环,其实你就可以理解为setTimeout就是往定时器观察者(这里不是I/O观察者哦,观察者有多个)队列中插入一个事件而已,每次循环的时候判断是否到期,到期就执行。

值得注意的是:定时器观察者是一棵红黑树。

好了,最后我们就要开始解决文章开头提到的T3问题了:

如何利用多核CPU的优点?

这里其实要解决的是单进程单核对于多核使用不足的问题。

废话不多说,Node用的是多进程架构,并采用Master-Worker的模式。,理想状态下每个进程都分配到一个专属的CPU。

主进程负责调度,工作进程做具体的工作。进程间通过IPC(进程间通信)传递数据。

但是我们要注意的是,创建工作进程(即子进程)的代价昂贵,需要至少30ms的启动时间和10MB的内存空间。所以一定要在开发的时候审慎对待。

搞清楚我们的目的:多进程是为了利用多核CPU,而不是为了解决并发

IPC可传递句柄,这让我们可以实现多个进程监听同个端口,可实现负载均衡。具体参考《深入浅出Node》第九章。

总结

Node通过异步调用+维护I/O线程池+基于epoll的事件循环机制来实现的异步I/O,并通过Master-Worker的多进程架构来充分利用多核CPU

以后在面对这样的言论你可以说他们是错的了:

  • Node是单线程的。
  • Node适用于I/O密集型,而不适用于CPU密集型。
  • Node写的东西太容易挂了。

你可以给他们解释清楚,然后说:

WechatIMG25934