Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

《深入浅出 Node.js》读书笔记 #51

Open
JChehe opened this issue Sep 13, 2021 · 0 comments
Open

《深入浅出 Node.js》读书笔记 #51

JChehe opened this issue Sep 13, 2021 · 0 comments

Comments

@JChehe
Copy link
Owner

JChehe commented Sep 13, 2021

难得一见的好书,无论深度还是文笔。本文几乎原样摘抄书本上个人认为相对重要的段落,方便日后回顾。

第 1 章 Node 简介

Chrome 浏览器和 Node 的组件构成
Chrome 浏览器和 Node 的组件构成

除了 HTML、WebKit 和显卡这些 UI 相关技术没有支持外,Node 的结构与 Chrome 十分相似。它们都是基于事件驱动的异步架构,浏览器通过事件驱动来服务界面上的交互,Node 通过事件驱动来服务 I/O。

Node 的特点

  1. 异步 I/O

    经典的异步调用
    经典的异步调用

    在 Node 中,绝大多数的操作都以异步的方式进行调用。Ryan Dahl 排除万难,在底层构建了很多异步 I/O 的 API,从文件读取到网络请求等,均是如此。这样的意义在于,在 Node 中,我们可以从语言层面很自然地进行并行 I/O 操作。每个调用之间无须等待之前的 I/O 调用结束。在编程模型上可以极大提升效率。

  2. 事件与回调函数

  3. 单线程
    Node 保持了 JavaScript 在浏览器总单线程的特点。而且在 Node 中,JavaScript 与其余线程是无法共享任何状态的。

    单线程的好处:

    • 不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交互所带来的性能上的开销。

    单线程的弱点:

    • 无法利用多核 CPU。
    • 错误会引起整个应用退出,应用的健壮性值得考验。
    • 大量计算占用 CPU 导致无法继续调用异步 I/O。

    像浏览器中 JavaScript 与 UI 共用一个线程一样,JavaScript 长时间执行会导致 UI 的渲染和响应被中断。在 Node 中,长时间的 CPU 占用也会导致后续的异步 I/O 发不出调用,已完成的异步 I/O 的回调函数也会得不到及时执行。

    HTML5 的 Web Workers 能够创建工作线程来进行计算,以解决 JavaScript 大计算阻塞 UI 渲染的问题。工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作线程不能访问到主线程中的 UI。

    Node 采用了与 Web Workers 相同的思路来解决单线程中大计算量的问题:child_process。

    子进程的出现,意味着 Node 可以从容地应对单线程在健壮性和无法利用多核 CPU 方面的问题。通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来传递结果,这可以很好地保持应用模型的简单和低依赖。通过 Master-Worker 的管理方式,也可以很好地管理各个工作进程,以达到更高的健壮性。

  4. 跨平台

    基于 libuv 实现跨平台的架构示意图
    基于 libuv 实现跨平台的架构示意图

    兼容 Windows 和 *nix 平台主要得益于 Node 在架构层面的改动,它在操作系统与 Node 上层模块系统之间构建了一层平台架构,即 libuv。

Node 的应用场景

关于 Node,探讨得较多的主要有 I/O 密集型和 CPU 密集型。

  1. I/O 密集型

    Node 面向网络且擅长并行 I/O,能够有效地组织起更多的硬件资源,从而提供更多好的服务。

    I/O 密集的优势主要在于 Node 利用事件循环的处理能力,而不是启动每一个线程为每一个请求服务,资源占用极少。

  2. 是否不擅长 CPU 密集型业务

    Node 是足够高效的,它优秀的运算能力主要来自 V8 的深度性能优化。

    CPU 密集型应用给 Node 带来的挑战主要是:由于 JavaScript 单线程的原因,如果有长时间运行的计算(比如大循环),将会导致 CPU 时间片不能释放,使得后续 I/O 无法发起。但是适当调整和分解大型运算任务为多个小任务,使得运算能够适时释放,不阻塞 I/O 调用的发起,这样既可同时享受到并行异步 I/O 的好处,又能充分利用 CPU。

    关于 CPU 密集型应用,Node 的异步 I/O 已经解决了在单线程上 CPU 与 I/O 之间阻塞无法重叠利用的问题,I/O 阻塞造成的性能浪费远比 CPU 的影响小。对于长时间运行的计算,如果它的耗时超过普通阻塞 I/O 的耗时,那么应用场景就需要重新评估,因为这类计算比阻塞 I/O 还影响效率,甚至说就是一个纯计算的场景,根本没有 I/O。此类应用场景或许采用多线程的方式进行计算。Node 虽然没有提供多线程用于计算支持,但是还是有以下两个方式来充分利用 CPU。

    • Node 可以通过编写 C/C++ 扩展的方式更高效地利用 CPU,将一些 V8 不能做到性能极致的地方通过 C/C++ 来实现。
    • 如果单线程的 Node 不满足需求,甚至用了 C/C++ 扩展后还觉得不够,那么通过子进程的方式,将一部分 Node 进程当作常驻服务进程用于计算,然后利用进程间的消息来传递结果,将计算与 I/O 分离,这样还能充分利用多 CPU。

    CPU 密集不可怕,如何合理调度是诀窍。

第 2 章 模块机制

CommonJS 规范

社区提出的 CommonJS 规范涵盖了模块、二进制、Buffer、字符集编码、I/O 流、进程环境、文件系统、套接字、单元测试、Web 服务器网关接口、包管理等。

理论和实践总是相互影响和促进的,Node 能以一种比较成熟的姿态出现,离不开 CommonJS 规范的影响。在服务器端,CommonJS 能以一种寻常的姿态写进各个公司的项目代码中,离不开 Node 优异的表现。实现的优良表现离不开规范最初优秀的设计,规范因实现的推广而得以普及。

Node 与浏览器以及 W3C 组织、CommonJS 组织、ECMAScript 之间的关系
Node 与浏览器以及 W3C 组织、CommonJS 组织、ECMAScript 之间的关系

CommonJS 的模块规范

CommonJS 对模块的定义十分简单,主要分为模块引用、模块定义和模块标识 3 个部分。

  1. 模块引用

    模块引用的示例代码如下:

    var math = require('math');

    在 CommonJS 规范中,存在 require() 方法,这个方法接受模块标识,以此引入一个模块的 API 到当前上下文中。

  2. 模块定义

    在模块中,上下文提供 require() 方法来引入外部模块。对应引入的功能,上下文提供了 exports 对象用于导出当前模块的方法或者变量,并且它是唯一导出的出口。在模块中,还存在一个 module 对象,它代表模块自身,而 exports 是 module 的属性。在 Node 中,一个文件就是一个模块,将方法挂载在 exports 对象上作为属性即可定义导出的方式:

    // math.js
    exports.add = function () {
        var sum = 0,
          i = 0,
          args = arguments,
          l = args.length;
        while (i < l) {
          sum += args[i++];
        }
        return sum;
    }

    在另一个文件中,我们通过 require() 方法引入模块后,就能调用定义的属性或方法了:

    // program.js
    
    var math = require('math');
    exports.increment = function (val) {
      return math.add(val, 1);
    }
  3. 模块标识

    模块标识其实就是传递给 require() 方法的参数,它必须是符合小驼峰命名的字符串,或者以 ... 开头的相对路径,或者绝对路径。它可以没有文件名后缀 .js

    模块的定义十分简单,接口也十分简洁。它的意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出功能以顺畅地连接上下游依赖。如下图所示,每个模块具有独立的空间,它们互不干扰,在引用时也显得干净利落。

    模块定义
    模块定义

    CommonJS 构建的这套模块导出和导入机制使得用户完全不必考虑变量污染,命名空间等方案与之相比相形见绌。

Node 的模块实现

Node 在实现中并非完全按照规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性。

在 Node 中引入模块,需要经历如下 3 个步骤。

  • 路径分析
  • 文件定位
  • 编译执行

在 Node 中,模块分为两类:

  1. Node 提供的模块,称为核心模块。
  2. 用户编写的模块,称为文件模块。
  • 核心模块部分在 Node 源代码的编译过程中,编译进了二进制执行文件。在 Node 进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
  • 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

优先从缓存加载

Node 对引入过的模块都会进行缓存,以减少二次引入时的开销。与浏览器仅仅缓存文件相比,Node 缓存的是编译和执行之后的对象。

不论是核心模块还是文件模块,require() 方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。不同之处在于核心模块的缓存检查先于文件模块的缓存检查。

路径分析和文件定位

因为标识符有几种形式,对于不同的标识符,模块的查找和定位有不同程度上的差异。

  1. 模块标识符分析

    模块标识符在 Node 中主要分为以下几类。

    • 核心模块,如 http、fs、path 等。
    • ... 开始的相对路径文件模块。
    • / 开始的绝对路径文件模块。
    • 非路径形式的文件模块,如自定义的 connect 模块。

    核心模块

    核心模块的优先级仅次于缓存加载,它在 Node 的源代码编译过程中已经编译为二进制代码,其加载过程最快。

    如果试图加载一个与核心模块标识符相同的自定义模块,那是不会成功的。如果自己编写了一个 http 模块,想要加载成功,必须选择一个不同的标识符或者换用路径的方式。

    路径形式的文件模块

    .../ 开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require() 方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。

    由于文件模块给 Node 指明了确切的文件路径,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块。

    自定义模块

    自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。这类模块的查找是最费时的,也是所有方式中最慢的一种。

    模块路径是 Node 在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组。关于这个路径的生称规则,我们可以手动尝试一番。

    1. 创建 module_path.js 文件,其内容为 console.log(module.paths);
    2. 将其放在任意一个目录中然后执行 node module_path.js

    在 Linux 下,你可能得到的是这样一个数组输出:

    [ '/home/jackson/research/node_modules',
    '/home/jackson/node_modules',
    '/home/node_modules',
    '/node_modules' ]

    而在 Windows 下,也许是这样:

    [ 'c:\\nodejs\\node_modules', 'c:\\node_modules' ]

    可以看出,模块路径的生成规则如下所示。

    • 当前文件目录下的 node_modules 目录。
    • 父目录下的 node_modules 目录。
    • 父目录的父目录下的 node_modules 目录。
    • 沿路径向上逐级递归,直到根目录下的 node_modules 目录。

    在加载的过程中,Node 会逐个尝试模块路径中的路径,直到找到目标文件为止。可以看到,当前文件的路径越深,模块查找耗时会越多,这是自定义模块的加载速度是最慢的原因。

  2. 文件定位

    从缓存加载的优化策略使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大提高了再次加载模块时的效率。

    但在文件的定位过程中,还有一些细节需要注意,这主要包括文件扩展名的分析、目录和包的处理。

    文件扩展名分析

    require() 在分析标识符的过程中,会出现标识符中不包含文件扩展名的情况。CommonJS 模块规范也允许在标识符中不包含文件扩展名,这种情况下,Node 会按 .js、.json、.node 的次序补足扩展名,依次尝试。

    在尝试的过程中,需要调用 fs 模块同步阻塞式地判断文件是否存在。因为 Node 是单线程的,所以这里是一个会引起性能问题的地方。小诀窍是:如果是 .node 和 .json 文件,在传递给 require() 的标识符中带上扩展名,会加快一点速度。另一个诀窍是:同步配合缓存,可以大幅度缓解 Node 单线程中阻塞式调用的缺陷。

    目录分析和包

    在分析标识符的过程中,require() 通过分析文件扩展名之后,可能没有查到对应文件,但却得到一个目录,这在引入自定义模块和逐个模块路径进行查找时经常会出现,此时 Node 会将目录当作一个包来处理。

    在这个过程中,Node 对 CommonJS 包规范进行了一定程度的支持。首先,Node 在当前目录下查找 package.json(CommonJS 包规范定义的包描述文件),通过 JSON.parse() 解析包描述对象,从中取出 main 属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。

    而如果 main 属性指定的文件名错误,或者压根没有 package.json 文件,Node 会将 index 当作默认文件名,然后依次查找 index.js、index.json、index.node。

    如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。

  3. 模块编译

    在 Node 中,每个文件模块都是一个对象,它的定义如下:

    function Module (id, parent) {
      this.id = id;
      this.exports = {};
      this.parent = parent;
      if (parent && parent.children) {
        parent.children.push(this);
      }
      
      this.filename = null;
      this.loaded = false;
      this.children = [];
    }

    编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,Node 会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,其载入方法也有所不同,具体如下所示。

    • .js 文件。通过 fs 模块同步读取文件后编译执行。
    • .node 文件。这是调用 C/C++ 编写的扩展文件,通过 dlopen() 方法加载最后编译生成的文件。
    • .json 文件。通过 fs 模块同步读取文件后,用 JSON.parse() 解析返回结果。
    • 其余扩展名文件。它们都被当作 .js 文件载入。

    每一个编译成功的模块都会将其文件路径作为索引缓存在 Module._cache 对象上,以提高二次引入的性能。

    根据不同的文件扩展名,Node 会调用不同的读取方法,如 .json 文件的调用如下:

    // Native extension for .json
    Module._extensions['.json'] = function (module, filename) {
      var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
      try {
        module.exports = JSON.parse(stripBOM(content));
      } catch (err) {
        err.message = filename + ': ' + err.message;
        throw err;
      }
    };

    其中,Module._extensions 会被赋值给 require() 的 extensions 属性,所以通过在代码中访问 require.extensions 可以知道系统中已有的扩展加载方式。编写如下代码测试一下:

    console.log(require.extensions);

    得到的执行结果如下:

    { '.js': [Function], '.json': [Function], '.node': [Function] } 

    如果想对自定义的扩展名进行特殊的加载,可以通过类似 require.extensions['.ext'] 的方式实现。

    在确定文件的扩展名之后,Node 将调用具体的编译方式来将文件执行后返回给调用者。

    1. JavaScript 模块的编译

      回到 CommonJS 模块规范,我们知道每个模块文件中存在着 require、exports、module 这 3 个变量,但是它们在模块文件中没有定义,那么从何而来呢?甚至在 Node 的 API 文档中,我们知道每个模块中还有 __filename、__dirname 这两个变量的存在,它们又是从何而来的呢?如果我们把直接定义模块的过程放诸在浏览器端,会存在污染全局变量的情况。

      事实上,在编译的过程中,Node 对获取的 JavaScript 文件内容进行了头尾包装。在头部添加了 (function (exports, require, module, __filename, __dirname) {\n,在尾部添加了 \n})。一个正常的 JavaScript 文件会被包装成如下的样子:

      (function (exports, require, module, __filename, __dirname) {
        var math = require('math');
        exports.area = function (radius) {
          return Math.PI * radius * radius;
        };
      });

      这样每个模块文件之间都进行了作用域隔离。包装之后的代码会通过 vm 原生模块的 runInThisContext() 方法执行(类似 eval,只是具有明确上下文,不污染全局),返回一个具体的 function 对象。最后,将当前模块对象的 exports 属性、require() 方法、module(模块对象自身),以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个 function() 执行。

      这就是这些变量并没有定义在每个模块文件中却存在的原因。在执行之后,模块的 exports 属性被返回给了调用方。exports 属性上的任何方法和属性都可以被外部调用到,但是模块中的其余变量或属性则不可直接被调用。

      至此,require、exports、module 的流程已经完整,这就是 Node 对 CommonJS 模块规范的实现。

      此外,许多初学者都曾经纠结过为何存在 exports 的情况下,还存在 module.exports。理想情况下,只要赋值给 exports 即可:

      exports = function () {
        // My Class
      };

      但是通过会得到一个失败的结果。其原因在于,exports 对象是通过形参的方式传入的,直接赋值形参会改变形参的引用,但并不能改变作用域外的值。测试代码如下:

      var change = function (a) {
        a = { a: 2 };
        console.log(a); // => {a: 2}
      }
      
      var a = { a: 1 };
      change(a);
      console.log(a); // => {a: 1}

      如果要达到 require 引入一个类的效果,请赋值给 module.exports 对象。这个迂回的方案不改变形参的引用。

    2. C/C++ 模块的编译

      Node 调用 process.dlopen() 方法进行加载和执行。在 Node 的架构下,dlopen() 方法在 Windows 和 *nix 平台下分别有不同的实现,通过 libuv 兼容层进行了封装。

      实际上,.node 的模块文件并不需要编译,因为它是编写 C/C++ 模块之后编译生成的,所以这里只有加载和执行的过程。在执行的过程中,模块的 exports 对象与 .node 模块产生联系,然后返回给调用者。

      C/C++ 模块给 Node 使用者带来的优势主要是执行效率方面的,劣势则是 C/C++ 模块的编写门槛比 JavaScript 高。

    3. JSON 文件的编译

      .json 文件的编译是 3 种编译方式中最简单的。Node 利用 fs 模块同步读取 JSON 文件的内容之后,调用 JSON.parse() 方法得到对象,然后将它赋给模块对象的 exports,以供外部调用。

      JSON 文件在用作项目的配置文件时比较有用。如果你定义了一个 JSON 文件作为配置,那就不必调用 fs 模块去异步读取和解析,直接调用 require() 引入即可。此外,还可以享受到模块缓存的便利,并且二次引入时也没有性能影响。

    这里我们提到的模块编译都是指文件模块,即用户自己编写的模块。

核心模块

Node 的核心模块在编译成可执行文件的过程中被编译进了二进制文件。核心模块其实分为 C/C++ 编写的和 JavaScript 编写的两部分,其中 C/C++ 文件存放在 Node 项目的 src 目录下,JavaScript 文件存放在 lib 目录下。

JavaScript 核心模块的编译过程

在编译所有 C/C++ 文件之前,编译程序需要将所有的 JavaScript 模块文件编译为 C/C++ 代码,此时是否直接将其编译为可执行代码了呢?其实不是。

  1. 转存为 C/C++ 代码

    Node 采用了 V8 附带的 js2c.py 工具,将所有内置的 JavaScript 代码(src/node.js 和 lib/*.js)转换成 C++ 里的数组,生成 node_natives.h 头文件,相关代码如下:

    namespace node {
      const char node_native[] = { 47, 47, ..};
      const char dgram_native[] = { 47, 47, ..};
      const char console_native[] = { 47, 47, ..};
      const char buffer_native[] = { 47, 47, ..};
      const char querystring_native[] = { 47, 47, ..};
      const char punycode_native[] = { 47, 42, ..};
      ...
      struct _native {
        const char* name;
        const char* source;
        size_t source_len;
      };
      static const struct _native natives[] = {
        { "node", node_native, sizeof(node_native)-1 },
        { "dgram", dgram_native, sizeof(dgram_native)-1 },
        ...
      };
    } 

    在这个过程中,JavaScript 代码以字符串的形式存储在 node 命名空间中,是不可直接执行的。在启动 Node 进程时,JavaScript 代码直接加载进内存中。在加载的过程中,JavaScript 核心模块经历标识符分析后直接定位到内存中,比普通的文件模块从磁盘中一处一处查找要快很多。

  2. 编译 JavaScript 核心模块

    lib 目录下的所有模块文件也没有定义 require、module、exports 这些变量。在引入 JavaScript 核心模块的过程中,也经历了头尾包装的过程,然后才执行和导出了 exports 对象。与文件模块有区别的地方在于:获取源代码的方式(核心模块是从内存中加载的)以及缓存执行结果的位置。

    JavaScript 核心模块的定义如下面的代码所示,源文件通过 process.binding('natives') 取出,编译成功的模块缓存到 NativeModule._cache 对象上,文件模块则缓存到 Module._cache 对象上:

    function NativeModule(id) {
      this.filename = id + '.js';
      this.id = id;
      this.exports = {};
      this.loaded = false;
    }
    NativeModule._source = process.binding('natives');
    NativeModule._cache = {}; 

C/C++ 核心模块的编译过程

在核心模块中,有些模块全部由 C/C++ 编写,有些模块则由 C/C++ 完成核心部分,其他部分则由 JavaScript 实现包装或向外导出,以满足性能需求。后者这种 C++ 模块主内完成核心,JavaScript 主外实现封装的模块是 Node 能够提高性能的常见方式。通常,脚本语言的开发速度优于静态语言,但是其性能则弱于静态语言。而 Node 的这种复合模块可以在开发速度和性能之间找到平衡点。

这里我们将那些由纯 C/C++ 编写的部分统一称为内建模块,因为它们通常不被用户直接调用。Node 的 buffer、crypto、evals、fs、os 等模块都是部分通过 C/C++ 编写的。

在 Node 的所有模块类型中,存在如下图所示的一种依赖层级关系,即文件模块可能会依赖核心模块,核心模块可能会依赖内建模块。

依赖层级关系
依赖层级关系

通常,不推荐文件模块直接调用内建模块。如需调用,直接调用核心模块即可,因为核心模块中基本都封装了内建模块。

核心模块的引入流程

如下图所示的 os 原生模块的引入流程可以看到,为了符合 CommonJS 模块规范,从 JavaScript 到 C/C++ 的过程是相当复杂的,它要经历 C/C++ 层面的内建模块定义、(JavaScript)核心模块的定义和引入以及(JavaScript)文件模块层面的引入。但是对于用户而言,require() 十分简洁、友好。

os 原生模块的引入流程
os 原生模块的引入流程

C/C++ 扩展模块

JavaScript 的一个典型弱点就是位运算。JavaScript 的位运算参照 Java 的位运算实现,但是 Java 位运算是在 int 型数字的基础上进行的,而 JavaScript 中只有 double 型的数据类型,在进行位运算的过程中,需要将 double 类型转为 int 型,然后再进行。所以在 JavaScript 层面上做位运算的效率不高。

在应用中,会频繁出现位运算的需求,包括转码、编码等过程,如果通过 JavaScript 来实现,CPU 资源将会耗费很多,这时编写 C/C++ 扩展模块来提升性能的机会来了。

C/C++ 扩展模块属于文件模块中的一类。

C/C++ 扩展模块与 JavaScript 模块的区別在于加载之后不需要编译,直接执行之后就可以被外部调用了,其加载速度比 JavaScript 模块略快。

使用 C/C++ 扩展模块的一个好处在于可以更灵活和动态地加载它们,保持 Node 模块自身简单性的同时,给予 Node 无限的可扩展性。

模块调用栈

C/C++ 内建模块属于最底层的模块,它属于核心模块,主要提供 API 给 JavaScript 核心模块和第三方 JavaScript 文件模块调用。

JavaScript 核心模块主要扮演的职责有两类:

  • 作为 C/C++ 内建模块的封装层和桥接层,供文件模块调用。
  • 纯粹的功能模块,它不需要和底层打交道,但又十分重要。

模块之间的调用关系
模块之间的调用关系

包与 NPM

Node 对模块规范的实现,一定程度上解决了变量依赖、依赖关系等代码组织性问题。包的出现,则是在模块的基础上进一步组织 JavaScript 代码。下图为包组织模块示意图。

包组织模块示意图
包组织模块示意图

CommonJS 的包规范的定义其实十分简单,它由包结构和包描述文件两个部分组成,前者组织包中的各种文件,后者则用于描述包的相关信息,以供外部读取分析。

前后端共用模块

AMD 规范

AMD 规范是 CommonJS 模块规范的一个延伸,它的模块定义如下:

define(id?, dependencies?, factory); 

它的模块 id 和依赖是可选的,与 Node 模块相似的地方在于 factory 的内容就是实际代码的内容。下面的代码定义了一个简单的模块:

define(function() {
  var exports = {};
  exports.sayHello = function() {
    alert('Hello from module: ' + module.id);
  };
  return exports;
}); 

不同之处在于 AMD 模块需要用 define 来明确定义一个模块,而 Node 实现中是隐式包装的。另一个区别则是内容需要通过返回的方式实现导出。

CMD 规范

与 AMD 规范的主要区别在于定义模块和依赖引入的部分。AMD 需要在声明模块时指定所有的依赖,通过形参传递依赖到模块内容中:

define(['dep1', 'dep2'], function (dep1, dep2) {
  return function () {};
}); 

与 AMD 模块规范相比,CMD 模块更接近于 Node 对 CommonJS 规范的定义:

define(factory);

在依赖部分,CMD 支持动态引入,示例如下:

define(function(require, exports, module) {
  // The module code goes here
}); 

require、exports 和 module 通过形参传递给模块,在需要依赖模块时,随时调用 require() 引入即可。

兼容多种模块规范

为了保持前后端的一致性,类库开发者需要将类库代码包装在一个闭包内。以下代码演示如何将 hello() 方法定义到不同的运行环境中,它能够兼容 Node、AMD、CMD 以及常见的浏览器环境中:

;(function (name, definition) {
  // 检测上下文环境是否为 AMD 或 CMD
  var hasDefine = typeof define === 'function',
  // 检查上下文环境是否为 Node
  hasExports = typeof module !== 'undefined' && module.exports;
  if (hasDefine) {
    // AMD 环境 或 CMD 环境
    define(definition);
  } else if (hasExports) {
    // 定义为普通 Node 模块
    module.exports = definition();
  } else {
    // 将模块的执行结果挂在 window 变量中,在浏览器中 this 指向 window 对象
    this[name] = definition();
  }
})('hello', function () {
  var hello = function () {};
  return hello;
}); 

第 3 章 异步 I/O

不同的 I/O 类型及其对应的开销
不同的 I/O 类型及其对应的开销

I/O 是昂贵的,分布式 I/O 是更昂贵的。

Node 利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,以更好地使用 CPU。

为弥补单线程无法利用多核 CPU 的缺点,Node 提供了子进程,该子进程可以通过工作进程高效地利用 CPU 和 I/O。

异步 I/O 实现现状

异步 I/O 与非阻塞 I/O

从计算机内核 I/O 而言,异步/同步和阻塞/非阻塞实际上是两回事。

操作系统内核对于 I/O 只有两种方式:阻塞与非阻塞。

阻塞 I/O 的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。以读取硬盘上的一段文件为例,系统内核在完成磁盘寻道、读取数据、复制数据到内存中之后,这个调用才结束。

调用阻塞 I/O 的过程
调用阻塞 I/O 的过程

阻塞 I/O 造成 CPU 等待 I/O,浪费等待时间,CPU 的处理能力不能得到充分利用。为了提高性能,内核提供了非阻塞 I/O。非阻塞 I/O 跟阻塞 I/O 的差别为调用之后会立即返回。

操作系统对计算机进行了抽象,将所有输入输出设备抽象为文件。内核在进行文件 I/O 操作时,通过文件描述符进行管理,而文件描述符类似于应用程序与系统内核之间的凭证。应用程序如果需要进行 I/O 调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写。此处非阻塞 I/O 与阻塞 I/O 的区别在于阻塞 I/O 完成整个获取数据的过程,而非阻塞 I/O 则不带数据直接返回,要获取数据,还需要通过文件描述符再次读取。

调用非阻塞 I/O 的过程
调用非阻塞 I/O 的过程

非阻塞 I/O 返回之后,CPU 的时间片可以用来处理其他事务,此时的性能提升是明显的。

但非阻塞 I/O 也存在一些问题。由于完整的 I/O 并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用 I/O 操作来确认是否完成。这种重复调用判断操作是否完成的技术叫轮询

阻塞 I/O 造成 CPU 等待浪费,非阻塞带来的麻烦却是需要轮询去确认是否完全完成数据获取,它会让 CPU 处理状态判断,是对 CPU 资源的浪费。这里我们且看轮询技术是如何演进的,以减少 I/O 状态判断的 CPU 损耗。

现存的轮询技术主要有以下这些。

  • read。它是最原始、性能最低的一种,通过重复调用来检查 I/O 的状态来完成完整数据的读取。在得到最终数据前,CPU 一直耗用在等待上。

通过 read 进行轮询的示意图
通过 read 进行轮询的示意图

  • select。它是在 read 的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断。

通过 select 进行轮询的示意图
通过 select 进行轮询的示意图

select 轮询具有一个较弱的限制,那就是由于它采用一个 1024 长度的数组来存储状态,所以它最多可以同时检查 1024 个文件描述符。

  • poll。该方案较 select 有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查。但是当文件描述符较多的时候,它的性能还是十分低下的。它与 select 相似,但性能限制有所改善。

通过 poll 实现轮询的示意图
通过 poll 实现轮询的示意图

  • epoll。该方案是 Linux 下效率最高的 I/O 事件通知机制,在进入轮询的时候如果没有检查到 I/O 事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费 CPU,执行效率较高。

通过 epoll 方式实现轮询的示意图
通过 epoll 方式实现轮询的示意图

  • kqueue。该方案的实现方式与 epoll 类似,不过它仅在 FreeBSD 系统下存在。

轮询技术满足了非阻塞 I/O 确保获取完整数据的需求,但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要等待 I/O 完全返回,依旧花费了很多时间来等待。等待期间,CPU 要么用于遍历文件描述符的状态,要么用于休眠等待事件发生。结论是它不够好。

理想的非阻塞异步 I/O

尽管 epoll 已经利用了事件来降低 CPU 的耗用,但是休眠期间 CPU 几乎是闲置的,对于当前线程而言利用率不够。

我们期望的完美的异步 I/O 应该是应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在 I/O 完成后通过信号或回调将数据传递给应用程序即可。下图为理想中的异步 I/O 示意图。

理想中的异步 I/O 示意图
理想中的异步 I/O 示意图

幸运的是,在 Linux 下存在这样一种方式,它原生提供的一种异步 I/O 方式(AIO)就是通过信号或回调来传递数据的。但不幸的是,只有 Linux 下有,而且它还有缺陷——AIO 仅支持内核 I/O 中的 0_DIRECT 方式读取,导致无法利用系统缓存。

现实的异步 I/O

现实比理想要骨感一些,但是要达成异步 I/O 的目标,并非难事。前面我们将场景限定在了单线程的状况下,多线程的方式会是另一番风景。通过让部分线程进行阻塞 I/O 或者非阻塞 I/O 加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将 I/O 得到的数据进行传递,这就轻松实现了异步 I/O(尽管它是模拟的),如下图所示。

异步 I/O
异步 I/O

glibc 的 AIO 便是典型的线程池模拟异步 I/O。然而遗憾的是,它存在一些难以忍受的缺陷和 bug,不推荐采用。libev 的作者 Marc Alexander Lehmann 重新实现了一个异步 I/O 的库: libeio。libeio 实质上依然是采用线程池与阻塞 I/O 模拟异步 I/O。最初,Node 在 *nix 平台下采用了 libeio 配合 libev
实现 I/O 部分,实现了异步 I/O。在 Node v0.9.3 中,自行实现了线程池来完成异步 I/O。

另一种我迟迟没有透露的异步 I/O 方案则是 Windows 下的 IOCP,它在某种程度上提供了理想的异步 I/O:调用异步方法,等待 I/O 完成之后的通知,执行回调,用户无须考虑轮询。但是它的内部其实仍然是线程池原理,不同之处在于这些线程池由系统内核接手管理。

IOCP 的异步 I/O 模型与 Node 的异步调用模型十分近似。在 Windows 平台下采用了 IOCP 实现异步 I/O。

由于 Windows 平台和 *nix 平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,并保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间会判断平台条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中,其架构如下图所示。

基于 libuv 的构架示意图
基于 libuv 的构架示意图

需要强调一点的是,这里的 *I/O 不仅仅只限于磁盘文件的读写。nix 将计算机抽象了一番,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为了文件,因此这里描述的阻塞和非阻塞的情况同样能适用于套接字等。

另一个需要强调的地方在于我们时常提到 *Node 是单线程的,这里的单线程仅仅只是 JavaScript 执行在单线程中罢了。在 Node 中,无论是 nix 还是 Windows 平台,内部完成 I/O 任务的另有线程池。

Node 的异步 I/O

介绍完系统对异步 I/O 的支持后,我们将继续介绍 Node 是如何实现异步 I/O 的。这里我们除了介绍异步 I/O 的实现外,还将讨论 Node 的执行模型。完成整个异步 I/O 环节的有事件循环、观察者和请求对象等。

事件循环

首先,我们着重强调一下 Node 自身的执行模型——事件循环,正是它使得回调函数十分普遍。

在进程启动时,Node 便会创建一个类似于 while(true) 的循环,每执行一次循环体的过程我们称为 Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下个循环,如果不再有事件处理,就退出进程。流程图如下图所示。

Tick 流程图
Tick 流程图

观察者

在每个 Tick 的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者

每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。

浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件对应的观察者有文件 I/O 观察者、网络 I/O 观察者等。观察者将事件进行了分类。

事件循环是一个典型的生产者/消费者模型。异步 I/O、网络请求等则是事件的生产者,源源不断为 Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

在 Windows 下,这个循环基于 IOCP 创建,而在 *nix 下则基于多线程创建。

请求对象

在这一节中,我们将通过解释 Windows 下异步 I/O(利用 IOCP 实现)的简单例子来探寻从 JavaScript 代码到系统内核之间都发生了什么。

对于一般的(非异步)回调函数,函数由我们自行调用,如下所示:

var forEach = function (list, callback) {
  for (var i = 0; i < list.length; i++) {
    callback(list[i], i, list);
  }
};

对于 Node 中的异步 I/O 调用而言,回调函数却不由开发者来调用。那么从我们发出调用后,到回调函数被执行,中间发生了什么呢?事实上,从 JavaScript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,它叫做请求对象

下面我们以最简单的 fs.open() 方法来作为例子,探素 Node 与底层之间是如何执行异步 I/O 调用以及回调函数究竟是如何被调用执行的:

fs.open = function(path, flags, mode, callback) {
  // ...
  binding.open(pathModule._makeLong(path),
                   stringToFlags(flags),
                   mode,
                   callback);
};

fs.open() 的作用是根据指定路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有 I/O 操作的初始操作。从前面的代码中可以看到,JavaScript 层面的代码通过调用 C++ 核心模块进行下层的操作。下图为调用示意图。

调用示意图
调用示意图

从 JavaScript 调用 Node 的核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用,这是 Node 里经典的调用方式。这里 libuv 作为封装层,有两个平台的实现,实质上是调用了 uv_fs_open() 方法。在 uv_fs_open() 的调用过程中,我们创建了一个 FSReqWrap 请求对象。从 JavaScript 层传入的参数和当前方法都被封装在这个请求对象中,其中我们最为关注的回调函数则被设置在这个对象的 uncomplete_sym 属性上:

req_wrap->object_->Set(oncomplete_sym, callback);

对象包装完毕后,在 Windows 下,则调用 QueueUserWorkItem() 方法将这个 FSReqWrap 对象推入线程池中等待执行,该方法的代码如下所示:

QueueUserWorkItem(&uv_fs_thread_proc, \
                         req, \
                         WT_EXECUTEDEFAULT) 

QueueUserWorkItem() 方法接受 3 个参数:第一个参数是将要执行的方法的引用,这里引用的是 uv_fs_thread_proc,第二个参数是 uv_fs_thread_proc 方法运行时所需要的参数;第三个参数是执行的标志。当线程池中有可用线程时,我们会调用 uv_fs_thread_proc() 方法。uv_fs_thread_proc() 方法会根据传入参数的类型调用相应的底层函数。以 uv_fs_open() 为例,实际上调用 fs_open() 方法。

至此,JavaScript 调用立即这回,由 JavaScript 层面发起的异步调用的第一阶段就此结束。JavaScript 线程可以继续执行当前任务的后续操作。当前的 I/O 操作在线程池中等待执行,不管它是否阻塞 I/O,都不会影响到 JavaScript 线程的后续执行,如此就达到了异步的目的。

请求对象是异步 I/O 过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及 I/O 操作完毕后的回调处理。

执行回调

组装好请求对象、送入 I/O 线程池等待执行,实际上完成了异步 I/O 的第一部分,回调通知是第二部分。

线程池中的 I/O 操作调用完毕之后,会将获取的结果储存在 req->result 属性上,然后调用 PostQueuedCompletionStatus() 通知 IOCP,告知当前对象操作已经完成:

PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))

PostQueuedCompletionStatus() 方法的作用是向 IOCP 提交执行状态,并将线程归还线程池。通过 PostQueuedCompletionStatus() 方法提交的状态,可以通过 GetQueuedCompletionStatus() 提取。

在这个过程中,我们其实还动用了事件循环的 I/O 观察者。在每次 Tick 的执行中,它会调用 IOCP 相关的 GetQueuedCompletionStatus() 方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到 I/O 观察者的队列中,然后将其当做事件处理。

I/O 观察者回调函数的行为就是取出请求对象的 result 属性作为参数,取出 oncomplete_sym 属性作为方法,然后调用执行,以此达到调用 JavaScript 中传入的回调函数的目的。

至此,整个异步 I/O 的流程完全结東,如下图所示。

整个异步 I/O 的流程
整个异步 I/O 的流程

事件循环、观察者、请求对象、I/O 线程池这四者共同构成了 Node 异步 I/O 模型的基本要素。

Windows 下主要通过 IOCP 来向系统内核发送 I/O 调用和从内核获取已完成的 I/O 操作,配以事件循环,以此完成异步 I/O 的过程。在 Linux 下通过 epoll 实现这个过程,FreeBSD 下通过 kqueue 实现,Solaris 下通过 Event ports 实现。不同的是线程池在 Windows 下由内核(IOCP)直接提供,*nix 系列下由 libuv 自行实现。

小结

从前面实现异步 I/O 的过程描述中,我们可以提取出异步 I/O 的几个关键词:单线程、事件循环、观察者和 I/O 线程池。这里单线程与 I/O 线程池之间看起来有些悖论的样子。由于我们知道 JavaScript 是单线程的,所以按常识很容易理解为它不能充分利用多核 CPU。事实上,在 Node 中除了 JavaScript 是单线程外,Node 自身其实是多线程的,只是 I/O 线程使用的 CPU 较少。另一个需要重视的观点则是,除了用户代码无法并行执行外,所有的 I/O(磁盘 I/O 和网络 I/O 等)则是可以并行起来的。

非 I/O 的异步 API

除了异步 I/O,Node 中还存在一些与 I/O 无关的异步 API,它们分别是:

  • setTimeout()
  • setInterval()
  • setImmediate()
  • process.nextTick()

定时器

setTimeout() 和 setInterval() 与浏览器中的 API 是一致的,分别用于单次和多次定时执行任务。它们的实现原理与异步 I/O 比较类似,只是不需要 I/O 线程池的参与。调用 setTimeout() 或者 setInterval() 创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次Tick 执行时,会从该红黑树中迭代取出定时器对象,检査是否超过定时时间,如果超过,就形成一个事件,它的回调函数将立即执行。

下图提到的主要是 setTimeout() 的行为。setInterval() 与之相同,区别在于后者是重复性的检测和执行。

setTimeout() 的行为
setTimeout() 的行为

定时器的问题在于,它并非精确的(在容忍范围内)。尽管事件循环十分快,但是如果某一次循环占用的时间较多,那么下次循环时,它也许已经超时很久了。譬如通过 setTimeout() 设定一个任务在 10 毫秒后执行,但是在 9 毫秒后,有一个任务占用了 5 毫秒的 CPU 时间片,再次轮到定时器执行时,时间就已经过期 4 毫秒。

process.nextTick()

在未了解 process.nextTick() 之前,很多人也许为了立即异步执行一个任务,会这样调用 setTimeout() 来达到所需的效果:

setTimeout(function () {
  // TODO
}, 0);

由于事件循环自身的特点,定时器的精确度不够。而事实上,采用定时器需要动用红黑树创建定时器对象和迭代等操作,而 setTimeout(fn,0) 的方式较为浪费性能。实际上,process.nextTick() 方法的操作相对较为轻量,具体代码如下:

process.nextTick = function(callback) {
  // on the way out, don't bother.
  // it won't get fired anyway
  if (process._exiting) return;
  if (tickDepth >= process.maxTickDepth)
    maxTickWarn();

  var tock = { callback: callback };
  if (process.domain) tock.domain = process.domain;
  nextTickQueue.push(tock);
  if (nextTickQueue.length) {
    process._needTickCallback();
  }
}; 

每次调用 process.nextTick() 方法,只会将回调函数放入队列中,在下一轮 Tick 时取出执行。定时器中采用红黑树的操作时间复杂度为 O(lg(n)),nextTick() 的时间复杂度为 O(1)。相较之下,process.nextTick() 更高效。

setImmediate()

setImmediate() 方法与 process.nextTick() 方法十分类似,都是将回调函数延迟执行。该方法的代码如下所示:

process.nextTick(function () {
  console.log('延迟执行');
});
console.log('正常执行');

上述代码的输出结果如下:

正常执行
廷迟执行

而用 setImmediate() 实现时,相关代码如下:

setImmediate(function () {
  console.log('延迟执行');
});
console.log('正常执行'); 

其结果完全一样:

正常执行
廷迟执行

但是两者之间其实是有细微差别的。将它们放在一起时,又会是怎样的优先级呢。示例代码如下:

process.nextTick(function () {
  console.log('nextTick延迟执行');
});
setImmediate(function () {
  console.log('setImmediate延迟执行');
});
console.log('正常执行');

其结果如下:

正常执行
nextTick延迟执行
setImmediate延迟执行

从结果里可以看到,process.nextTick() 中的回调函数执行的优先级要高于 setImmediate()。这里的原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick() 属于 idle 观察者,setImmediate() 属于 check 观察者。在每一个轮循环检査中,idle 观察者先于 I/O 观察者,I/O 观察者先于 check 观察者。

在具体实现上,**process.nextTick() 的回调函数保存在一个数组中,setImmediate() 的结果则是保存在链表中。在行为上,process.nextTick() 在每轮循环中会将数组中的回调函数全部执行完,而 setImmediate() 在每轮循环中执行链表中的一个回调函数。**如下的示例代码可以佐证:

// 加入两个nextTick()的回调函数
process.nextTick(function () {
  console.log('nextTick延迟执行1');
});
process.nextTick(function () {
  console.log('nextTick延迟执行2');
});
// 加入两个setImmediate()的回调函数
setImmediate(function () {
  console.log('setImmediate延迟执行1');
  // 进入下次循环
  process.nextTick(function () {
    console.log('强势插入');
  });
});
setImmediate(function () {
  console.log('setImmediate延迟执行2');
});
console.log('正常执行');

其执行结果如下:

正常执行
nextTick廷迟执行1
nextTick廷迟执行2
setImmediate廷迟执行1
强势插入
setImmediate延迟执行2

从执行结果上可以看出,当第一个 setImmediate() 的回调函数执行后,并没有立即执行第二个,而是进入了下一轮循环,再次按 process.nextTick() 优先、setImmediate() 次后的顺序执行。之所以这样设计,是为了保证每轮循环能够较快地执行结束,防止 CPU 占用过多而阻塞后续 I/O 调用的情况。

建议对 CPU 的耗用不要超过 10ms,或者将大量的计算分解为诸多的小量计算,通过 setImmediate() 进行调度。

笔者补充来自网络更复杂的案例:

setImmediate(() => {
  console.log(1);
  setTimeout(() => {
    console.log(2);
  }, 100);
  setImmediate(() => {
    console.log(3);
  });
  process.nextTick(() => {
    console.log(4);
  });
});

process.nextTick(() => {
  console.log(5);

  setTimeout(() => {
    console.log(6);
  }, 100);

  setImmediate(() => {
    console.log(7);
  });
  process.nextTick(() => {
    console.log(8);
  });
});

console.log(9);
// 9 5 8 1 4 7 3 6 2

事件驱动与高性能服务器

前面主要介绍了异步的实现原理,在这个过程中,我们也基本勾勒出了事件驱动的实质,即通过主循环加事件触发的方式来运行程序。

尽管本章只用了 fs.open() 方法作为例子来阐述 Node 如何实现异步 I/O。而实质上,异步 I/O 不仅仅应用在文件操作中。对于网络套接字的处理,Node 也应用到了异步 I/O,网络套接字上侦听到的请求都会形成事件交给 I/O 观察者。事件循环会不停地处理这些网络 I/O 事件。如果 JavaScript 有传入回调函数,这些事件将会最终传递到业务逻辑层进行处理。利用 Node 构建 Web 服务器,正是在这样一个基础上实现的,其流程图如下图所示。

利用 Node 构建 Web 服务器的流程图
利用 Node 构建 Web 服务器的流程图

第 4 章 异步编程

本章主要讲解 JavaScript 异步编程中遇到的难题,并介绍了当时主流的几种异步编程解决方案。至今已有 async/await 这种更优雅的解决方案,故本章不记录异步编程解决方案。

异步并发控制

在 Node 中,我们可以十分方便地利用异步发起并行调用。使用下面的代码,我们可以轻松发起 100 次异步调用:

for (var i = 0, i < 100; i++) {
  async();
}

但是如果并发量过大,我们的下层服务器将会吃不消。如果是对文件系统进行大量并发调用,操作系统的文件描述符数量将会被瞬间用光,抛出如下错误:

Error: EMFILE, too many open files

可以看出,异步 I/O 与同步 I/O 的显著差距:同步 I/O 因为每个 I/O 都是彼此阻塞的,在循环体中,总是一个接着一个调用,不会出现耗用文件描述符太多的情况,同时性能也是低下的;对于异步 I/O,虽然并发容易实现,但是由于太容易实现,依然需要控制。换言之,尽管是要压榨底层系统的性能,但还是需要给予一定的过载保护,以防止过犹不及。

bagpipe 的解决方案

bagpipe 的解决思路:

  • 通过一个队列来控制并发量。
  • 如果当前活跃(指调用发起但未执行回调)的异步调用量小于限定值,从队列中取出执行。
  • 如果活跃调用达到限定值,调用暂时存放在队列中。
  • 每个异步调用结束时,从队列中取出新的异步调用执行。

async 的解决方案

async 的 parallelLimit()。

第 5 章 内存控制

V8 的垃圾回收机制与内存限制

Node 在 JavaScript 的执行上直接受益于 V8,可以随着 V8 的升级就能享受到更好的性能或新的语言特性(如 ES5 和 ES6)等,同时也受到 V8 的一些限制,尤其是本章要重点讨论的内存限制。

V8 的内存限制

在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在 Node 中通过 JavaScript 使用内存时就会发现只能使用部分内存(64位系统下约为 1.4GB【1464MB】,32位系统下约为 0.7GB【732MB】)。在这样的限制下,将会导致 Node 无法直接操作大内存对象,比如无法将一个 2GB 的文件读入内存中进行字符串分析处理,即使物理内存有 32GB。这样在单个 Node 进程的情况下,计算机的内存资源无法得到充足的使用。

造成这个问题的主要原因在于 Node 基于 V8 构建,所以在 Node 中使用的 JavaScript,对象基本上都是通过 V8 自己的方式来进行分配和管理的。

V8 的对象分配

在 V8 中,所有的 JavaScript 对象都是通过堆来进行分配的。Node 提供了 V8 中内存使用量的查看方式,执行下面的代码,将得到输出的内存信息:

$ node
> process.memoryUsage();
{ rss: 14958592,
  heapTotal: 7195904,
  heapUsed: 2821496 }

单位均为字节:

  • heapTotal:堆中总共申请的内存量。
  • heapUsed:堆中使用中的内存量。
  • rss:resident set size 的缩写,即进程的常驻内存部分。进程的内存总共有几部分,一部分是 rss,其余部分在交换区(swap)或者文件系统(filesystem)中。

V8 的堆示意图
V8 的堆示意图

当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过 V8 的限制为止。

至于 V8 为何要限制堆的大小,

  • 表层原因为 V8 最初为浏览器而设计,不太可能遇到用大量内存的场景。对于网页来说,V8 的限制值已经绰绰有余。
  • 深层原因是 V8 的垃圾回收机制的限制。按官方的说法,以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要 50 毫秒以上,做一次非增量式的垃圾回收甚至要 1 秒以上。这是垃圾回收中引起 JavaScript 线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。

Node 在启动时可传递 --max-old-space-size--max-new-space-size 来调整内存限制的大小,如下所示:

node --max-old-space-size=1700 test.js // 单位为MB
// 或者
node --max-new-space-size=1024 test.js // 单位为KB

上述参数在 V8 初始化时生效,一旦生效就不能再动态改变。

V8 的垃圾回收机制

V8 用到的各种垃圾回收算法。

  1. V8 主要的垃圾回收算法

V8 的垃圾回收策略主要基于分代式垃圾回收机制。现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

  • V8 的内存分代

    • 新生代:对象为存活时间较短的对象。
    • 老生代:对象为存活时间较长或常驻内存的对象。

    V8 的分代示意图
    V8 的分代示意图

    V8 堆的整体大小就是新生代所用内存空间加上老生代的内存空间。前面我们提及的 --max-old-space-size 命令行参数可以用于设置老生代内存空间的最大值,--max-new-space-size 命令行参数则用于设置新生代内存空间的大小的。这两个值需要在启动时就指定。这意味着 V8 使用的内存无法根据使用情况自动扩充,当内存分配过程中超过极限值时,就会引起进程出错。

  • Scavenge 算法

    在分代的基础上,新生代中的对象主要通过 Scavenge 算法进行垃圾回收。在 Scavenge 的具体实现中,主要采用了 Cheney 算法。

    Cheney 算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间中的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。

    Scavenge 的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但 Scavenge 由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。

    由于 Scavenge 是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge 非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

    V8 的堆内存示意图
    V8 的堆内存示意图

    当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升。

    在单纯的 Scavenge 过程中,From 空间中的存活对象会被复制到 To 空间中去,然后对 From 空间和 To 空间进行角色对换(又称翻转)。但在分代式垃圾回收的前提下,From 空间中的存活对象在复制到 To 空间之前需要进行检査。在一定条件下,需要将存活周期长的对象移动到老生代中,也就是完成对象晋升。

    对象晋升的条件主要有两个:

    • 对象是否经历过 Scavenge 回收。
    • To 空间的内存占用比超过限制。

    在默认情况下,V8 的对象分配主要集中在 From 空间中。对象从 From 空间中复制到 To 空间时会检査它的内存地址来判断这个对象是否已经经历过一次 Scavenge 回收。如果已经经历过了,会将该对象从 From 空间复制到老生代空间中,如果没有,则复制到 To 空间中。这个晋升流程如下图所示。

    晋升过程
    晋升过程

    另一个判断条件是 To 空间的内存占用比。当要从 From 空间复制一个对象到 To 空间时,如果 To 空间已经使用了超过 25%,则这个对象直接晋升到老生代空间中,这个晋升的判断示意图如下图所示。

    晋升的判断示意图
    晋升的判断示意图

    设置 25% 这个限制值的原因是当这次 Scavenge 回收完成后,这个 To 空间将变成 From 空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

    对象晋升后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收算法处理。

  • Mark-Sweep & Mark-Compact

    对于老生代中的对象,由于存活对象占较大比重,再采用 Scavenge 的方式会有两个问题:

    1. 存活对象较多,复制存活对象的效率将会很低;
    2. 浪费一半空间。

    为此,V8 在老生代中主要采用了 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收。

    Mark-Sweep 是标记清除的意思,它分为标记和清除两个阶段。与 Scavenge 相比,Mark-Sweep 并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与 Scavenge 复制活着的对象不同,Mark-Sweep 在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出, Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。

    Mark-Sweep 在老生代空间中标记后的示意图(黑色部分标记为死亡的对象)
    Mark-Sweep 在老生代空间中标记后的示意图(黑色部分标记为死亡的对象)

    Mark-Sweep 最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

    为了解决 Mark-Sweep 的内存碎片问题,Mark-Compact 被提出来。Mark-Compact 是标记整理的意思,是在 Mark-Sweep 的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

    完成标记并移动存活对象后的示意图(白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞)
    完成标记并移动存活对象后的示意图(白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞)

    完成移动后,就可以直接清除最右边的存活对象后面的内存区域完成回收。

    这里将 Mark-Sweep 和 Mark-Compact 结合着介绍不仅仅是因为两种策略是递进关系,在 V8 的回收策略中两者是结合使用的。下表是目前介绍到的 3 种主要垃圾回收算法的简单对比。

    3 种垃圾回收算法的简单对比
    3 种垃圾回收算法的简单对比

    从上表中可以看到,在 Mark-Sweep 和 Mark-compact 之间,由于 Mark-Compact 需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8 主要使用 Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用 Mark-Compact。

  • Incremental Marking

    为了避免出现 JavaScript 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的 3 种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。在 V8 的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但 V8 的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full 垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。

    增量标记示意图
    增量标记示意图

    V8 在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的 1/6 左右。

    V8 后续还引入了延迟清理(lazy sweeping)与增量式整理( incremental compaction),让清理与整理动作也変成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。鉴于篇幅有限,此处不再深入讲解了。

查看垃圾回收日志

查看垃圾回收日志的方式主要是在启动时添加 --trace_gc 参数。

通过在 Node 启动时使用 --prof 参数,可以得到 V8 执行时的性能分析数据。

高效使用内存

如果需要释放常驻内存的对象,可以通过 delete 操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清除和整理的过程中,会被回收释放。

虽然 delete 操作和重新赋值具有相同的效果,但是在 V8 中通过 delete 删除对象的属性有可能干扰 V8 的优化,所以通过赋值方式解除引用更好。

在正常的 JavaScript 执行中,无法立即回收的内存有闭包和全局变量引用这两种情况。由于 V8 的内存限制,要十分小心此类变量是否无限制地增加,因为它会导致老生代中的对象増多。

内存指标

除了上述提到的 process.memoryUsage(),os 模块中的 totalmem() 和 freemem() 方法也可以查看内存使用的情况。

  • totalmem():返回系统的总内存(字节)。
  • freemem():返回系统的闲置内存(字节)。

堆外内存

通过 process.momoryUsage() 的结果可以看到,堆中的内存用量总是小于进程的常驻内存用量,这意味着 Node 中的内存使用并非都是通过 V8 进行分配的。我们将那些不是通过 V8 分配的内存称为堆外内存

Buffer 对象不同于其他对象,它不经过 V8 的内存分配机制,所以也不会有堆内存的大小限制。

这意味着利用堆外内存可以突破内存限制的问题。

为何 Buffer 对象并非通过 V8 分配?这在于 Node 并不同于浏览器的应用场景。在浏览器中 JavaScript 直接处理字符串即可满足绝大多数的业务需求,而 Node 则需要处理网络流和文件 I/O 流,操作字符串远远不能满足传输的性能需求。

Node 的内存构成主要由通过 V8 进行分配的部分和 Node 自行分配的部分。受 V8 的垃圾回收限制的主要是 V8 的堆内存。

内存泄漏

Node 对内存泄漏十分敏感,一旦线上应用有成千上万的流量,那怕是一个字节的内存泄漏也会造成堆积,垃圾回收过程中将会耗费更多时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。

尽管内存泄漏的情况不尽相同,但其实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。

通常,造成内存泄漏的原因有如下几个。

  • 缓存。
  • 队列消费不及时。
  • 作用域未释放。

慎将内存当作缓存

  1. 缓存限制策略

    • 需要增加完善的过期策略,防止内存(作为缓存)无限增长

      • 限制键值数量
      • LRU 算法
    • 模块机制
      为了加速模块的引入,所有模块都会通过编译执行,然后被缓存起来。由于通过 exports 导出的函数,可以访问文件模块中的私有变量,这样每个文件模块在编译执行后形成的作用域因为模块缓存的原因,不会被释放。示例代码如下所示:

      (function (exports, require, module, __filename, __dirname) {
        var local = "局部变量";
        exports.get = function () {
          return local;
        };
      });

      由于模块的缓存机制,模块是常驻老生代的。在设计模块时,要十分小心内存泄漏的出现。在下面的代码,每次调用 leak() 方法时,都导致局部变量 leakArray 不停增加内存的占用,且不被释放:

      var leakArray = [];
      exports.leak = function () {
        leakArray.push("leak" + Math.random());
      }; 

      如果模块不可避免地需要这么设计,那么请添加清空队列的相应接口,以供调用者释放内存。

  2. 缓存的解决方案

    直接将内存作为缓存的方案要十分慎重。除了限制缓存的大小外,另外要考虑的事情是,进程之间无法共享内存。如果在进程内使用缓存,这些缓存不可避免地有重复,对物理内存的使用是一种浪费。

    解决方案:采用进程外的缓存。

    解决以下两个问题:

    1. 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效。
    2. 进程之间可以共享缓存。

关注队列状态

在解决了缓存带来的内存泄漏问题后,另一个不经意产生的内存泄漏则是队列。在大多数应用场景下,队列的消费的速度远远大于生产的速度,内存泄漏不易产生。但是一旦消费速度低于生产速度,将会形成堆积。

举个实际的例子,有的应用会收集日志。如果欠缺考虑,也许会采用数据库来记录日志。日志通常会是海量的,数据库构建在文件系统之上,写入效率远远低于文件直接写入,于是会形成数据库写入操作的堆积,而 JavaScript 中相关的作用域也不会得到释放,内存占用不会回落,从而出现内存泄漏。

遇到这种场景,表层的解决方案是换用消费速度更高的技术。在日志收集的案例中,换用文件写入日志的方式会更高效。需要注意的是,如果生产速度因为某些原因突然激增,或者消费速度因为突然的系统故障降低,内存泄漏还是可能出现的。

深度的解决方案应该是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。

内存泄漏排查

常见工具:

  • v8-profiler
  • node-heapdump
  • node-mtrace
  • dtrace
  • node-memwatch

大内存应用

在 Node 中,不可避免地还是会存在操作大文件的场景。由于 Node 的内存限制,操作大文件也需要小心,好在 Node 提供了 stream 模块用于处理大文件。

stream 模块是 Node 的原生模块,直接引用即可。stream 继承自 EventEmitter,具备基本的自定义事件功能,同时抽象出标准的事件和方法。它分可读和可写两种。Node 中的大多数模块都有 stream 的应用,比如 fs 的 createReadStream() 和 createWriteStream() 方法可以分别用于创建文件的可读流和可写流,process 模块中的 stdin 和 stdout 则分别是可读流和可写流的示例。

由于 V8 的内存限制,我们无法通过 fs.readFile() 和 fs.writeFile() 直接进行大文件的操作,而改用 fs.createReadStream() 和 fs.createWriteStream() 方法通过流的方式实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入到另一个文件的过程:

var reader = fs.createReadStream('in.txt');
var writer = fs.createWriteStream('out.txt');
reader.on('data', function (chunk) {
  writer.write(chunk);
});
reader.on('end', function () {
  writer.end();
});

由于读写模型固定,上述方法有更简洁的方式,具体如下所示:

var reader = fs.createReadStream('in.txt');
var writer = fs.createWriteStream('out.txt');
reader.pipe(writer); 

可读流提供了管道方法 pipe(),封装了 data 事件和写入操作。通过流的方式,上述代码不会受到 V8 内存限制的影响,有效地提高了程序的健壮性。

如果不需要进行字符串层面的操作,则不需要借助 V8 来处理,可以尝试进行纯粹的 Buffer 操作,这不会受到 V8 堆内存的限制。但是这种大片使用内存的情况依然要小心,即使 V8 不限制堆内存的大小,物理内存依然有限制。

第 6 章 理解 Buffer

在 Node 中,应用需要处理网络协议、操作数据库、处理图片、接收上传文件等,在网络流和文件的操作中,要处理大量二进制数据,JavaScript 自有的字符串远远不能满足这些需求,于是 Buffer 对象应运而生。

Buffer 结构

Buffer 是一个像 Array 的对象,但它主要用于操作字节。下面我们从模块结构和对象结构的层面上来认识它。

模块结构

Buffer 是一个典型的 JavaScript 与 C++ 结合的模块,它将性能相关部分用 C++ 实现,将非性能相关的部分用 JavaScript 实现,如下图所示。

Buffer 所占用的内存不是通过 V8 分配的,属于堆外内存。

Buffer 对象

Buffer 对象类似于数组,它的元素为 16 进制的两位数,即 0 到 255 的数值。示例代码如下所示:

var str = "深入浅出node.js";
var buf = new Buffer(str, 'utf-8');
console.log(buf);
// => <Buffer e6 b7 b1 e5 85 a5 e6 b5 85 e5 87 ba 6e 6f 64 65 2e 6a 73>

由上面的示例可见,不同编码的字符串占用的元素个数各不相同,上面代码中的中文字在 UTF-8 编码下占用 3 个元素,字母和半角标点符号占用 1 个元素。

Buffer 受 Array 类型的影响很大,可以访问 length 属性得到长度,也可以通过下标访问元素,在构造对象时也十分相似。

给元素的赋值如果小于 0,就将该值逐次加 256,直到得到一个 0 到 255 之间的整数。如果得到的数值大于 255,就逐次减 256,直到得到 0 ~ 255 区间内的数值。如果是小数,舍弃小数部分,只保留整数部分。

Buffer 内存分配

Buffer 对象的内存分配不是在 V8 的堆内存中,而是在 Node 的 C++ 层面实现内存的申请的。因为处理大量的字节数据不能采用需要一点内存就向操作系统申请一点内存的方式,这可能造成大量的内存申请的系统调用,对操作系统有一定压力。为此 Node 在内存的使用上应用的是在 C++ 层面申请内存、在 JavaScript 中分配内存的策略。

为了高效地使用申请来的内存,Node 采用了 slab 分配机制。 slab 是一种动态内存管理机制,最早延生于 SunOS 操作系统( Solaris)中,目前在一些 *nix 操作系统中有广泛的应用,如 FreeBSD 和 Linux。

简单而言,slab 就是一块申请好的固定大小的内存区域。slab 具有如下 3 种状态。

  • full:完全分配状态。
  • partial:部分分配状态。
  • empty:没有被分配状态。

当我们需要一个 Buffer 对象,可以通过以下方式分配指定大小的 Buffer 对象:

new Buffer(size); 

Node 以 8KB 为界限来区分 Buffer 是大对象还是小对象:

Buffer.poolSize = 8 * 1024;

这个 8KB 的值也就是每个 slab 的大小值,在 JavaScript 层面,以它作为单位单元进行内存的分配。

  1. 分配小 Buffer 对象

    如果指定 Buffer 的大小少于 8KB,Node 会按照小对象的方式进行分配。

    当再次创建一个 Buffer 对象时,构造过程中将会判断这个 slab 的剩余空间是否足够。如果足够,使用剩余空间,并更新 slab 的分配状态。

    如果 slab 剩余的空间不够,将会构造新的 slab,原 slab 中剩余的空间会造成浪费。

    这里要注意的事项是,由于同一个 slab 可能分配给多个 Buffer 对象使用,只有这些小 Buffer 对象在作用域释放并都可以回收时,slab 的 8KB 空间オ会被回收。尽管创建了 1 个字节的 Buffer 对象,但是如果不释放它,实际可能是 8KB 的内存没有释放。

  2. 分配大 Buffer 对象

    如果需要超过 8KB 的 Buffer 对象,将会直接分配一个 SlowBuffer 对象作为 slab 单元,这个 slab 单元将会被这个大 Buffer 对象独占。

Buffer 的转换

Buffer 对象可以与字符串之间相互转换。目前支持的字符串编码类型有如下这几种。

  • ASCII
  • UTF-8
  • UTF-16LE/UCS-2
  • Base64
  • Binary
  • Hex

字符串转 Buffer

字符串转 Buffer 对象主要是通过构造函数完成的:

new Buffer(str, [encoding]);

通过构造函数转换的 Buffer 对象,存储的只能是一种编码类型。encoding 默认值为 UTF-8 编码。

一个 Buffer 对象可以存储不同编码类型的字符串转码的值,调用 write() 方法可以实现该目的,代码如下:

buf.write(string, [offset], [length], [encoding]) 

由于可以不断写入内容到 Buffer 对象中,并且每次写入可以指定编码,所以 Buffer 对象中可以存在多种编码转化后的内容。需要小心的是,每种编码所用的字节长度不同,将 Buffer 反转回字符串时需要谨慎处理。

Buffer 转字符串

Buffer 对象的 toString() 可以将 Buffer 对象转换为字符串,代码如下:

buf.toString([encoding], [start], [end])

比较精巧的是,可以设置 encoding(默认为 UTF-8)、start、end 这 3 个参数实现整体或局部的转换。如果 Buffer 对象由多种编码写入,就需要在局部指定不同的编码,才能转换回正常的编码。

Buffer 不支持的编码类型

Buffer 提供了一个 isEncoding() 函数来判断编码是否支持转换:

Buffer.isEncoding(encoding)

对于不支持的编码类型,可以借助 Node 生态圈中的模块完成转换。

Buffer 的拼接

Buffer 在使用场景中,通常是以一段一段的方式传输。以下是常见的从输入流中读取内容的示例代码:

var fs = require('fs');
var rs = fs.createReadStream('test.md');
var data = '';
rs.on("data", function (chunk){
  data += chunk;
});
rs.on("end", function () {
  console.log(data);
}); 

上面这段代码常见于国外,用于流读取的示范,data 事件中获取的 chunk 对象即是 Buffer 对象。

一旦输入流中有宽字节编码时,问题就会暴露出来。如果你在通过 Node 开发的网站上看到 � 乱码符号,那么该问题的起源多半来自于这里。

这里潜藏的问题在于如下这句代码:

data += chunk;

这句代码里隐藏了 toString() 操作,它等价于如下的代码:

data = data.toString() + chunk.toString();

英文环境下,toString() 不会造成任何问题。但对于宽字节的中文,却会形成问题。这是因为 toString() 方法默认以 UTF-8 为编码,中文字在 UTF-8 下占 3 个字节,这使得宽字节存在被截断的可能性。

setEncoding() 与 string_decoder()

可读流还有一个设置编码的方法 setEncoding(),该方法的作用是让 data 事件中传递的不再是一个 Buffer 对象,而是编码后的字符串。

var rs = fs.createReadStream('test.md', { highWaterMark: 11});
rs.setEncoding('utf8'); 

要知道,无论如何设置编码,触发 data 事件的次数依旧相同,这意味着设置编码并未改变按段读取的基本方式。

事实上,在调用 setEncoding() 时,可读流对象在内部设置了一个 decoder 对象。每次 data 事件都通过该 decoder 对象进行 Buffer 到字符串的解码,然后传递给调用者。是故设置编码后,data 不再收到原始的 Buffer 对象。但是这依旧无法解释为何设置编码后乱码问题被解决掉了,因为在前述分析中,无论如何转码,总是存在宽字节字符串被截断的问题。

最终乱码问题得以解决,还是在于 decoder 的神奇之处。decoder 对象来自于 string_decoder 模块 StringDecoder 的实例对象。它神奇的原理是什么,下面我们以代码来说明:

var StringDecoder = require('string_decoder').StringDecoder;
var decoder = new StringDecoder('utf8');
var buf1 = new Buffer([0xE5, 0xBA, 0x8A, 0xE5, 0x89, 0x8D, 0xE6, 0x98, 0x8E, 0xE6, 0x9C]);
console.log(decoder.write(buf1));
// => 床前明
var buf2 = new Buffer([0x88, 0xE5, 0x85, 0x89, 0xEF, 0xBC, 0x8C, 0xE7, 0x96, 0x91, 0xE6]);
console.log(decoder.write(buf2));
// => 月光,疑

我将前文提到的前两个 Buffer 对象写入 decoder 中。奇怪的地方在于“月”的转码并没有如平常一样在两个部分分开输出。StringDecoder 在得到编码后,知道宽字节字符串在 UTF-8 编码下是以 3 个字节的方式存储的,所以第一次 write() 时,只输出前 9 个字节转码形成的字符,“月”字的前两个字节被保留在 StringDecoder 实例内部。第二次 write() 时,会将这 2 个剩余字节和后续 11 个字节组合在一起,再次用 3 的整数倍字节进行转码。于是乱码问题通过这种中间形式被解决了。

虽然 string_decoder 模块很奇妙,但是它也并非万能药,它目前只能处理 UTF-8、Base64 和 UCS-2/UTF-16LE 这 3 种编码。所以,通过 setEncoding() 的方式不可否认能解決大部分的乱码问题,但并不能从根本上解决该问题。

正确拼接 Buffer

淘汰掉 setEncoding() 方法后,剩下的解决方案只有将多个小 Buffer 对象拼接为一个 Buffer 对象,然后通过 iconv-lite 一类的模块来转码这种方式。+= 的方式显然不行,那么正确的 Buffer 拼接方法应该如下面展示的形式:

var chunks = [];
var size = 0;
res.on('data', function (chunk) {
  chunks.push(chunk);
  size += chunk.length;
});
res.on('end', function () {
  var buf = Buffer.concat(chunks, size);
  var str = iconv.decode(buf, 'utf8');
  console.log(str);
}); 

正确的拼接方式是用一个数组来存储接收到的所有 Buffer() 片段并记录下所有片段的总长度,然后调用 Buffer.concat() 方法生成一个合并的 Buffer 对象。

Buffer 与性能

Buffer 在文件 I/O 和网络 I/O 中运用广泛,尤其在网络传输中,它的性能举足轻重。在应用中,我们通常会操作字符串,但一旦在网络中传输,都需要转换为 Buffer,以进行二进制数据传输。在 Web 应用中,字符串转换到 Buffer 是时时刻刻发生的,提高字符串到 Buffer 的转换效率,可以很大程度地提高网络吞吐率。

通过预先转换静态内容为 Buffer 对象,可以有效地减少 CPU 的重复使用,节省服务器资源。在 Node 构建的 Web 应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通过预先转换为 Buffer 的方式,使性能得到提升。由于文件自身是二进制数据,所以在不需要改变内容的场景下,尽量只读取 Buffer,然后直接传输,不做额外的转换,避免损耗。

第 7 章 网络编程

Node 提供了 net、dgram、http、https 这 4 个模块,分别用于处理 TCP、UDP、HTTP、HTTPS,适用于服务器端和客户端。

构建 TCP 服务

TCP

TCP 全名为传输控制协议,在 OSI 模型(由七层组成,分别为物理层、数据链结层、网络层、传输层、会话层、表示层、应用层)中属于传输层协议。许多应用层协议基于 TCP 构建,典型的是 HTTP、SMTP、IMAP 等协议。七层协议示意图如下图所示。

OSI 模型(七层协议)
OSI 模型(七层协议)

TCP 是面向连接的协议,其显著的特征是在传输之前需要 3 次握手形成会话,如下图所示。

TCP 在传输之前的 3 次握手
TCP 在传输之前的 3 次握手

只有会话形成之后,服务器端和客户端之间才能互相发送数据。在创建会话的过程中,服务器端和客户端分别提供一个套接字,这两个套接字共同形成一个连接。服务器端与客户端则通过套接字实现两者之间连接的操作。

服务器可以同时与多个客户端保持连接,对于每个连接而言是典型的可写可读 Stream 对象。Stream 对象可以用于服务器端和客户端之间的通信,既可以通过 data 事件从一端读取另一端发来的数据,也可以通过 write() 方法从一端向另一端发送数据。

另外,由于 TCP 套接字是可写可读的 Stream 对象,可以利用 pipe() 方法巧妙地实现管道操作。

构建 UDP 服务

UDP 与 TCP 一样同属于网络传输层。UDP 与 TCP 最大的不同是 UDP 不是面向连接的。TCP 中连接一旦建立,所有的会话都基于连接完成,客户端如果要与另一个 TCP 服务通信,需要另创建一个套接字来完成连接。但在 UDP 中,一个套接字可以与多个 UDP 服务通信,它虽然提供面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,但是由于它无须连接,资源消耗低,处理快速且灵活,所以常常应用在那种偶尔丢一两个数据包也不会产生重大影响的场景,比如音频、视频等。UDP 目前应用很广泛,DNS 服务即是基于它实现的。

UDP 套接字相对 TCP 套接字使用起来更简单,它只是一个 EventEmitter 的实例,而非 Stream 的实例。

构建 HTTP 服务

TCP 与 UDP 都属于网络传输层协议,如果要构造高效的网络应用,就应该从传输层进行着手。但是对于经典的应用场景,则无须从传输层协议入手构造自己的应用,比如 HTTP 或 SMTP 等,这些经典的应用层协议对于普通应用而言绰绰有余。

HTTP

HTTP 构建在 TCP 之上,属于应用层协议。

HTTP 报文

$ curl -v http://127.0.0.1:1337
# TCP 的 3 次握手过程
* About to connect() to 127.0.0.1 port 1337 (#0)
* Trying 127.0.0.1...
* connected
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
# TCP 的 3 次握手过程

# 客户端向服务器发送请求报文
> GET / HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: 127.0.0.1:1337
> Accept: */*
>
# 客户端向服务器发送请求报文

# 服务器端完成处理后,向客户端发送响应内容
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
Hello World
# 服务器端完成处理后,向客户端发送响应内容

# 结束会话的消息
* Connection #0 to host 127.0.0.1 left intact
* Closing connection #0 
# 结束会话的消息

从上述的报文信息中可以看出 HTTP 的特点,它是基于请求响应式的,以一问一答的方式实现服务,虽然基于 TCP 会话,但是本身却并无会话的特点。

http 模块

Node 的 http 模块包含对 HTTP 处理的封装。在 Node 中 HTTP 服务继承自 TCP 服务器(net 模块),它能够与多个客户端保持连接,由于其采用事件驱动的形式,并不为每一个连接创建额外的线程或进程,保持很低的内存占用,所以能实现高并发。HTTP 服务与 TCP 服务模型有区别的地方在于,在开启 keepalive 后,一个 TCP 会话可以用于多次请求和响应。TCP 服务以 connection 为单位进行服务,HTTP 服务以 request 为单位进行服务,HTTP 模块即是将 connection 到 request 的过程进行了封装,示意图下图所示。

http 模块将 connection 到 request 的过程进行了封装
http 模块将 connection 到 request 的过程进行了封装

除此之外,HTTP 模块将连接所用套接字的读写抽象为 ServerRequest 和 ServerResponse 对象,它们分别对应请求和响应操作。在请求产生的过程中 HTTP 模块拿到连接中传来的数据,调用二进制模块 http_parser 进行解析,在解析完请求报文的报头后,触发 request 事件,调用用户的业务逻辑。该流程的示意图下图所示。

http 模块产生请求的流程
http 模块产生请求的流程

响应结東后,HTTP 服务器可能会将当前的连接用于下一个请求,或者关闭连接。值得注意的是,报头是在报文体发送前发送的,一旦开始了数据的发送,whiteHead() 和 setHeader() 将不再生效。这由协议的特性决定。

另外,无论服务器端在处理业务逻辑时是否发生异常,务必在结束时调用 res.end() 结束请求,否则客户端将一直处于等待的状态。当然,也可以通过延迟 res.end() 的方式实现客户端与服务器端之间的长连接,但结束时务必关闭连接。

构建 WebScoket 服务

WebScoket 与传统 HTTP 有如下好处。

  • 客户端与服务器端只建立一个 TCP 连接,可以使用更少的连接。
  • WebSocket 服务器端可以推送数据到客户端,这远比 HTTP 请求响应模式更灵活、更高效。
  • 有更轻量级的协议头,减少数据传送量。

WebSocket 协议主要分为两个部分:握手和数据传输。

WebScoket 握手

客户端建立连接时,通过 HTTP 发起请求报文。

服务器端在处理完请求后,返回报文告知客户端正在更换协议,更新应用层协议为 WebSocket 协议,并在当前的套接字连接上应用新协议。

一旦 WebSocket 握手成功,服务器端与客户端将会呈现对等的效果,都能接收和发送消息。

WebSocket 数据传输

在握手顺利完成后,当前连接将不再进行 HTTP 的交互,而是开始 WebSocket 的数据帧协议,实现客户端与服务器端的数据交换。下图为协议升级过程示意图。

协议升级过程示意图
协议升级过程示意图

网络服务与安全

暂略。

第 8 章 构建 Web 应用

暂略。

第 9 章 玩转进程

从严格的意义上而言,Node 并非真正的单线程架构,Node 自身还有一定的 I/O 线程存在,这些 I/O 线程由底层 libuv 处理,这部分线程对于 JavaScript 开发者而言是透明的,只在 C++ 扩展开发时才会关注到。JavaScript 代码永远运行在 V8 上,是单线程的。本章将围绕 JavaScript 部分展开,所以屏蔽底层细节的讨论。

服务模型的变迁

假设每次响应服务耗用的时间稳定为 N 秒。进程数上限为 M。线程所占用的资源为进程的 1/L。

  • 同步:QPS 为 1/N。

  • 复制进程:QPS 为 M/N。

    在进程复制的过程中,需要复制进程内部的状态,对于每个连接都进行这样的复制的话,相同的状态将会在内存中存在很多份,造成浪费。并且这个过程由于要复制较多的数据,启动是较为缓慢的。

    为了解决启动缓慢的问题,预复制( prefork)被引入服务模型中,即预先复制一定数量的进程。同时将进程复用,避免进程创建、销毁带来的开销。但是这个模型并不具备伸缩性,一旦并发请求过高,内存使用随着进程数的增长将会被耗尽。

  • 多线程:QPS 为 M*L/N。

    为了解决进程复制中的浪费问题,多线程被引入服务模型,让一个线程服务一个请求。线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。但是多线程所面临的并发问题只能说比多进程略好,因为每个线程都拥有自己独立的堆栈,这个堆栈都需要占用一定的内存空间。另外,由于一个 CPU 核心在一个时刻只能做一件事情,操作系统只能通过将 CPU 切分为时间片的方法,让线程可以较为均匀地使用 CPU 资源,但是操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时时间将会被耗用在上下文切换中。所以在大并发量时,多线程结构还是无法做到强大的伸缩性。

  • 事件驱动:

    多线程的服务模型服役了很长一段时间,Apache 就是采用多线程/多进程模型实现的,当并发増长到上万时,内存耗用的问题将会暴露出来,这即是著名的 C10k 问题。

    为了解决高并发问题,基于事件驱动的服务模型出现了,像 Node 与 Nginx 均是基于事件驱动的方式实现的,采用单线程避免了不必要的内存开销和上下文切换开销。

    基于事件的服务模型存在的两个问题:CPU 的利用率和进程的健壮性。单线程的架构并不少见,其中尤以 PHP 最为知名——在 PHP 中没有线程的支持。它的健壮性是由它给每个请求都建立独立的上下文来实现的。但是对于 Node 来说,所有请求的上下文都是统一的,它的稳定性是亟需解决的问题。

    由于所有处理都在单线程上进行,影响事件驱动服务模型性能的点在于 CPU 的计算能力,它的上限决定这类服务模型的性能上限,但它不受多进程或多线程模式中资源上限的影响,可伸缩性远比前两者高。如果解决掉多核 CPU 的利用问题,带来的性能上提升是可观的。

多进程架构

创建子进程

child_process 模块提供了 4 个方法用于创建子进程。

  • spawn():启动一个子进程来执行命令。
  • exec():启动一个子进程来执行命令,与 spawn() 不同的是其接口不同,它有一个回调函数获知子进程的状况。
  • execFile():启动一个子进程来执行可执行文件。
  • fork():与 spawn() 类似,不同点在于它创建 Node 的子进程只需指定要执行的 JavaScript 文件模块即可。

以一个寻常命令为例,node worker.js 分别用上述 4 种方法实现,如下所示:

var cp = require('child_process');
cp.spawn('node', ['worker.js']);
cp.exec('node worker.js', function (err, stdout, stderr) {
  // some code
});
cp.execFile('worker.js', function (err, stdout, stderr) {
  // some code
});
cp.fork('./worker.js'); 

以上 4 个方法在创建子进程之后均会返回子进程对象。

4 个方法的差别如下表所示。

4 种方法的差别
4 种方法的差别

进程间通信

在 Master-Worker 模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。

子进程对象由 send() 方法实现主进程向子进程发送数据,message 事件实现收听子进程发来的数据。

// parent.js
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');
n.on('message', function (m) {
  console.log('PARENT got message:', m);
});
n.send({hello: 'world'});
// sub.js 
process.on('message', function (m) {
  console.log('CHILD got message:', m);
});
process.send({foo: 'bar'});

通过 fork() 或者其他 API,创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间将会创建 IPC 通道。通过 IPC 通道,父子进程之间才能通过 message 和 send() 传递消息。

  • 进程间通信原理

IPC 的全称是 Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。实现进程间通信的技术有很多,如命名管道、匿名管道、 socket、信号量、共享内存、消息队列、Domain Socket 等。Node 中实现 IPC 通道的是管道(pipe)技术。但此管道非彼管道,在 Node 中管道是个抽象层面的称呼,具体细节实现由 libuv 提供,在 Windows 下由命名管道(named pipe)实现,*nix 系统则采用 Unix Domain Socket 实现。表现在应用层上的进程间通信只有简单的 message 事件和 send() 方法,接口十分简洁和消息化。下图为 IPC 创建和实现的示意图。

IPC 创建和实现示意图
IPC 创建和实现示意图

父进程在实际创建子进程之前,会创建 IPC 通道并监听它,然后オ真正创建出子进程,并通过环境变量( NODE_CHANNEL_FD)告诉子进程这个 IPC 通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的 IPC 通道,从而完成父子进程之间的连接。下图为创建 IPC 管道的步骤示意图。

创建 IPC 管道的步骤示意图
创建 IPC 管道的步骤示意图

建立连接之后的父子进程就可以自由地通信了。由于 IPC 通道是用命名管道或 Domain Socket 创建的,它们与网络 socket 的行为比较类似,属于双向通信。不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。在 Node 中,IPC 通道被抽象为 Stream 对象,在调用 send() 时发送数据(类似于 write()),接收到的消息会通过 message 事件(类似于 data)触发给应用层。

注意:只有启动的子进程是 Node 进程时,子进程才会根据环境变量去连接 IPC 通道,对于其他类的子进程则无法实现进程间通信,除非其他进程也按约定去连接这个已经创建好的 IPC 通道。

句柄传递

建立好进程之间的 IPC 后,如果仅仅只用来发送一些简单的数据,显然不够我们的实际应用使用。

如果让服务都监听到相同的端口,会导致只有一个工作进程能够监听到该端口上,其余的进程在监听的过程中都抛出了 EADDRINUSE 异常,这是端口被占用的情况,新的进程不能继续监听该端口了。

要解决这个问题,通常的做法是让每个进程监听不同的端口,其中主进程监听主端口(如 80),主进程对外接收所有的网络请求,再将这些请求分别代理到不同的端口的进程上。示意图如下图所示。

主进程接收、分配网络请求的示意图
主进程接收、分配网络请求的示意图

通过代理,可以避免端口不能重复监听的问题,甚至可以在代理进程上做适当的负载均衡,使得每个子进程可以较为均衡地执行任务。由于进程每接收到一个连接,将会用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符。操作系统的文件描述符是有限的,代理方案浪费掉一倍数量的文件描述符的做法影响了系统的扩展能力。

为了解决上述这样的问题,Node 在版本 v0.5.9 引入了进程间发送句柄的功能。send() 方法除了能通过 IPC 发送数据外,还能发送句柄,第二个可选参数就是句柄,如下所示:

child.send(message, [sendHandle])

句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如句柄可以用来标识一个服务器端 socket 对象、一个客户端 socket 对象、一个 UDP 套接字、一个管道等。

发送句柄意味着什么?在前一个问题中,我们可以去掉代理这种方案,使主进程接收到 socket 请求后,将这个 socket 直接发送给工作进程,而不是重新与工作进程之间建立新的 socket 连接来转发数据。文件描述符浪费的问题可以通过这样的方式轻松解决。来看看我们的示例代码。

主进程代码如下所示:

var child = require('child_process').fork('child.js');
// Open up the server object and send the handle
var server = require('net').createServer();
server.on('connection', function (socket) {
  socket.end('handled by parent\n');
});
server.listen(1337, function () {
  child.send('server', server);
}); 

子进程代码如下所示:

process.on('message', function (m, server) {
  if (m === 'server') {
    server.on('connection', function (socket) {
      socket.end('handled by child\n');
    });
  }
}); 

这里子进程和父进程都有可能处理我们客户端发起的请求。

以上是在 TCP 层面上完成的事情,我们尝试将其转化到 HTTP 层面来试试。对于主进程而言,我们甚至想要它更轻量一点,那么是否将服务器句柄发送给子进程之后,就可以关掉服务器的监听,让子进程来处理请求呢?

我们对主进程进行改动,如下所示:

// parent.js
var cp = require('child_process'); 
var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');
// Open up the server object and send the handle
var server = require('net').createServer();
server.listen(1337, function () {
  child1.send('server', server);
  child2.send('server', server);
  // 关掉
  server.close();
}); 

然后对子进程进行改动,如下所示:

// child.js
var http = require('http');
var server = http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('handled by child, pid is ' + process.pid + '\n');
});
process.on('message', function (m, tcp) {
  if (m === 'server') {
    tcp.on('connection', function (socket) {
      server.emit('connection', socket);
    });
  }
}); 

这样一来,所有的请求都是由子进程处理了。整个过程中,服务的过程发生了一次改变,如下图所示。

主进程将请求发送给工作进程
主进程将请求发送给工作进程

主进程发送完句柄并关闭监听之后,成为了下图所示的结构。

主进程发送完句柄并关闭监听后的结构
主进程发送完句柄并关闭监听后的结构

我们神奇地发现,多个子进程可以同时监听相同端口,再没有 EADDRINUSE 异常发生了。

  1. 句柄发送与还原

    目前子进程对象 send() 方法可以发送的句柄类型包括如下几种。

    • net.Socket。TCP 套接字。
    • net.Server。TCP 服务器,任意建立在 TCP 服务上的应用层服务都可以享受到它带来的好处。
    • net.Native。C++ 层面的 TCP 套接字或 IPC 管道。
    • dgram.Socket。UDP 套接字。
    • dgram.Native。C++ 层面的 UDP 套接字。

    send() 方法在将消息发送到 IPC 管道前,将消息组装成两个对象,一个参数是 handle,另一个是 message。message 参数如下所示:

    {
      cmd: 'NODE_HANDLE',
      type: 'net.Server',
      msg: message
    }

    发送到 IPC 管道中的实际上是我们要发送的句柄文件描述符,文件描述符实际上是一个整数值。这个 message 对象在写入到 IPC 管道时也会通过 JSON.stringify() 进行序列化。所以最终发送到 IPC 通道中的信息都是字符串,send() 方法能发送消息和句柄并不意味着它能发送任意对象。

    连接了 IPC 通道的子进程可以读取到父进程发来的消息,将字符串通过 JSON.parse() 解析还原为对象后,才触发 message 事件将消息体传递给应用层使用。在这个过程中,消息对象还要被进行过滤处理,message.cmd 的值如果以 NODE_ 为前缀,它将响应一个内部事件 internalMessage。如果 message.cmd 值为 NODE_HANDLE,它将取出 message.type 值和得到的文件描述符一起还原出一个对应的对象。这个过程的示意图如下图所示。

    句柄的发送与还原示意图
    句柄的发送与还原示意图

    以发送的 TCP 服务器句柄为例,子进程收到消息后的还原过程如下所示:

    function(message, handle, emit) {
      var self = this;
      var server = new net.Server();
      server.listen(handle, function() {
        emit(server);
      });
    } 

    上面的代码中,子进程根据 message.type 创建对应 TCP 服务器对象,然后监听到文件描述符上。由于底层细节不被应用层感知,所以在子进程中,开发者会有一种服务器就是从父进程中直接传递过来的错觉。值得注意的是,Node 进程之间只有消息传递,不会真正地传递对象,这种错觉是抽象封装的结果。

    目前 Node 只支持上述提到的几种句柄,并非任意类型的句柄都能在进程之间传递,除非它有完整的发送和还原的过程。

  2. 端口共同监听

    在了解了句柄传递背后的原理后,我们继续探究为何通过发送句柄后,多个进程可以监听到相同的端口而不引起 EADDRINUSE 异常。其答案也很简单,我们独立启动的进程中,TCP 服务器端 socket 套接字的文件描述符并不相同,导致监听到相同的端口时会抛出异常。

    Node 底层对每个端口监听都设置了 SO_REUSEADDR 选项,这个选项的涵义是不同进程可以就相同的网卡和端口进行监听,这个服务器端套接字可以被不同的进程复用,如下所示:

    setsockopt(tcp->io_watcher.fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) 

    由于独立启动的进程互相之间并不知道文件描述符,所以监听相同端口时就会失败。但对于 send() 发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引起异常。

    多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。换言之就是网络请求向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进行服务。这些进程服务是抢占式的。

集群稳定之路

搭建好了集群,充分利用了多核 CPU 资源,似乎就可以迎接客户端大量的请求了。但请等等,我们还有一些细节需要考虑。

  • 性能问题。
  • 多个工作进程的存活状态管理。
  • 工作进程的平滑重启。
  • 配置或者静态数据的动态重新载入
  • 其他细节。

虽然我们创建了很多工作进程,但每个工作进程依然是在单线程上执行的,它的稳定性还不能得到完全的保障。我们需要建立起一个健全的机制来保障 Node 应用的健壮性。

进程事件

  • error:当子进程无法被复制创建、无法被杀死、无法发送消息时会触发该事件。
  • exit:子进程退出时触发该事件,子进程如果是正常退出,这个事件的第一个参数为退出码,否则为 null。如果进程是通过 kill() 方法被杀死的,会得到第二个参数,它表示杀死进程时的信号。
  • close:在子进程的标准输入输出流中止时触发该事件,参数与 exit 相同。
  • disconnect:在父进程或子进程中调用 disconnect() 方法时触发该事件,在调用该方法时将关闭监听 IPC 通道。

上述这些事件是父进程能监听到的与子进程相关的事件。除了send() 外,还能通过 kill() 方法给子进程发送消息。kill() 方法并不能真正地将通过 IPC 相连的子进程杀死,它只是给子进程发送了一个系统信号。默认情况下,父进程将通过 kill() 方法给子进程发送一个 SIGTERM 信号。它与进程默认的 kill() 方法类似,如下所示:

// 子进程
child.kill([signal]);
// 当前进程
process.kill(pid, [signal]);

它们一个发给子进程,一个发给目标进程。在 POSIX 标准中,有一套完备的信号系统,在命令行中执行 kill -l 可以看到详细的信号列表,如下所示:

$ kill -l
 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
 5) SIGTRAP 6) SIGABRT 7) SIGEMT 8) SIGFPE
 9) SIGKILL 10) SIGBUS 11) SIGSEGV 12) SIGSYS
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGURG
17) SIGSTOP 18) SIGTSTP 19) SIGCONT 20) SIGCHLD
21) SIGTTIN 22) SIGTTOU 23) SIGIO 24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH
29) SIGINFO 30) SIGUSR1 31) SIGUSR2 

Node 提供了这些信号对应的信号事件,每个进程都可以监听这些信号事件。这些信号事件是用来通知进程的,每个信号事件有不同的含义,进程在收到响应信号时,应当做出约定的行为,如 SIGTERM 是软件终止信号,进程收到该信号时应当退出。示例代码如下所示:

process.on('SIGTERM', function() {
  console.log('Got a SIGTERM, exiting...');
  process.exit(1);
});
console.log('server running with PID:', process.pid);
process.kill(process.pid, 'SIGTERM');

自动重启

主进程加入子进程管理机制的示意图
主进程加入子进程管理机制的示意图

// master.js
var fork = require('child_process').fork;
var cpus = require('os').cpus();
var server = require('net').createServer();
server.listen(1337);
var workers = {};
var createWorker = function () {
  var worker = fork(__dirname + '/worker.js');
  // 退出时重新启动新的进程
  worker.on('exit', function () {
    console.log('Worker ' + worker.pid + ' exited.');
    delete workers[worker.pid];
    createWorker();
  });
  // 句柄转发
  worker.send('server', server);
  workers[worker.pid] = worker;
  console.log('Create worker. pid: ' + worker.pid);
};
for (var i = 0; i < cpus.length; i++) {
  createWorker();
}
// 进程自己退出时,让所有工作进程退出
process.on('exit', function () {
  for (var pid in workers) {
    workers[pid].kill();
  }
}); 

在实际业务中,可能有隐藏的 bug 导致工作进程退出,那么我们需要仔细地处理这种异常,如下所示:

// worker.js
var http = require('http');
var server = http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('handled by child, pid is ' + process.pid + '\n');
});
var worker;
process.on('message', function (m, tcp) {
  if (m === 'server') {
    worker = tcp;
    worker.on('connection', function (socket) {
      server.emit('connection', socket);
    });
  }
});
process.on('uncaughtException', function () {
  // 停止接收新的连接
  worker.close(function () {
    // 所有已有连接断开后,退出进程
    process.exit(1);
  });
}); 

上述代码的处理流程是,一旦有未捕获的异常出现,工作进程就会立即停止接收新的连接;当所有连接断开后,退出进程。主进程在侦听到工作进程的 exit 后,将会立即启动新的进程服务,以此保证整个集群中总是有进程在为用户服务的。

  1. 自杀信号

    当然上述代码存在的问题是要等到已有的所有连接断开后进程才退出,在极端的情况下,所有工作进程都停止接收新的连接,全处在等待退出的状态。但在等到进程完全退出才重启的过程中,所有新来的请求可能存在没有工作进程为新用户服务的情景,这会丢掉大部分请求。

    为此需要改进这个过程,不能等到工作进程退出后才重启新的工作进程。当然也不能暴力退出进程,因为这样会导致已连接的用户直接断开。于是我们在退出的流程中增加一个自杀(suicide)信号。工作进程在得知要退出时,向主进程发送一个自杀信号,然后才停止接收新的连接,当所有连接断开后才退出。主进程在接收到自杀信号后,立即创建新的工作进程服务。代码改动如下所示:

    // worker.js
    process.on('uncaughtException', function (err) {
      process.send({act: 'suicide'});
      // 停止接收新的连接
      worker.close(function () {
        // 所有已有连接断开后,退出进程
        process.exit(1);
      });
    }); 

    主进程将重启工作进程的任务,从 exit 事件的处理函数中转移到 message 事件的处理函数中,如下所示:

    var createWorker = function () {
      var worker = fork(__dirname + '/worker.js');
      // 启动新的进程
      worker.on('message', function (message) {
        if (message.act === 'suicide') {
          createWorker();
        }
      });
      worker.on('exit', function () {
        console.log('Worker ' + worker.pid + ' exited.');
        delete workers[worker.pid];
      });
      worker.send('server', server);
      workers[worker.pid] = worker;
      console.log('Create worker. pid: ' + worker.pid);
      delete workers[worker.pid];
    });

    与前一种方案相比,创建新工作进程在前,退出异常进程在后。在这个可怜的异常进程退出之前,总是有新的工作进程来替上它的岗位。至此我们完成了进程的平滑重启,一旦有异常出现,主进程会创建新的工作进程来为用户服务,旧的进程一旦处理完已有连接就自动断开。整个过程使得我们的应用的稳定性和健壮性大大提高。

    这里存在问题的是有可能我们的连接是长连接,不是 HTTP 服务的这种短连接,等待长连接断开可能需要较久的时间。为此为已有连接的断开设置一个超时时间是必要的,在限定时间里强制退出的设置如下所示:

    process.on('uncaughtException', function (err) {
      process.send({act: 'suicide'});
      // 停止接收新的连接
      worker.close(function () {
        // 所有已有连接断开后,退出进程
        process.exit(1);
      });
      // 5秒后退出进程
      setTimeout(function () {
        process.exit(1);
      }, 5000);
    }); 

    进程中如果出现未能捕获的异常,就意味着有那么一段代码在健壮性上是不合格的。为此退出进程前,通过日志记录下问题所在是必须要做的事情,它可以帮我们很好地定位和追踪代码异常出现的位置,如下所示:

    process.on('uncaughtException', function (err) {
      // 记录日志
      logger.error(err);
      // 发送自杀信号
      process.send({act: 'suicide'});
      // 停止接收新的连接
      worker.close(function () {
        // 所有已有连接断开后,退出进程
        process.exit(1);
      });
      // 5秒后退出进程
      setTimeout(function () {
        process.exit(1);
      }, 5000);
    }); 
  2. 限量重启

    通过自杀信号告知主进程可以使得新连接总是有进程服务,但是依然还是有极端的情况。工作进程不能无限制地被重启,如果启动的过程中就发生了错误,或者启动后接到连接就收到错误会导致工作进程被频繁重启,这种频繁重启不属于我们捕捉未知异常的情况,因为这种短时间内频繁重启已经不符合预期的设置,极有可能是程序编写的错误。

    为了消除这种无意义的重启,在满足一定规则的限制下,不应当反复重启。比如在单位时间内规定只能重启多少次,超过限制就触发 giveup 事件,告知放弃重启工作进程这个重要事件。

    为了完成限量重启的统计,我们引入一个队列来做标记,在每次重启工作进程之间进行打点并判断重启是否太过频繁,如下所示:

    // 重启次ْ数
    var limit = 10;
    // 时间单位
    var during = 60000;
    var restart = [];
    var isTooFrequently = function () {
      // 记录重启时间
      var time = Date.now();
      var length = restart.push(time);
      if (length > limit) {
        // 取出最后10个记录
        restart = restart.slice(limit * -1);
      }
      // 最后一次重启到前10次重启之间的时间间隔
      return restart.length >= limit && restart[restart.length - 1] - restart[0] < during;
    };
    var workers = {};
    var createWorker = function () {
      // 检查是否太过频繁
      if (isTooFrequently()) {
        // 触发 giveup 事件后,不再重启
        process.emit('giveup', length, during);
        return;
      }
      var worker = fork(__dirname + '/worker.js');
      worker.on('exit', function () {
        console.log('Worker ' + worker.pid + ' exited.');
        delete workers[worker.pid];
      });
      // 重新启动新的进程
      worker.on('message', function (message) {
        if (message.act === 'suicide') {
          createWorker();
        }
      });
      // 句柄转发
      worker.send('server', server);
      workers[worker.pid] = worker;
      console.log('Create worker. pid: ' + worker.pid);
    }; 

    giveup 事件是比 uncaughtException 更严重的异常事件。uncaughtException 只代表集群中某个工作进程退出,在整体性保证下,不会出现用户得不到服务的情况,但是这个 giveup 事件则表示集群中没有任何进程服务了,十分危险。为了健壮性考虑,我们应在 giveup 事件中添加重要日志,并让监控系统监视到这个严重错误,进而报警等。

负载均衡

Node 默认提供的机制是采用操作系统的抢占式策略。所谓的抢占式就是在一堆工作进程中闲着的进程对到来的请求进行争抢,谁抢到谁服务。

一般而言,这种抢占式策略对大家是公平的,各个进程可以根据自己的繁忙度来进行抢占。但是对于 Node 而言,需要分清的是它的繁忙是由 CPU、I/O 两个部分构成的,影响抢占的是 CPU 的繁忙度。对不同的业务,可能存在 I/O 繁忙,而 CPU 较为空闲的情况,这可能造成某个进程能够抢到较多请求,形成负载不均衡的情况。

为此 Node 在 v0.11 中提供了一种新的策略使得负载均衡更合理,这种新的策略叫 Round-Robin,又叫轮叫调度。轮叫调度的工作方式是由主进程接受连接,将其依次分发给工作进程。分发的策略是在 N 个工作进程中,每次选择第 i=(i+1) mod n 个进程来发送连接。在 cluster 模块中启用它的方式如下:

// 启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_RR
// 不启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_NONE

或者在环境变量中设置 NODE_CLUSTER_SCHED_POLICYE 的值,如下所示:

export NODE_CLUSTER_SCHED_POLICY=rr
export NODE_CLUSTER_SCHED_POLICY=none 

Round-Robin 非常简单,可以避免 CPU 和 I/O 繁忙差异导致的负载不均衡。Round-Robin 策略也可以通过代理服务器来实现,但是它会导致服务器上消耗的文件描述符是平常方式的两倍。

状态共享

  1. 第三方数据存储解决数据共享最直接、简单的方式就是通过第三方来进行数据存储,比如将数据存放到数据库、磁盘文件、缓存服务(如 Redis)中,所有工作进程启动时将其读取进内存中。但这种方式存在的问题是如果数据发生改变,还需要一种机制通知到各个子进程,使得它们的内部状态也得到更新。

实现状态同步的机制有两种,一种是各个子进程去向第三方进行定时轮询,示意图如下图所示。

定时轮询示意图
定时轮询示意图

定时轮询带来的问题是轮询时间不能过密,如果子进程过多,会形成并发处理,如果数据没有发生改变,这些轮询会没有意义,白白增加查询状态的开销。如果轮询时间过长,数据发生改变时,不能及时更新到子进程中,会有一定的延迟。

  1. 主动通知

一种改进的方式是当数据发生更新时,主动通知子进程。当然,即使是主动通知,也需要种机制来及时获取数据的改变。这个过程仍然不能脱离轮询,但我们可以减少轮询的进程数量,我们将这种用来发送通知和查询状态是否更改的进程叫做通知进程。为了不混合业务逻辑,可以将这个进程设计为只进行轮询和通知,不处理任何业务逻辑,示意图如下图所示。

主动通知示意图
主动通知示意图

这种推送机制如果按进程间信号传递,在跨多台服务器时会无效,是故可以考虑采用 TCP 或 UDP 的方案。进程在启动时从通知服务处除了读取第一次数据外,还将进程信息注册到通知服务处。一旦通过轮询发现有数据更新后,根据注册信息,将更新后的数据发送给工作进程。由于不涉及太多进程去向同一地方进行状态査询,状态响应处的压力不至于太过巨大,单一的通知服务轮询带来的压力并不大,所以可以将轮询时间调整得较短,一旦发现更新,就能实时地推送到各个子进程中。

Cluster 模块

Node 在 v0.8 版本时新增的 Cluster 模块就能解决。在 v0.8 版本之前,实现多进程架构必须通过 child_process 来实现,要创建单机 Node 集群,由于有这么多细节需要处理,对普通工程师而言是一件相对较难的工作,于是 v0.8 时直接引入了 Cluster 模块,用以解决多核 CPU 的利用率问题,同时也提供了较完善的 API,用以处理进程的健壮性问题。

通过 cluster 创建 Node 进程集群

// cluster.js
var cluster = require('cluster');
cluster.setupMaster({
  exec: "worker.js"
});
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
  cluster.fork();
} 

在进程中判断是主进程还是工作进程,主要取决于环境变量中是否有 NODE_UNIQUE_ID,如下所示:

cluster.isWorker = ('NODE_UNIQUE_ID' in process.env);
cluster.isMaster = (cluster.isWorker === false);

Cluster 工作原理

事实上 cluster 模块就是 child_process 和 net 模块的组合应用。cluster 启动时,如同我们在【句柄传递】小节里的代码一样,它会在内部启动 TCP 服务器,在 cluster.fork() 子进程时,将这个 TCP 服务器端 socket 的文件描述符发送给工作进程。如果进程是通过 cluster.fork() 复制出来的,那么它的环境变量里就存在 NODE_UNIQUE_ID,如果工作进程中存在 listen() 侦听网络端口的调用,它将拿到该文件描述符,通过 SO_REUSEADDR 端口重用,从而实现多个子进程共享端口。对于普通方式启动的进程,则不存在文件描述符传递共享等事情。

在 cluster 内部隐式创建 TCP 服务器的方式对使用者来说十分透明,但也正是这种方式使得它无法如直接使用 child_process 那样灵活。在 cluster 模块应用中,一个主进程只能管理一组工作进程,如下图所示。

在 cluster 模块应用中,一个主进程只能管理一组工作进程
在 cluster 模块应用中,一个主进程只能管理一组工作进程

对于自行通过 child_process 来操作时,则可以更灵活地控制工作进程,甚至控制多组工作进程。其原因在于自行通过 child_process 操作子进程时,可以隐式地创建多个 TCP 服务器,使得子进程可以共享多个的服务器端 socket,如下图所示。

自行通过 child_process 控制多组工作进程
自行通过 child_process 控制多组工作进程

9.4.2 事件

对于健壮性处理,Cluster 模块也暴露了相当多的事件。

  • fork:复制一个工作进程后触发该事件。
  • online:复制好一个工作进程后,工作进程主动发送一条 online 消息给主进程,主进程收到消息后,触发该事件。
  • listening:工作进程中调用 listen() (共享了服务器端 Socket 后),发送一条 listening 消息给主进程,主进程收到消息后,触发该事件。
  • disconnect:主进程和工作进程之间 IPC 通道断开后会触发该事件。
  • exit:有工作进程退出时触发该事件。
  • setup:cluster.setupMaster() 执行后触发该事件。

这些事件大多跟 child_process 模块的事件相关,在进程间消息传递的基础上完成的封装这些事件对于增强应用的健壮性已经足够了。

总结

尽管 Node 从单线程的角度来讲它有够脆弱的:既不能充分利用多核 CPU 资源,稳定性也无法得到保障。但是群体的力量是强大的,通过简单的主从模式,就可以将应用的质量提升一个档次。在实际的复杂业务中,我们可能要启动很多子进程来处理任务,结构甚至远比主从模式复杂,但是每个子进程应当是简单到只做好一件事,然后通过进程间通信技术将它们连接起来即可。这符合 Unix 的设计理念,每个进程只做一件事,并做好一件事,将复杂分解为简单,将简单组合成强大。

尽管通过 child_process 模块可以大幅提升 Node 的稳定性,但是一旦主进程出现问题,所有子进程将会失去管理。在 Node 的进程管理之外,还需要用监听进程数量或监听日志的方式确保整个系统的稳定性,即使主进程出错退出,也能及时得到监控警报,使得开发者可以及时处理故障。

第 10 章 测试

单元测试

测试风格:TDD(测试驱动开发)、BDD(行为驱动开发)。它们的差别如下:

  • 关注点不同。TDD 关注所有功能是否被正确实现,每一个功能都具备对应的测试用例;BDD 关注整体行为是否符合预期,适合自顶向下的设计方式。
  • 表达方式不同。TDD 的表述方式偏向于功能说明书的风格;BDD 的表述方式更接近于自然语言的习惯。

性能测试

基准测试

基准测试要统计的就是在多少时间内执行了多少次某个方法。为了増强可比性,一般会以次数作为参照物,然后比较时间,此来判别性能的差距。

压力测试

对网络接口进行压力测试以判断网络接口的性能。对网络接口做压力测试需要考査的几个指标有吞吐率、响应时间和并发数,这些指标反映了服务器的并发处理能力。

笔者个人补充:

负载测试

是通过逐步增加系统负载,测试系统性能的变化,并在满足最终确定性能指标的情况下,系统所能承受的最大负载量。

负载测试的重点是:在系统正常工作情况下的性能指标,发现系统能够承受最大负载量的测试,属于正常范围的测试;

压力测试的重点是:确定在什么负载下系统的性能处于失效状态,发现系统性能的拐点,来获得系统能提供的最大服务级别的测试,属于异常范围的测试。

第 11 章 产品化

项目工程化

所谓的工程化,可以理解为项目的组织能力。体现在文件上,就是文件的组织能力。对于不同类型的项目,其组织方式也有所不同。

  • 目录结构
  • 构建工具
  • 编码规范
  • 代码审查

部署流程

  • stage(普通测试环境):排除掉无关因素,供开发或测试人员验证代码的改动是否正确。
  • pre-release(预发布环境):与测试环境相比,它的数据较为接近线上真实数据。
  • product(生产环境)

部署流程图
部署流程图

性能

  • 动静分离

    动静分离示意图
    动静分离示意图

  • 启用缓存

  • 多进程架构

  • 读写分离

  • 日志

    • 访问日志
    • 异常日志
    • 日志与数据库
    • 分隔日志
  • 监控报警

    • 监控
      • 日志监控
      • 响应时间
      • 进程监控
      • 磁盘监控
      • 内存监控
      • CPU 占用监控
      • I/O 负载监控
      • 应用状态监控
      • DNS 监控
    • 报警
      • 邮件报警
      • 短信或电话报警
    • 监控系统的稳定性
  • 稳定性

    • 多机器
      负载均衡示意图
      负载均衡示意图
    • 多机房
    • 容灾备份
  • 异构共存
    编程语言与服务通过网络协议进行调用的示意图
    编程语言与服务通过网络协议进行调用的示意图

    • 对于一般系统,可能并非 TCP 层面的网络协议,而是 RESTful 的服务接口。两者的不同在于一个是 HTTP 协议,处于应用层;一个是 TCP 协议,处于传输层。协议层次不同,性能方面会体现出差异来。TCP 协议会建立持久的长连接,甚至连接池,而 HTTP 协议则可能频繁地进行连接,在性能上存在损耗。TCP 协议需要依赖客户端驱动,HTTP 协议则基本上有现成的客户端。
@JChehe JChehe changed the title 《深入浅出 Node.js》——读书笔记 《深入浅出 Node.js》读书笔记 Sep 13, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant