深度解析Node.js的架构设计

发表时间: 2020-04-28 11:42

一.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++类库,从而接入操作系统提供的平台能力

其中, 最重要的部分是 V8libuv

三.源码依赖

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_processcluster 模块

这些内置模块称为 核心模块,为迈出浏览器世界的 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.jsconst fs = require('fs')const path = require('path')const filePath = path.resolve(__filename);// Parses the buffer into a stringfunction callback (data) {  return data.toString()}// Transforms the function into a promiseconst 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#L58const binding = internalBinding('fs');// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L71const { FSReqCallback, statValues } = binding;// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L297function 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 队列),送达前台后,跑堂的喊名字,顾客过来取