关于前端:Node-连载-49深入理解-Nodejs-底层原理

3次阅读

共计 11645 个字符,预计需要花费 30 分钟才能阅读完成。

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

完整版高清 PPT 请增加小助手「zzleva」获取

注释如下

大家好,我是来自字节跳动 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 语言实现的。

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 中的事件循环。

  • 第一个阶段 – 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() 回调里产生的工作能在下一个宏工作执行之前被解决掉。

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 与对端进行通信。

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 模块导出的内容。

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 这种个性和模式,具体的状况大家能够参考相干材料。

最初

以上就是我的全副分享内容。最初,举荐一些材料。

首先是我在钻研 Node.js 源码时产出的一个仓库,大家有趣味的能够去看一下。

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

另外,如果你对 Node.js 有趣味,欢送学习《深刻分析 Node.js 底层原理》。

正文完
 0