探索 Node.js 内部结构
已发表: 2022-03-10自从 Ryan Dahl 于 2009 年 11 月 8 日在欧洲 JSConf 上介绍 Node.js 以来,它已经在整个科技行业得到广泛使用。 Netflix、Uber 和 LinkedIn 等公司相信 Node.js 可以承受大量流量和并发性。
掌握了基础知识后,Node.js 的初学者和中级开发人员会遇到很多事情:“这只是一个运行时!” “它有事件循环!” “Node.js 像 JavaScript 一样是单线程的!”
虽然其中一些说法是正确的,但我们将更深入地研究 Node.js 运行时,了解它如何运行 JavaScript,看看它是否真的是单线程的,最后,更好地理解它的核心依赖项、V8 和 libuv 之间的互连.
先决条件
- JavaScript 基础知识
- 熟悉 Node.js 语义(
require
,fs
)
什么是 Node.js?
假设许多人对 Node.js 的看法可能很诱人,最常见的定义是它是JavaScript 语言的运行时。 考虑到这一点,我们应该了解是什么导致了这个结论。
Node.js 通常被描述为 C++ 和 JavaScript 的组合。 C++ 部分由运行低级代码的绑定组成,使访问连接到计算机的硬件成为可能。 JavaScript 部分将 JavaScript 作为其源代码,并在该语言的流行解释器(称为 V8 引擎)中运行它。
有了这种理解,我们可以将 Node.js 描述为一种独特的工具,它结合了 JavaScript 和 C++,可以在浏览器环境之外运行程序。
但我们真的可以称它为运行时吗? 为了确定这一点,让我们定义什么是运行时。
什么是运行时? https://t.co/eaF4CoWecX
— Christian Nwamba (@codebeast) 2020 年 3 月 5 日
在他对 StackOverflow 的一个回答中,DJNA 将运行时环境定义为“执行程序所需的一切,但没有工具可以改变它”。 根据这个定义,我们可以自信地说,当我们运行我们的代码(无论使用任何语言)时发生的一切都是在运行时环境中运行的。
其他语言有自己的运行时环境。 对于 Java,它是 Java 运行时环境 (JRE)。 对于 .NET,它是公共语言运行时 (CLR)。 对于 Erlang,它是 BEAM。
然而,其中一些运行时具有依赖于它们的其他语言。 例如,Java 有 Kotlin,这是一种可以编译为 JRE 可以理解的代码的编程语言。 Erlang 有 Elixir。 我们知道 .NET 开发有许多变体,它们都在 CLR 中运行,称为 .NET Framework。
现在我们了解运行时是为程序能够成功执行提供的环境,并且我们知道 V8 和大量 C++ 库使 Node.js 应用程序可以执行。 Node.js 本身是将所有内容绑定在一起以使这些库成为实体的实际运行时,并且它只理解一种语言——JavaScript——而不管 Node.js 是用什么构建的。
Node.js 的内部结构
当我们尝试使用命令node index.js
从命令行运行 Node.js 程序(例如index.js
)时,我们正在调用 Node.js 运行时。 如前所述,此运行时包含两个独立的依赖项,V8 和 libuv。
V8 是由 Google 创建和维护的项目。 它需要 JavaScript 源代码并在浏览器环境之外运行它。 当我们通过node
命令运行程序时,源代码由 Node.js 运行时传递给 V8 执行。
libuv 库包含允许对操作系统进行低级访问的 C++ 代码。 V8 中默认不提供网络、写入文件系统和并发等功能,V8 是运行我们的 JavaScript 代码的 Node.js 的一部分。 libuv 凭借其一组库,在 Node.js 环境中提供了这些实用程序以及更多功能。
Node.js 是将两个库结合在一起的粘合剂,从而成为一个独特的解决方案。 在脚本的整个执行过程中,Node.js 了解将控制权传递给哪个项目以及何时传递。
服务器端程序的有趣 API
如果我们研究一下 JavaScript 的历史,我们就会知道它旨在为浏览器中的页面添加一些功能和交互。 在浏览器中,我们将与构成页面的文档对象模型 (DOM) 的元素进行交互。 为此,存在一组 API,统称为 DOM API。
DOM 只存在于浏览器中; 它是被解析以呈现页面的内容,它基本上是用称为 HTML 的标记语言编写的。 此外,浏览器存在于一个窗口中,因此window
对象在 JavaScript 上下文中充当页面上所有对象的根。 该环境称为浏览器环境,是 JavaScript 的运行时环境。
在 Node.js 环境中,我们没有页面,也没有浏览器——这使我们对全局窗口对象的了解无效。 我们所拥有的是一组与操作系统交互以向 JavaScript 程序提供附加功能的 API。 Node.js 的这些 API( fs
、 path
、 buffer
、 events
、 HTTP
等),正如我们所拥有的那样,仅存在于 Node.js 中,它们由 Node.js (本身是一个运行时)提供,因此我们可以运行为 Node.js 编写的程序。
实验: fs.writeFile
如何创建新文件
如果创建 V8 是为了在浏览器之外运行 JavaScript,并且如果 Node.js 环境没有与浏览器相同的上下文或环境,那么我们将如何执行诸如访问文件系统或制作 HTTP 服务器之类的事情呢?
作为示例,让我们以一个简单的 Node.js 应用程序为例,它将文件写入当前目录中的文件系统:
const fs = require("fs") fs.writeFile("./test.txt", "text");
如图所示,我们正在尝试将新文件写入文件系统。 此功能在 JavaScript 语言中不可用; 它仅在 Node.js 环境中可用。 这是如何执行的?
为了理解这一点,让我们浏览一下 Node.js 代码库。
前往 Node.js 的 GitHub 存储库,我们看到两个主要文件夹, src
和lib
。 lib
文件夹包含 JavaScript 代码,这些代码提供了每个 Node.js 安装默认包含的一组不错的模块。 src
文件夹包含 libuv 的 C++ 库。
如果我们查看lib
文件夹并浏览fs.js
文件,我们会发现它充满了令人印象深刻的 JavaScript 代码。 在第 1880 行,我们会注意到一个exports
语句。 该语句通过导入fs
模块导出我们可以访问的所有内容,我们可以看到它导出了一个名为writeFile
的函数。
搜索function writeFile(
(定义函数的地方)将我们带到第 1303 行,在那里我们看到函数定义有四个参数:
function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }
在第 1315 和 1324 行,我们看到一个函数writeAll
在经过一些验证检查后被调用。 我们在同一个fs.js
文件的第 1278 行找到了这个函数。
function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }
值得注意的是,这个模块正在尝试调用自己。 我们在第 1280 行看到了这一点,它正在调用fs.write
。 寻找write
函数,我们会发现一点信息。
write
函数从第 571 行开始,它运行了大约 42 行。 我们在这个函数中看到了一个重复的模式:它调用binding
模块上的函数的方式,如第 594 和 612 行所示。 binding
模块上的函数不仅在此函数中被调用,而且在几乎任何导出的函数中都被调用在fs.js
文件文件中。 它一定有什么特别之处。
binding
变量在文件最顶部的第 58 行声明,在 GitHub 的帮助下,单击该函数调用会显示一些信息。
这个internalBinding
函数位于名为 loaders 的模块中。 loaders模块的主要功能是加载所有libuv库,并通过V8项目与Node.js进行连接。 它是如何做到的相当神奇,但要了解更多信息,我们可以仔细查看fs
模块调用的writeBuffer
函数。
我们应该看看它与 libuv 的联系,以及 V8 的来源。在加载程序模块的顶部,一些很好的文档说明了这一点:
// This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.
我们在这里了解到的是,对于从 Node.js 项目的 JavaScript 部分中的binding
对象调用的每个模块,在src
文件夹中的 C++ 部分中都有一个等效的模块。
从我们的fs
之旅中,我们看到执行此操作的模块位于node_file.cc
中。 可以通过模块访问的每个函数都在文件中定义; 例如,我们在第 2258 行有writeBuffer
。该方法在 C++ 文件中的实际定义在第 1785 行。此外,对 libuv 进行实际写入文件的部分的调用可以在第 1809 行找到, 1815,libuv函数uv_fs_write
被异步调用。
我们从这种理解中得到什么?
就像许多其他解释语言运行时一样,Node.js 的运行时可能会被黑客入侵。 有了更深入的了解,我们可以通过查看源代码来完成标准发行版无法完成的事情。 我们可以添加库来更改某些函数的调用方式。 但最重要的是,这种理解是进一步探索的基础。
Node.js 是单线程的吗?
借助 libuv 和 V8,Node.js 可以访问一些在浏览器中运行的典型 JavaScript 引擎所没有的附加功能。
在浏览器中运行的任何 JavaScript 都将在单个线程中执行。 程序执行中的线程就像一个黑匣子,位于执行程序的 CPU 顶部。 在 Node.js 上下文中,一些代码可以在我们的机器可以承载的尽可能多的线程中执行。
为了验证这个特定的说法,让我们探索一个简单的代码片段。
const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });
在上面的代码片段中,我们试图在当前目录的磁盘上创建一个新文件。 为了查看这可能需要多长时间,我们添加了一个小基准来监控脚本的开始时间,它为我们提供了创建文件的脚本的持续时间(以毫秒为单位)。
如果我们运行上面的代码,我们会得到这样的结果:
$ node ./test.js -> 1 Done: 0.003s
这非常令人印象深刻:仅 0.003 秒。
但是让我们做一些非常有趣的事情。 首先让我们复制生成新文件的代码,并更新日志语句中的数字以反映它们的位置:
const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });
如果我们尝试运行这段代码,我们会得到一些让我们大吃一惊的东西。 这是我的结果:
首先,我们会注意到结果并不一致。 其次,我们看到时间增加了。 发生了什么?
低级任务被委派
正如我们现在所知,Node.js 是单线程的。 Node.js 的一部分是用 JavaScript 编写的,而其他部分是用 C++ 编写的。 Node.js 使用了我们在浏览器环境中熟悉的事件循环和调用堆栈的相同概念,这意味着 Node.js 的 JavaScript 部分是单线程的。 但是需要与操作系统对话的低级任务不是单线程的。
当 Node.js 将调用识别为针对 libuv 的调用时,它会将这个任务委托给 libuv。 在它的操作中,libuv 的一些库需要线程,因此在需要时使用线程池来执行 Node.js 程序。
默认情况下,libuv 提供的 Node.js 线程池中有四个线程。 我们可以通过在脚本顶部调用process.env.UV_THREADPOOL_SIZE
来增加或减少这个线程池。
// script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …
我们的文件制作程序会发生什么
看起来,一旦我们调用代码来创建我们的文件,Node.js 就会命中其代码的 libuv 部分,该部分专门为该任务分配一个线程。 libuv 中的这一部分在处理文件之前获取有关磁盘的一些统计信息。
这种统计检查可能需要一段时间才能完成; 因此,线程被释放用于一些其他任务,直到完成统计检查。 检查完成后,libuv 部分会占用任何可用线程或等待线程可用。
我们只有四个调用和四个线程,所以有足够的线程可以运行。 唯一的问题是每个线程处理其任务的速度有多快。 我们会注意到,第一个进入线程池的代码将首先返回其结果,并在运行其代码时阻塞所有其他线程。
结论
我们现在了解 Node.js 是什么。 我们知道这是一个运行时。 我们已经定义了运行时是什么。 我们已经深入挖掘了 Node.js 提供的运行时的构成要素。
我们已经走了很长一段路。 从我们对 GitHub 上的 Node.js 存储库的小游览中,我们可以探索我们可能感兴趣的任何 API,遵循我们在此处进行的相同过程。 Node.js 是开源的,所以我们当然可以深入研究源代码,不是吗?
尽管我们已经触及了 Node.js 运行时中发生的一些低级别的事情,但我们不能假设我们知道这一切。 以下资源指向我们可以建立知识的一些信息:
- Node.js 简介
作为一个官方网站,Node.dev 解释了 Node.js 是什么,以及它的包管理器,并列出了基于它构建的 Web 框架。 - “JavaScript & Node.js”, Node 初学者书籍
Manuel Kiessling 写的这本书在解释 Node.js 方面做得非常出色,在警告浏览器中的 JavaScript 与 Node.js 中的不一样之后,尽管两者都是用相同的语言编写的。 - 开始 Node.js
这本初学者书籍超越了对运行时的解释。 它教授包和流以及使用 Express 框架创建 Web 服务器。 - LibUV
这是 Node.js 运行时的支持 C++ 代码的官方文档。 - V8
这是 JavaScript 引擎的官方文档,它使使用 JavaScript 编写 Node.js 成为可能。