NodeJS入门基础与线程模型

NodeJS作为一钟广泛使用的服务端运行环境,拥有大量的应用场景。NodeJS设计简单,并兼备高性能, 其设计思想具有很大借鉴意义,本文将介绍NodeJS涉及原理,科普相关知识。

前言

NodeJs在不引入其他线程的情况下,可以实现高并发,尤其适用高IO场景。并兼容JavaScript, 前端工程师可快速上手,提供后端服务能力。

NodeJS的诞生

说到Nodejs,那就得先从Ryan Dahl讲起了。他一开始从事的工作是使用C++构建高性能的Web服务。但是由于C++更偏于底层,所以使用C++实现Web服务太过于繁琐,也很痛苦。

因此他就想能否使用一种简单的高级语言就可以实现Web服务呢?经过几番对比,最后Ryan Dahl选择了JavaScript, 当时前端技术蓬勃兴起,并且JS上手简单,并JS本身就是异步IO,能很好的支持Web服务。那么,如何降JS运行在服务端的环境呢?但是Google已经推出了JavaScript的C++解析引擎V8, 并拥有不错的性能表现,因此顺利成章的成为了NodeJS的组成部分。

NodeJS由于设计巧妙,开发简单,迅速收到开发者的喜爱,迅速得到了大公司的注意,JoyentMicrosoft都给NodeJS提供帮助,因此NodeJS迅速发展壮大。截止到目前,NodeJS的最新版本已经发展到V14/V15。成为广大开发者尤其是前端开发人员的快速、高性能后端服务的首选运行环境。

NodeJS的体系结构

在介绍NodeJS体系架构前,我们先来明确NodeJS里的几个概念:

  • 同步/异步

    同步/异步是用户态操作。同步就是函数在调用后,一只等待直到结果返回。异步那么就是调用后不需要等待结果返回,即可继续执行下一个函数,那么异步的结果会通过状态、回调触发返回,从而被获得到。

  • 阻塞/非阻塞

    阻塞/非阻塞是内核态操作。阻塞是指操作在等待数据返回之前,当前执行线程会被挂起直到数据返回,这时线程理论上一直被这个操作所占用。非阻塞是指操作不会等待数据返回,但是会向内核注册一个通知或Hook,线程继续执行其他操作,待数据返回时,注册的通知会自动触发继续执行原来的操作。

  • 事件驱动:

    事件驱动是一种操作触发方式,是异步动作的一种实现方式。比如鼠标左键点击,就可以看做一个事件。

有了以上的概念后,我们看一下NodeJS的整体架构图:

NodeJS架构

我们可以从架构图中看到,NodeJS由运行环境几大部分组成。

  • Application: 上层的应用JS代码
  • V8: 用于加载、解析JS的代码。
  • Bindings: 是V8引擎和下层C++内置库、第三方库交互的桥梁,这层会对V8中的JS数据结构和操作进行转换成底层C++对应模块的调用。
  • LibUV: 负责底层OS文件系统、网络系统、进程等的异步事件触发和回调。例如, Native I/O、Network I/O、Signal、Timer、Process、Pipe、Pool。

V8引擎

大名鼎鼎的Google V8引擎,可以快速解析JS语言, 并翻译成各平台的机器语言执行。NodeJS根据稳定性和维护性方面考虑,引入了V8引擎,并对V8引擎做了修改,使V8层可以加载内部的或者第三方C++库文件,我们叫做Bingdings layer, 作为NodeJS的基础组件。

这么我们通过一个简单图,来简要理解一下V8的工作流程

V8解析流程图

从流程图中我们可以看出,V8先会将JS语言解析成语法树(AST), 然后再转换成的字节码, 最后将字节码输入到解释器中翻译成机器码运行。

这过程中,可观察到两个实时的功能,一个是垃圾回收,负责回收已经不会使用的对象内存;一个是JIT,负责对频率比较高的JS代码,直接转换成机器码,跳过解释器的翻译过程,从而加快代码运行速度。

更详细的关于V8的介绍这里就不展开了,因为它并不是NodeJS设计的精髓,读者可以自行查阅V8相关资料。

Bindings layer

NodeJS绑定层,其实是一种C++加载器,其任务是加载内部或者第三方的C++库,然后暴露接口给上层JS和V8引擎调用。当NodeJS启动时,Bindings会自动加载内部的C++模块。

用户可以按照NodeJS中V8提供的宏定义,实现自己的C++层部分,并暴露N-API给上层JS调用。具体方法请阅读N-API文档。

Libuv

介绍了上述的组成部分,我们要来说一下NodeJS的精髓部分了: 事件循环

NodeJS中使用了Libuv实现了事件循环,它的特点是:单线程轮询事件,全异步操作,多线程执行。

下面这张图片可以清除的示意事件循环的机制

Libuv事件循环

我们从中可以看出,上层的每一次的请求,都会转换成request放入事件队列中,然后使用一个线程来轮询队列请求。

接下来,这些请求的执行需要分两种类型:

  • 对于网络I/O请求,直接交给OS的内核异步执行,例如Epoll.
  • 对于本地I/O请求,事件循环会将请求提交I/O线程池,又其他线程来异步执行线程池中的任务。

下图可以描述两种类型的各自分工情况

LibuvI/O类型

因此,当V8执行上层JS代码时,对于I/O代码会直接生成异步请求,并提交给事件循环分发,最终异步结果放入Event Queue, 最终返回给上层代码。具体流程可参考如下流程图

Libuv调用流程

因此,最终NodeJS再用户的角度来看,只使用了单线程,就可以处理大量的并发请求,并可以高效的异步返回结果。

NodeJS的多线程模型

既然,使用事件循环机制已经很高效率,为什么我们还需要使用多线程呢?因为,在NodeJS中的搞并发都是有关I/O的,在高I/O低计算的场景中,NodeJS的单线程完全够用了。但是如果存在一些密集计算场景,就会因为单线程的计算耗时较长,影响事件循环,导致整体性能严重下降。

因此,NodeJS引入了worker线程,使NodeJS可以胜任一部分计算工作,但是总体来讲,由于涉及机制原因,NodeJS还是不适合密集计算工作的。

从NodeJS V12开始,正式引入了worker线程概念。他的实现思路是为每个worker线程单独分配V8引擎实例(V8 isolate)、事件循环、独立的JS代码文件、堆、栈。可以理解成在一个进程中的全新的一套NodeJS,但是与NodeJS主线程名义上属于主从关系。

Worker线程

从上图中,我们可以看到两个独立的线程是通过parentPortworkerpostMessage相互通信的,从而使他们关联起来,那么是如何实现的呢?答案是管道。下图可以比较清晰的说明如何建立的管道通信。

Worker管道

worker线程需要经历两个阶段,才能正常使用:

  1. worker初始化,这部分主要由主线程完成

    • 用户级脚本通过使用引入worker_threads创建一个单独的worker实例
    • Node会主线程创建并分配worker线程的内存资源,并生成一个线程ID
    • 一个内部消息管道(IMC)被主线程创建。就是上图中灰色部分Initialisation Message Channel
    • 一个可暴露的JS消息通道(PMC)被worker主线程创建。用于该worker线程与主线程之间传递消息。
    • Node主线程讲一些worker初始化元数据(worker执行脚本名称、worker数据、PMC的worker侧端口等)通过IMC发送给worker线程,worker线程完成最后的初始化。
  2. worker运行,这部分主要由worker线程自行完成

    • 创建一个新的V8 isolate并被分配给worker线程
    • 初始化Libuv。确保拥有独立的事件循环
    • 启动事件循环,并从IMC中读取主线程提供的元数据
    • 开始执行worker用户代码。

这样,主线程可以通过这种方式创建多个worker线程,进行并行工作。

总结

本文通过介绍NodeJS的设计思想,组成机制,解释了NodeJS是如何通过运行在单线程中来实现高并发、高I/O的后端服务。虽然NodeJS适用于高I/O的Web服务场景,但是对于一些密集计算任务,也给出了折中的worker线程方案,并简要的阐述了worker线程模型,以及如何工作。

以上是本人对NodeJS的一些浅显的理解和介绍,如有出入,请读者积极指正。

最近的文章

NodeJS多线程库Threads

NodeJS作为server端的运行环境,在低资源占用的情况下,处理高IO有很大的优势。然而对于密集型计算的任务却有些力不从心,虽然早已引入worker线程,但依然依然在使用上有诸多不便。本文介绍一种NodeJS三方库: ThreadsJS, 使用者可以快速使NodeJS具有复杂场景下密集计算的能力。 前言 Threads.js 整体设计 线程封装 工作线程封装函数: expose 主线程封装函数: spawn 线程间信息传递 ...…

继续阅读