【Node 连载 4/9】深入理解 Node.js 底层原理

1,981 阅读30分钟

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

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

正文如下

大家好,我是来自字节跳动 Web Infra 团队的陈跃标。我今天分享的主题是《深入理解 Node.js 的底层原理》,今天分享的内容一共分为以下 5 个部分。

Node.js 的组成和代码架构

下面我们先来看一下 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 语言实现的。

无码有码,全靠拖拉。跟早早聊一起,学习低代码搭建,上车链接:www.zaozao.run/conf/c63

Node.js 中的 Libuv

在了解了 Node.js 的组成和代码架构之后,让我们来看看 Node.js 中一些核心实现。首先介绍一下 Libuv,这将分为三个部分。

  1. 关于 Libuv 的模型以及限制。
  2. 介绍了线程池如何解决问题以及带来的问题。
  3. 介绍了事件循环和微任务处理的内容。

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 实际上对应着调用 setTimeout(callback, delay) setInterval(callback, delay) 函数时传入的任务,在这个队列中,最快到期的节点会加入队列的最前面。

当我们调用 setTimeout(callback, delay) 时,Node.js 会通过 setTimeout 的第二个参数,找到对应在最小堆中的一个节点,然后将 setTimeout 的回调函数插入到队列的尾部。在必要的时候,Node.js 会调整 JS 层最小堆的结构,并从最小堆中选出一个最快到期的节点,然后修改底层 Libuv 的定时器节点。当底层的定时器节点到期时,它会回调上层的 JS 回调函数。在这个 JS 回调函数中,它会遍历 JS 的最小堆,找出所有已经超时的节点,并执行它们的回调函数。从图中我们也可以看到,即使在 Node.js 中存在多个定时器,实际上只有底层的 Libuv 的定时器节点被消耗掉。

check、Idol、prepare 阶段

这三个阶段的实现方式是一样的,它们都对应着一个自己的任务队列。当产生任务时,会将任务插入到相应阶段的任务队列中,并在相应的阶段时遍历任务队列,执行每个节点对应的回调函数。不过这三个阶段比较特殊的地方在于,当任务节点被消费并执行回调函数后,它会被重新插入到任务队列中。也就是说,在每一轮的事件循环中,这三个阶段的任务都会被执行。

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

类似地,pending 和 close 阶段的实现方式也是一样的,它们都维护了自己的任务队列,并在产生任务时将任务插入到队列中,在相应的阶段时遍历队列并执行每个节点对应的回调函数,并在执行完毕后将节点删除。

Pull IO 阶段

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

微任务的处理

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

在 Node.js 中,处理微任务有两种方式。

  • 第一种方式是定义一个 C++ 的 InternalCallbackScope 的对象,然后在对象析构或者主动去调用 close() 函数的时候,就会进行一次微任务的处理。
  • 第二种方式的话就是主动去调 JS 函数 runNextTickets() 的时候。

从下面的图中我们可以看到这两个类型的任务。

哪里定义了 InternalCallbackScope 对象:

  1. Node.js 初始化之前,执行完用户 JS 后,进入事件循环之前。
  2. 每次从 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 届早早聊大会 - 低代码无代码,跟早早聊一起,学习低代码搭建,上车链接:www.zaozao.run/conf/c63

Node.js 的 JS 引擎 - V8

虽然我们有了一些底层能力,但是这些底层能力怎么给上层的 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。

接下来,我们来看如何通过 V8 执行一段 JS 代码。

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

接下来,我们将看一下如何通过 V8 来扩展 JS 的功能。

  • 第一步,通过 Context 的 Global() 函数获取一个全局对象,这个对象在当前上下文中可以被访问到。
  • 第二步,通过 ObjectTemple 创建一个对象模板。
  • 第三步,为对象模板设置一个属性,属性名为 test,属性值是一个 test() 函数。
  • 第四步,通过这个对象模板新建一个对象,并将其设置为全局变量。
  • 第五步,在当前上下文中通过 demo.test() 的方式访问刚才定义的变量。

接下来,我们来看一下定义的全局变量和函数在 JS 层和 C++ 层之间是如何通信的。

在当前上下文中调用 test() 函数时,会相应地调用 C++ 层的一个 test() 函数,该函数有一个 FunctionCallbackInfo 类型的入参。

在 C++ 层,我们可以通过这个对象获取从 JS 层传来的参数,从而完成从当前层到 JS 层的通信。同样地,我们也可以通过这个对象设置相应的返回值,从而完成从 C++ 层到当前层的通信。

接下来,我们再看一下 JS 层和C、C++ 层之间的交互过程。

这里以启动一个服务器为例,来看这个流程的大致过程:

在当前层调用 net.createServer().listen() 时,将创建一个名为 server 的对象。该对象具有名为 handle 的属性,指向一个 C++ 对象。该 C++ 对象关联到一个名为 TCP_Wrap() 的 C++ 对象,该对象具有名为 handle 的属性,指向 C 语言结构体中的 fd 字段。随后,它将该监听的 fd 注册到 Epoll 中,从而启动服务器并等待连接。

当连接到达时,Epoll 会执行上层的回调函数,即 connection_cb。随后,对应到 C++ 层的 OnConnection() 函数。在该函数中,通过调用 uv_accept() 函数获取该连接对应的 fd。基于此 fd,在 C++ 层创建一个 C++ 对象,该对象关联到一个 TCP_Wrap() 对象。然后,C++ 层会回调当前层,并执行当前层的 OnConnection() 函数。在该函数中,它会创建一个新的 socket,并基于该 socket 与对端进行通信。

欢迎报名第 63 届早早聊大会 - 低代码无代码,了解如何通过低代码平台提高生产力,上车戳:www.zaozao.run/conf/c63

Node.js 的模块加载器

下面我们现在已经具备了一些底层能力,并且 V8 给我们提供了相应的接口。那么,现在我们需要考虑如何加载和执行我们的代码。这就需要使用一个模块加载器。在 Node.js 中,一共有五种模块加载器。

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

下面我们来看一下每个模块加载器的实现。

JSON 模块加载器

JSON 模块加载器的实现比较简单,主要是将对应的 JSON 文件从硬盘读取到内存中,然后通过 V8 提供的 JSONParse() 函数将其解析成一个对象,从而可以直接使用。

用户 JS 模块加载器

当通过 requeire() 函数去加载用户 JS 模块时,Node.js 会从硬盘读取该模块对应的内容,通过 V8 提供的一个叫做 CompileFunctionInContext() 的函数,将这个模块内容包装成一个函数。

需要注意的是,这里传入的 require() 函数既可以加载用户自己写的 JS 代码,也可以加载 Node.js 本身提供的 JS 代码。因此,在编写代码时,我们可以通过调用这个 require() 函数来加载我们自己的代码,也可以加载 Node.js 提供的 JS 代码。

原生 JS 模块加载器

当我们通过调用一个内置函数加载一个原生 JS 模块时,JS 模块的内容就会从内存中直接读取出来,因为原生 JS模块在默认情况下是存储在内存中的,这主要是为了提高模块加载的速度。

接着,同样通过 V8 提供的一个叫做 CompileFunctionInContext() 的函数,把模块的内容包裹成一个函数,并会创建一个 NativeModule 的对象,这个对象包含 exports 属性。在创建完这个对象之后,会把这个对象传入到函数中并执行。执行完之后,可以通过 model.exports 获取到这个模块所导出的内容。

需要注意的是,这里传入的 require() 函数与之前的不同,这里传入的是 nativeModuleRequire() 函数,该函数只能加载原生的 JS 模块。此外,这里还包含 internalBinding() 函数,该函数主要用来加载 C++ 模块,因为原生 JS 模块本质上就是对 C++ 模块的封装。

C++ 模块加载器

在 Node.js 初始化的时候,会调用 RegisterBuiltinModules() 函数来注册 C++ 模块。这个函数里面会调用一系列的 register xxx() 这样的函数。在源码中其实无法看到这些函数,宏展开后的内容就像上图所示,主要是定义了一个结构体和一个函数。当这个函数执行时,它会把这个结构体注册到一个链表中。最后,当 Node.js 初始化完成后,会形成一个 C++ 模块的链表。

当加载 C++ 模块时,会从这个 C++ 模块链表中找到对应的节点,并执行节点中的钩子函数。执行结束之后,就可以获取到这个模块里面导出的内容。

Addon 模块加载器

Addon 模块加载器本质上是一个动态链接库。当使用 require() 函数加载一个 Addon 模块时,Node.js 中会通过 dlopen() 函数来加载这个动态链接库,并执行其中的函数。上面这张图展示了在定义一个 Addon 模块时的标准格式,其中用到了一些红色部分,展开后的内容如右侧图所示。这里主要定义了一个结构体和一个函数,这个函数会将这个结构体挂载到一个全局变量中,供 Node.js 使用,从而执行其中的构造函数,例如 init。执行完毕后,就可以获取到 Addon 模块导出的内容。

欢迎报名第 63 届早早聊大会 - 低代码无代码,了解低代码的应用与实践,上车戳:www.zaozao.run/conf/c63

Node.js 的服务器架构

既然我们已经具备了底层的能力,并且有了 JS 的接口和代码加载器,接下来我们再来看一下作为服务器的 Node.js 的架构。

服务器处理 TCP 连接的模型

如何创建一个 TCP 服务器

首先,我们来看一下如何创建一个 TCP 服务器。

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

这样服务器就启动完成了,可以开始处理 HTTP 连接了。

如何处理 TCP 连接

第一种方式是单进程串行处理

在这种方式下,进程在一个循环中不断调用 accept() 函数进行连接,并通过 read() 函数和 write() 函数进行连接的处理。但是由于 accept()、 read() 和 write() 函数是阻塞式调用的,这会导致进程挂起,因此在这种方式下,同时只能处理一个连接,处理完一个连接后才能处理下一个连接。

第二种方式是多进程或多线程。

因为在单线程中调用阻塞式的 API 会导致进程阻塞,从而无法处理后续的请求。因此,在这种情况下,可以利用多个进程或多线程同时处理多个请求,这样如果一个进程阻塞,不会影响其他请求的处理。然而,这种模式的问题在于,如果请求数量非常大,流量也很大,那么进程数或线程数通常会成为整个系统的瓶颈,因为我们不能无限制地创建进程或线程。

第三种方式是单进程单线程 + 事件驱动

有两种类型,一种是 Reactor,另一种是 Proactor。

在 Reactor 中,应用层的代码可以通过事件驱动模块订阅连接的读写事件,当这些事件触发时,驱动模块会回调应用层代码,然后应用层代码会主动调用 read() 函数进行数据的读写。

然后在 Proactor 来说,应用层的代码可以通过事件驱动模块来订阅 TCP 连接的读写完成事件。一旦读写完成事件被触发,事件驱动模块会执行应用层的回调函数来处理数据。从两幅图中可以看出,这两种模式的区别在于一个订阅读写事件,而另一个订阅读写完成事件,因此数据的读写是由内核完成还是由应用层完成。显然,如果由内核完成,效率会更高。

然而,由于目前 Proactor 的兼容性较差,因此在实际使用中并不广泛。因此,目前主流的服务器,如 Node.js、Redis 等服务器,都使用了 Reactor 模式。

单进程架构下的问题

刚才提到的 Node.js 是一个单进程架构,那么在单进程架构中,如果没有多核处理器,如何利用多核呢?这时就需要引入多进程。但是引入多进程后,又会带来另一个问题,即多个进程如何解决监听同一个端口的问题?因为通常情况下,一个端口只能被一个进程监听。

第一种处理方式是主进程负责监听端口并接收请求。

当主进程接收到一个请求后,通过轮询的方式将请求传递给子进程进行处理。然而,这种模式在面对高流量时存在瓶颈,因为主进程可能会因为处理请求而占用高 CPU 使用率,而子进程却可能因为没有请求可处理而处于低负载状态。这种模式也存在一些与之相关的问题,有兴趣的同学可以参考相关的问题报告。

第二种模式是主进程通过 Fork 方式创建多个子进程,并以共享方式监听端口。

这种方式存在两个问题。首先,负载不均衡问题,因为当一个 TCP 连接到来时,操作系统会唤醒所有的子进程,然后这些子进程会以竞争的方式处理连接,导致一些进程一直处理请求,而其他进程可能没有请求可处理。其次,惊群的问题,因为当连接到来时,所有的子进程都会被唤醒,但只有一个进程能够处理该连接,这会导致其他进程被无效地唤醒,从而对系统性能造成损失。

第三种方式则通过使用过滤器表这一特性来解决之前提到的这两个问题。

在这种方式下,每个子进程都有独立的监听套接字和独立的 TCP 连接队列。当有一个 TCP 连接到来时,操作系统会将该连接插入到某个进程对应的 TCP 连接队列中,从而解决了惊群问题,因为它只会插入到某一个进程的连接队列,只会唤醒某一个进程,而不会唤醒所有子进程。另外,操作系统在分发请求时,通过在内核中实现了负载均衡的分发算法,解决了负载不均衡的问题。

Node.js 的实现和存在的问题

接下来,我们将仔细研究已知的 Node.js 关于这一部分的实现。

轮询模式的实现

在轮询模式下,Node.js 的主进程通过 fork 的方式创建多个子进程。在每个子进程中,它会调用 listen() 函数,但是这个 listen() 函数并不会监听一个端口,而是会请求主进程去监听这个端口。一旦主进程确认监听了这个端口,并且接收到一个连接后,它会通过一种叫做文件描述符传递的技术,将连接传递到子进程进行处理。

共享模式的实现

主进程还是通过 fork 的方式创建多个子进程,每个子进程中也会调用 listen() 函数。但同样,这个 listen() 函数不会监听一个端口,而是会请求主进程帮助创建一个监听的 socket。一旦主进程创建完这个监听的 socket,它会通过文件描述符传递的方式将这个监听的 socket 传递给子进程,从而实现多个子进程监听同一个端口。

文件描述符传递


刚才介绍的两种模式中都提到了文件描述符传递,下面我们来看一下文件描述符传递到底是什么?

当父进程通过 fork 的方式创建一个子进程时,这个子进程会继承父进程所打开的文件。但是有个问题是,如果在 fork 之后,主进程又打开了一个文件,那么子进程就无法感知这个新打开的文件。

那么,如何让子进程能够感知到附近层新打开的文件呢?

第一种方式是通过以下步骤实现:

首先,我们看下左边的图,假设此时父进程和子进程都已经打开了 fd0,fd1 和 fd2。接着,父进程打开了一个新的文件 a,并获得了值是 3 的 fd。如果单纯将这个值 3 传递给子进程是不行的,因为子进程无法知道这个 3 对应的文件是哪一个。这时需要通过一种叫做 Unix 域的进程间通信方式来实现。

Unix 域是唯一一种支持传递文件描述符的进程间通信方式,也是 Node.js 中使用的方式。在这种方式下,当父进程通过 Unix 域传递 fd=3 时,插入系统不仅会将 fd 传递给子进程,还会将 fd 对应的文件关联映射到子进程中。这样,才能真正地完成文件描述符的传递,即传递 fd 对应的资源或文件。

从上述的介绍中我们可以了解到,目前在 Node.js 服务架构中存在一些问题。当我们在 Node.js 中使用轮询模式时,流量过大时可能会导致主进程成为整个系统的瓶颈。而如果使用共享模式,可能会面临惊群和负载均衡的问题。

但在 Libuv 中,提供了一个稍微缓解问题的方案,即通过设置 UV_TCP_ SINGLE_ACCEPT 的环境变量。当我们设置了这个环境变量后,Node.js 进程在接收到请求时会睡眠一段时间,从而让其他进程有机会去处理请求。另外,由于系统兼容性的问题,目前 Node.js 还没有支持 SO_REUSEPORT 这种特性和模式,具体的情况大家可以参考相关资料。

欢迎报名第 63 届早早聊大会 - 低代码无代码,学习低代码的应用与实践新玩法,上车戳:www.zaozao.run/conf/c63

最后

以上就是我的全部分享内容。最后,推荐一些资料。

首先是我在研究 Node.js 源码时产出的一个仓库 github.com/theanarkh/u…,大家有兴趣的可以去看一下。

此外,推荐这两本书,《UNIX 网络编程》的卷 1 和卷 2,如果对 Node.js 的底层比较感兴趣的话可以去看一下。

另外,如果你对Node.js 有兴趣,欢迎学习《深入剖析 Node.js 底层原理》。


低代码是当今最热门的技术之一,如果你对低代码感兴趣,或者正在研究低代码,欢迎报名第 63 届早早聊大会 - 低代码无代码,跟早早聊一起,学习低码搭建。

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