Node系列:揭秘 Node.js 底层架构
一.Node.js 缔造的传奇
I have a job now, and this guy is the reason why I have that now. His hobby project is what I use for living. Thanks. —— Shajan Jacob
2009 年 Ryan Dahl 在JSConf EU大会上推出了 Node.js,最初是希望能够通过异步模型突破传统 Web 服务器的高并发瓶颈,之后愈渐发展成熟,应用越来越广,出现了繁荣的 Node.js 生态
借助 Node.js 走出浏览器之后,JavaScript 语言也一发不可收拾:
Any application that can be written in JavaScript, will eventually be written in JavaScript. —— Jeff Atwood
(摘自The Principle of Least Power)
早在 2017 年,NPM 就凭借茫茫多的社区模块成为了世界上最大的 package registry,目前模块数量已经超过 125 万,并且仍在快速增长中(每天新增900多个)
甚至 Node.js 工程师已经成为了一种新兴职业,那么,带有传奇色彩的 Node.js 本身是怎么实现的呢?
二.Node.js 架构概览
JS 代码跑在 V8 引擎上,Node.js 内置的fs
、http
等核心模块通过 C++ Bindings 调用 libuv、c-ares、llhttp 等 C/C++类库,从而接入操作系统提供的平台能力
其中,最重要的部分是V8和libuv
三.源码依赖
V8
V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others.
一个用 C++写的 JavaScript 引擎,由 Google 维护,用于 Chrome 浏览器和 Node.js
libuv
libuv is cross-platform support library which was originally written for Node.js. It’s designed around the event-driven asynchronous I/O model.
为 Node.js 量身打造,用 C 写的跨平台异步 I/O 库,提供了非阻塞的文件系统、DNS、网络、子进程、管道、信号、轮询和流式处理机制:
对于无法在操作系统层面异步去做的工作,通过线程池来完成,如文件 I/O、DNS 查询等,具体原因见Complexities in File I/O
P.S.线程池的容量可以配置,默认是 4 个线程,具体见Thread pool work scheduling
此外,Node.js 中的事件循环、事件队列也都是由 libuv 提供的:
Libuv provides the entire event loop functionality to NodeJS including the event queuing mechanism.
具体运作机制如下图:
其它依赖库
另外,还依赖一些 C/C++库:
llhttp:用 TypeScript 和 C 写的轻量级 HTTP 解析库,比之前的http_parser快 1.5 倍,不含任何系统调用和内存分配(也不缓存数据),因此每个请求的内存占用极小
c-ares:一个 C 库,用来处理异步的 DNS 请求,对应 Node.js 中
dns
模块提供的resolve()
系列方法OpenSSL:一个通用的加密库,多用于网络传输中的 TLS 和 SSL 协议实现,对应 Node.js 中的
tls
、crypto
模块zlib:提供快速压缩和解压支持
P.S.关于 Node.js 源码依赖的更多信息,见Dependencies
四.核心模块
像浏览器提供的 DOM/BOM API 一样,Node.js 不仅提供了 JavaScript 运行时环境,还扩展出了一系列平台 API,例如:
文件系统相关:对应fs模块
HTTP 通信:对应http模块
操作系统相关:对应os模块
多进程:对应child_process、cluster模块
这些内置模块称为核心模块,为迈出浏览器世界的 JavaScript 长上了手脚
五.C++ Bindings
在核心模块之下,有一层 C++ Bindings,将上层的 JavaScript 代码与下层 C/C++类库桥接起来
底层模块为了更好的性能,采用 C/C++实现,而上层的 JavaScript 代码无法直接与 C/C++通信,因而需要一个桥梁(即 Binding):
Bindings, as the name implies, are glue codes that “bind” one language with another so that they can talk with each other. In this case (Node.js), bindings simply expose core Node.js internal libraries written in C/C++ (c-ares, zlib, OpenSSL, llhttp, etc.) to JavaScript.
另一方面,通过 Bindings 也可以复用可靠的老牌开源类库,而不必手搓所有底层模块
以文件 I/O 为例,读取当前 JS 文件内容并输出到标准输出:
// readThisFile.js
const fs = require('fs')
const path = require('path')
const filePath = path.resolve(__filename);
// Parses the buffer into a string
function callback (data) {
return data.toString()
}
// Transforms the function into a promise
const readFileAsync = (filePath) => {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) return reject(err)
return resolve(callback(data))
})
})
}
(() => {
readFileAsync(filePath)
.then(console.log)
.catch(console.error)
})()
然而,其中用到的fs.readFile
接口既不是 V8 提供的,也不是 JS 自带的,而是由 Node.js 以 C++ Binding 的形式借助 libuv 实现的:
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L58
const binding = internalBinding('fs');
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L71
const { FSReqCallback, statValues } = binding;
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L297
function readFile(path, options, callback) {
callback = maybeCallback(callback || options);
options = getOptions(options, { flag: 'r' });
if (!ReadFileContext)
ReadFileContext = require('internal/fs/read_file_context');
const context = new ReadFileContext(callback, options.encoding);
context.isUserFd = isFd(path); // File descriptor ownership
const req = new FSReqCallback();
req.context = context;
req.oncomplete = readFileAfterOpen;
if (context.isUserFd) {
process.nextTick(function tick() {
req.oncomplete(null, path);
});
return;
}
path = getValidatedPath(path);
const flagsNumber = stringToFlags(options.flags);
binding.open(pathModule.toNamespacedPath(path),
flagsNumber,
0o666,
req);
}
最后的binding.open
是一个 C++调用,用来打开文件描述符,三个参数分别是文件路径,C++ fopen的文件访问模式串(如r
、w+
),以及八进制格式的文件读写权限(666
表示每个人都有读写权限),和接收返回数据的req
回调
其中,internalBinding
是个 C++ binding loader,internalBinding('fs')
实际加载的 C++代码位于node/src/node_file.cc
至此,关键的部分差不多都清楚了,那么,一段 Node.js 代码究竟是怎样运行的呢?
六.运行原理
首先,编写的 JavaScript 代码由 V8 引擎来运行,运行中注册的事件监听会被保留下来,在对应的事件发生时收到通知
网络、文件 I/O 等事件产生时,已注册的回调函数将排到事件队列中,接着被事件循环取出放到调用栈上,回调函数执行完(调用栈清空)之后,事件循环再取一个放上去……
执行过程中遇到 I/O 操作就交给 libuv 线程池中的某个 woker 来处理,结束之后 libuv 产生一个事件放入事件队列。事件循环处理到返回事件时,对应的回调函数才在主线程开始执行,主线程在此期间继续其它工作,而不阻塞等待
Node.js 就像一家咖啡馆,店里只有一个跑堂的(主线程),一大堆顾客涌过来的时候,会排队等候(进入事件队列),到号的顾客订单会被传给经理(libuv),经理将订单分配给咖啡师(worker 线程),咖啡师用不同的原料和工具(底层依赖的 C/C++模块)来制作订单要求的各种咖啡,一般会有 4 个咖啡师值班,高峰时候可能会增加一些。订单传给经理后,不等咖啡做出来,而是接着处理下一个订单。一杯咖啡做完之后,放到出餐流水线(IO Events 队列),送达前台后,跑堂的喊名字,顾客过来取
参考资料
Node.js Under The Hood #1 – Getting to know our tools
Original slides from Ryan Dahl’s NodeJs intro talk
An Introduction to libuv
Dependencies
Architecture of Node.js’ Internal Codebase
Event Loop and the Big Picture — NodeJS Event Loop Part 1
在线笔记
-
最后
1.看到这里了就点个在看支持下吧,你的
「点赞,在看」
是我创作的动力。