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由于设计巧妙,开发简单,迅速收到开发者的喜爱,迅速得到了大公司的注意,Joyent
和Microsoft
都给NodeJS提供帮助,因此NodeJS迅速发展壮大。截止到目前,NodeJS的最新版本已经发展到V14/V15。成为广大开发者尤其是前端开发人员的快速、高性能后端服务的首选运行环境。
NodeJS的体系结构
在介绍NodeJS体系架构前,我们先来明确NodeJS里的几个概念:
-
同步/异步
同步/异步是用户态操作。同步就是函数在调用后,一只等待直到结果返回。异步那么就是调用后不需要等待结果返回,即可继续执行下一个函数,那么异步的结果会通过状态、回调触发返回,从而被获得到。
-
阻塞/非阻塞
阻塞/非阻塞是内核态操作。阻塞是指操作在等待数据返回之前,当前执行线程会被挂起直到数据返回,这时线程理论上一直被这个操作所占用。非阻塞是指操作不会等待数据返回,但是会向内核注册一个通知或Hook,线程继续执行其他操作,待数据返回时,注册的通知会自动触发继续执行原来的操作。
-
事件驱动:
事件驱动是一种操作触发方式,是异步动作的一种实现方式。比如鼠标左键点击,就可以看做一个事件。
有了以上的概念后,我们看一下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先会将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
实现了事件循环,它的特点是:单线程轮询事件,全异步操作,多线程执行。
下面这张图片可以清除的示意事件循环的机制
我们从中可以看出,上层的每一次的请求,都会转换成request放入事件队列中,然后使用一个线程来轮询队列请求。
接下来,这些请求的执行需要分两种类型:
- 对于网络I/O请求,直接交给OS的内核异步执行,例如Epoll.
- 对于本地I/O请求,事件循环会将请求提交I/O线程池,又其他线程来异步执行线程池中的任务。
下图可以描述两种类型的各自分工情况
因此,当V8执行上层JS代码时,对于I/O代码会直接生成异步请求,并提交给事件循环分发,最终异步结果放入Event Queue
, 最终返回给上层代码。具体流程可参考如下流程图
因此,最终NodeJS再用户的角度来看,只使用了单线程,就可以处理大量的并发请求,并可以高效的异步返回结果。
NodeJS的多线程模型
既然,使用事件循环机制已经很高效率,为什么我们还需要使用多线程呢?因为,在NodeJS中的搞并发都是有关I/O的,在高I/O低计算的场景中,NodeJS的单线程完全够用了。但是如果存在一些密集计算场景,就会因为单线程的计算耗时较长,影响事件循环,导致整体性能严重下降。
因此,NodeJS引入了worker线程,使NodeJS可以胜任一部分计算工作,但是总体来讲,由于涉及机制原因,NodeJS还是不适合密集计算工作的。
从NodeJS V12开始,正式引入了worker线程概念。他的实现思路是为每个worker线程单独分配V8引擎实例(V8 isolate)、事件循环、独立的JS代码文件、堆、栈。可以理解成在一个进程中的全新的一套NodeJS,但是与NodeJS主线程名义上属于主从关系。
从上图中,我们可以看到两个独立的线程是通过parentPort
和worker
的postMessage
相互通信的,从而使他们关联起来,那么是如何实现的呢?答案是管道。下图可以比较清晰的说明如何建立的管道通信。
worker线程需要经历两个阶段,才能正常使用:
-
worker初始化,这部分主要由主线程完成
- 用户级脚本通过使用引入worker_threads创建一个单独的worker实例
- Node会主线程创建并分配worker线程的内存资源,并生成一个线程ID
- 一个内部消息管道(IMC)被主线程创建。就是上图中灰色部分
Initialisation Message Channel
- 一个可暴露的JS消息通道(PMC)被worker主线程创建。用于该worker线程与主线程之间传递消息。
- Node主线程讲一些worker初始化元数据(worker执行脚本名称、worker数据、PMC的worker侧端口等)通过IMC发送给worker线程,worker线程完成最后的初始化。
-
worker运行,这部分主要由worker线程自行完成
- 创建一个新的
V8 isolate
并被分配给worker线程 - 初始化Libuv。确保拥有独立的事件循环
- 启动事件循环,并从IMC中读取主线程提供的元数据
- 开始执行worker用户代码。
- 创建一个新的
这样,主线程可以通过这种方式创建多个worker线程,进行并行工作。
总结
本文通过介绍NodeJS的设计思想,组成机制,解释了NodeJS是如何通过运行在单线程中来实现高并发、高I/O的后端服务。虽然NodeJS适用于高I/O的Web服务场景,但是对于一些密集计算任务,也给出了折中的worker线程方案,并简要的阐述了worker线程模型,以及如何工作。
以上是本人对NodeJS的一些浅显的理解和介绍,如有出入,请读者积极指正。