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

它的优秀之处并不是原创,它的原创之处并不是优秀。编程

《深刻浅出Node》数组

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

-w201

背景

在计算机资源中,I/OCPU计算在硬件支持上是能够并行进行的。因此,同步编程中的I/O引发的阻塞致使后续任务(多是CPU计算,也多是其余I/O)的等待会形成资源的没必要要浪费架构

说白了明明就是硬件支持,可是软件上不支持,就是浪费。因此要作的是尽最大可能不让阻塞形成不必的等待并发

问题引入

假设咱们如今拿到一组任务,其中既有I/O又有CPU计算,同时假设咱们的计算机是多核的但计算机资源有限的,为了减小上述的资源浪费状况你会怎么作?负载均衡

-w400

第一种方案:多线程。

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

它有以下的缺点:socket

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

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

首先它能够规避上述方案的缺点。函数

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

可是它也有缺点:

  • 单线程无法利用多核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
相关文章
相关标签/搜索