本文是 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,这将分为三个部分。
-
关于 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 中引入了解决方案,即引入了一个线程池。下面我们来看一下线程池和主线程的一个关系。