
第 63 届早早聊大会将于 2023 年 4 月 15 日(本周六)举办 - 低代码搭建|无码有码 全靠拖拉,6 位讲师全天直播,关键词:低代码/组件物料/游戏实践/ AB Test /React 玩转。跟早早聊一起,学习低码搭建,上车链接:https://www.zaozao.run/conf/c63

本文是 2021 年 12 月 26 日,第三十五届 - 前端早早聊【前端搞 Node.js】专场,来自字节跳动 Web Infra 前端团队 —— 陈跃标的分享。感谢 AI 的发展,借助 GPT 的能力,最近我们终于可以非常高效地将各位讲师的精彩分享文本化后,分享给大家。(完整版含演示请看录播视频和 PPT):https://www.zaozao.run/video/c35
大家好,我是来自字节跳动 Web Infra 团队的陈跃标。我今天分享的主题是《深入理解 Node.js 的底层原理》,今天分享的内容一共分为以下 5 个部分。

下面我们先来看一下 Node.js 的组成和代码架构。
Node.js 主要由 V8 引擎、Libuv 和一些第三方库组成。

V8 是一个 JS 的引擎,不仅实现了 JS 的解析和执行,而且还支持一些自定义扩展能力。比如说我们可以通过 V8 提供的一些 C++ API,然后去定义一个全局的变量,这样的话我们就可以在 JS 层里面去访问到这个全局的变量。
Libuv 是一个跨平台的异步 I/O 库,主要封装了各个操作系统的一些 API,提供网络和文件等功能。因为我们知道在 JS 里面其实是没有网络和文件这些功能的,在前端这些功能是由浏览器去提供的。因此在 Node.js 里面,这些功能就由 Libuv 去实现。
另外 Node.js 里面还用了很多第三方库,比如说像 DNS 解析,用了 cares 这个异步的 DNS 解析库,还有像 HTTP 解析器、HTTP2 解析器,还有一些压缩解压、加密解密的借鉴库等等。
接下来我们再看一下 Node.js 的代码的整体架构。

Node.js 的代码一共分为 3 个部分,分别是 JS、C++ 和 C语言。
第一部分 JS 的代码主要是 Node.js 本身提供的一些模块,比如说像我们平时使用的 net、fs、HTTP 这些模块。而对于 C++ 的代码,主要是封装了 Libuv 和一些第三方库的 C++ 代码,比如说像我们平时使用的 net、fs、HTTP 这些模块会对应到 C++ 层的一个模块。
第二部分的内容是关于不依赖 Libuv 和第三方库的 C++ 代码。例如像我们通常使用的 Buffer 模块,主要依赖于 V8 提供的一些 API。 C++ 代码,则是关于 V8 本身的实现,因为 V8 是一个纯 C++ 实现的库。
第三部分 C 语言代码,则包括了一些第三方库和 Libuv 的代码,因为这些库都是纯 C 语言实现的。
无码有码,全靠拖拉。跟早早聊一起,学习低代码搭建,上车链接:https://www.zaozao.run/conf/c63
在了解了 Node.js 的组成和代码架构之后,让我们来看看 Node.js 中一些核心实现。首先介绍一下 Libuv,这将分为三个部分。
关于 Libuv 的模型以及限制。
介绍了线程池如何解决问题以及带来的问题。
介绍了事件循环和微任务处理的内容。
Libuv 本质上是一个生产者消费者模型。

从图中的右下角可以看出,在 Libuv 中有许多种类型的生产者,例如在一个回调函数中,或者在一个 Node.js 初始化的时候,或者在线程池完成操作的时候,它们都会充当生产者的角色,向这个事件循环中生产一些任务。Libuv 会在这个事件循环中不断地消费这些任务,从而驱动整个系统的运行。
生产者消费者模型存在一个问题,那就是消费者和生产者之间如何进行同步?例如,如果当前系统没有任务需要消费,消费者应该做什么?

第一种方式是以一种轮询的方式,也就是说在这种情况下,消费者会睡眠一段时间,然后醒来后会判断当前系统是否有任务需要处理,如果有的话就会处理,如果没有的话,就会继续睡眠。但显然,这种方式效率较低。
第二种方式是当系统没有任务需要处理时,进程会挂起,直到有任务需要执行时,系统会唤醒一个进程,然后进程会继续处理这些任务。
Libuv 中使用的就是第二种方式,并且这种方式是通过事件驱动模块来实现的。每个操作系统基本上都提供了一个事件驱动的模块,例如在 Linux 下提供的是 Epoll,在 Mac 下提供的是 Kqueue,在 Windows 下提供的是IOCP。

下面我们来看一下这个事件驱动模块使用的过程。
首先,应用层的代码通过事件驱动模块订阅一个 fd 对应的事件。如果此时该 fd 对应的事件没有就绪,那么该进程会被挂起,等待事件的发生。一旦事件发生,操作系统会唤醒该进程,并通过事件驱动模块回调应用层的代码。
以下以 Linux 下的事件驱动模块 Epoll 为例,我们来看一下事件驱动模块的使用过程。

第一步,通过 epoll_create 创建一个 Epoll 实例,这是后续操作的基础对象。
第二步,通过 epoll_ctl 可以订阅、修改或取消订阅一个 fd 对应的事件。
第三步,通过 epoll_wait 来判断当前是否有事件发生。如果有事件发生,就会直接执行上层注册的回调函数。如果没有事件发生,可以选择是非阻塞、定时阻塞或一直阻塞直到有事件发生。是否阻塞以及阻塞的时间取决于系统当前的状态。例如,在 Node.js 里,如果有定时器,Node.js 会选择定时阻塞,以确保定时器能按时执行。而如果系统中只有一个监听的 Socket 的话,Node.js 会一直阻塞,直到有连接到来时才会被唤醒。
Epoll 本身也存在一些限制:
Epoll 不支持文件操作,这是因为操作系统本身没有实现这个功能。
Epoll 不太适合执行一些耗时的任务,例如大量的 CPU 计算和可能导致进程阻塞的任务。因为 Epoll 通常是搭配单线程使用的,如果在单线程中执行耗时任务或可能导致进程阻塞的任务,后续的任务就无法进行。

针对这个问题,在 Node.js 中引入了解决方案,即引入了一个线程池。下面我们来看一下线程池和主线程的一个关系。

当处理 CPU 密集型任务、文件操作或者数据库查询等任务时,Node.js 会直接将这些任务提交给线程池处理,而不是提交给主线程处理。线程池在处理完任务后会通知主线程,在主线程的合适阶段,通常是在 Poll IO 阶段,执行对应的回调函数。
引入多线程解决了一个问题,但也带来了一个新问题,就是如何保证上层的代码在单个线程中运行,因为我们知道 JS 本身是单线程的。如果底层线程在完成任务后直接回调上层代码,那么上层代码可能会出现混乱。
为了解决这个问题,Node.js 中采用了异步通知的机制。

具体而言就是通过一个名为 Libuv 的库,在初始化时会创建一个管道,分为读端和写端。当线程池完成任务后,会向管道的写端写入一些数据,通知主线程任务已完成。然后在主线程的 Poll IO 阶段,会从管道的读端读取数据,从而执行对应的回调函数。信号处理也采用了类似的方式,当进程接收到信号时,会向管道的写端写入一些数据,通知主线程当前进程接收到了一个信号。在主线程的 Poll IO 阶段,会从读端读取数据,从而执行相应的回调函数。
从这可以看出,虽然 Node.js 底层是多线程的,但所有的回调函数都由主线程调度执行,这就是为什么关于 Node.js 是单线程还是多线程的问题,从不同角度看可能得到不同答案。
下面我们以异步读取文件为例,大致了解一下这个过程。

当我们提交一个异步读文件的操作时,Node.js 会直接将这个任务提交给线程池,然后主线程可以继续做其他事情,不需要等待任务完成。当任务完成后,会向主线程的任务队列插入一个任务节点,在主线程的 Poll IO 阶段,会执行对应的回调函数。
接下来,我们将探讨 Node.js 中的事件循环。Node.js 中的事件循环一共分为 7 个阶段。

第一个阶段 - time 阶段:主要处理与定时器相关的任务,例如 setTimeout() 函数和 setInterval() 函数。
第二个阶段 - pending 阶段:主要处理在轮询 I/O 阶段执行回调时产生的一些回调。
第三个阶段 - check、prepare、idle 阶段:用于处理一些自定义任务。其中,prepare 和 idle 这两个阶段主要用于处理 Node.js 内部使用的类似于我们平时使用的 setImmediate 属于 check 这个阶段。
第四个阶段 - Poll IO 阶段:主要处理与文件描述符相关的事件。
第五个阶段 - close 阶段:主要用来处理调用 UV close 时传入的回调,例如在关闭一个 TCP 连接时的回调将在这个阶段被执行。
上图中,每一项循环都表示一个阶段的流程,并标记了每个阶段在四项循环中的位置。现在我们来看一下每个阶段的具体实现。
在底层,Libuv 维护了一个最小堆,其中最快到期的节点位于堆的顶部。在定时器阶段,UV 会从上往下遍历这个最小堆,并判断当前节点是否已经到期。如果节点没有到期,那么后面的节点也不需要再进行判断,因为最快到期的节点都没有到期,那么后面的节点显然也不会到期。如果节点已经到期,那么 UV 会将它从最小堆中移除,并执行该节点对应的回调函数。在 setInterval 中,如果节点设置了一个 repeat 标记,Libuv 会将它重新插入到最小堆中,等待下一次超时。
刚才介绍的是 Libuv 中定时器的实现,但实际上在Node.js的上层,实现稍微复杂一些,主要是因为 Node.js 本身做了一些优化。从图中可以看到,Node.js 在 JS 层 也维护了一个最小堆,即图中红色部分。对于堆中的每个节点,它的相对超时时间是不一样的,而最快到期的节点位于最小堆的顶部。此外,堆中的每个节点还维护了一个名为 Timeout 的队列,其中每个 Timeout 实际上对应着调用 或 函数时传入的任务,在这个队列中,最快到期的节点会加入队列的最前面。

当我们调用 时,Node.js 会通过 setTimeout 的第二个参数,找到对应在最小堆中的一个节点,然后将 setTimeout 的回调函数插入到队列的尾部。在必要的时候,Node.js 会调整 JS 层最小堆的结构,并从最小堆中选出一个最快到期的节点,然后修改底层 Libuv 的定时器节点。当底层的定时器节点到期时,它会回调上层的 JS 回调函数。在这个 JS 回调函数中,它会遍历 JS 的最小堆,找出所有已经超时的节点,并执行它们的回调函数。从图中我们也可以看到,即使在 Node.js 中存在多个定时器,实际上只有底层的 Libuv 的定时器节点被消耗掉。
这三个阶段的实现方式是一样的,它们都对应着一个自己的任务队列。当产生任务时,会将任务插入到相应阶段的任务队列中,并在相应的阶段时遍历任务队列,执行每个节点对应的回调函数。不过这三个阶段比较特殊的地方在于,当任务节点被消费并执行回调函数后,它会被重新插入到任务队列中。也就是说,在每一轮的事件循环中,这三个阶段的任务都会被执行。

另外,在遍历任务队列时,这里有一个小技巧,就是会将任务队列赋值给一个临时变量。这么做的目的是防止在回调函数中又新增节点,导致遍历过程陷入死循环。

Pull IO 阶段实际上是对事件驱动模块的一种封装,它主要用于处理网络 IO 和文件监听等功能。当我们订阅一个 fd 的事件时,Libuv 会操作 Epoll,注册该 fd 对应的事件。如果事件没有就绪,Libuv 会阻塞在 epoll_wait 中。当事件触发后,Libuv 会遍历 Epoll 返回的事件列表,并执行每个事件对应的回调函数。

在 Node.js 中,微任务的处理也是一个非常关键的节点,例如常用的 nextTick 和 Promise。我们知道宏任务和微任务的执行流程是,在每次执行完一个宏任务之后,会清空所有的微任务。

在 Node.js 中,处理微任务有两种方式。
第一种方式是定义一个 C++ 的 InternalCallbackScope 的对象,然后在对象析构或者主动去调用 close() 函数的时候,就会进行一次微任务的处理。
第二种方式的话就是主动去调 JS 函数 runNextTickets() 的时候。
从下面的图中我们可以看到这两个类型的任务。
哪里定义了 InternalCallbackScope 对象:
Node.js 初始化之前,执行完用户 JS 后,进入事件循环之前。
每次从 C、C++ 层执行 JS 层回调时。
下面我们以异步读取文件为例,来看一下这个大致的流程。

当调用 readFile() 函数去读取一个文件时,那么就会陷入到 C++ 层,然后最后会陷入到C 层,然后在 C 层它完成这个文件读取之后,会回调 C++ 层,而 C++ 层要继续回调 JS 层,而在这一词层里面会执行这个 callback 回调。
如果在 callback() 里面调用了 nextTick() ,产生了一个 tick 任务的话,那么这个任务被插入到一个叫 tick 队列里面,然后接着这个 callback 执行完了之后,归回到 C++ 层,在 C++ 层里面进行一次微任务的处理,处理完了之后它才会继续事件循环。
那么 runNextTick 又是什么呢?当底层回调 JS 层时,JS 层会处理所有回调后再回到 C++ 层,这时候才有机会处理微任务。导致 callback1 回调里产生的微任务没有在下一个宏任务(callback2)执行前被处理。

在 Node.js 中以定时器为例,从下面这段代码中我们可以看到,每次调用 setTimeout() 函数后,会执行一个 runNextTicks() 的函数,进行一次微任务处理。这样的话,就能够保证在每一个 setTimeout() 回调里产生的任务能在下一个宏任务执行之前被处理掉。

欢迎报名第 63 届早早聊大会 - 低代码无代码,跟早早聊一起,学习低代码搭建,上车链接:https://www.zaozao.run/conf/c63
虽然我们有了一些底层能力,但是这些底层能力怎么给上层的 JS 使用呢?这时我们就需要 V8,这个 JS 引擎。
接下来会从三个部分来介绍一下 V8。
第一个部分会介绍一下 V8 在 Node.js 里面的作用和一些基础概念。
第二部分会介绍如何通过 V8 执行 JS 代码和拓展 JS 的能力。
第三部分会介绍如何通过 V8 实现这一层与 C++ 层的通信。
V8 在 Node.js 里面主要有两个作用。第一个作用是负责执行 JS 代码,第二个作用是提供拓展 JS 能力,作为 JS 和 C++ 层的桥梁。
接下来看一下 V8 里面一些基础的概念,也是比较核心的概念。
第一个是 Isolate 对象,它代表一个 V8 的实例,相当于一个独立的容器。比如说在 Node.js 里面,每一个线程里面都会有一个独立的 isolate 对象。
第二个是 Context,它代表一个代码执行的上下文,主要用来保存一些 V8 内置的对象,比如 object 和 function。
第三个是 ObjectTemplate,它主要用来定义一个对象的模板,可以基于这个模板创建对象。
第四个是 FunctionTemplate,用来定义一个函数的模板,可以基于这个模板创建函数。
第五个是 FunctionCallbackInfo,这个对象主要用来实现 JS 和 C++ 层的通信。
第六个是 Handle 对象,Handle 主要用于管理 V8 的堆对象。在 V8 中,像对象和数组等都是堆对象,而 Handle 则用于管理这些对象。
第七个是 HandleScope 对象,HandleScope 对象实际上是一个 Handle 的容器,它通过自己的生命周期来管理多个 Handle。

第一步,创建一个 Isolate 对象,它表示一个隔离的实例。
第二步,定义一个 HandleScope,因为我们需要在下面创建一些 Handle。
第三步,创建一个 context 对象,context 是执行代码的上下文。
第四步,定义我们需要执行的 JS 代码。
第五步,通过 V8 Script 对象的 compile() 函数来编译我们的代码,得到一个 Script 对象。
第六步,通过执行 Script 对象的 Run() 函数来执行我们的 JS 代码。



JSON 模块加载器。
用户 JSON 模块加载器。用户 JSON 模块的话就是我们自己写的一些 JS 代码。
原生 JSON 模块加载器。原生 JSON 模块的话就是 Node.js 它本身给我们提供了一些 JS 模块。
内置的 C++ 模块加载器。
Addon 模块加载器。 Addon 模块就是我们平时讲的 C++ 拓展,






通过 socket 函数创建一个 socket,并获取一个 fd。
将需要监听的地址,例如 IP 地址和端口,绑定到 df 中。
通过 listen 函数将 fd 的状态改为监听状态。














举办时间:2023 年 4 月 15 日 10:00 ~ 17:00
截至时间:2023 年 4 月 15 日 19:00
举办方式:微信群 PPT 推送 + 线上视频实时直播 + 会后资料推送
报名方式:https://www.zaozao.run/conf/c63
大会主办方:前端早早聊
