《深入浅出Node.js》

Node简介

为什么是JavaScript

Ryan Dahl创造出Node之前,他的主要工作都是围绕高性能Web服务器进行的。经历过一些尝试和失败之后,他找到了设计高性能web服务器的几个要点:事件驱动、非阻塞I/O.
他提到,大多数人不设计一种更简单和更有效率的程序的主要原因是他们用到了阻塞I/O的库。
他评估过许多语言:C的开发门槛高,不会有太多的开发者将它用于日常的业务开发。作者觉得自身还玩不转Haskell,所以舍弃它;Lua自身已经包含很多阻塞I/O库,为其构建非阻塞I/O库也不能改变人们继续使用阻塞I/O库的习惯。Ruby的虚拟机性能不好。
相比之下,JavaScript门槛低,比Lua历史包袱少,javas在浏览器中有广泛的事件驱动方面的应用,暗合Ryan Dahl喜好基于事件驱动的需求。Chrome浏览器的JavaScript引擎性能优异。考虑到高性能、符合事件驱动、没有历史包袱这3个主要原因,JavaScript成为了Node的实现语言。

avatar
chrome浏览器和node的组件构成

除了HTML、Webkit和显卡这些UI相关技术没有支持外,node的结构和chrome十分相似。在node中,javascript可以随心所欲地访问本地文件,可以搭建websocket服务器,可以连接数据库,可以如web workers一样玩转多进程。

为什么叫Node

项目的发展超过了他最初单纯开发一个web服务器的想法,变成了构建网络应用的一个基础框架,可以在它的基础上构建更多的东西,诸如服务器、客户端、命令行工具等。Node发展为一个强制不共享任何资源的单线程、单进程系统,包含十分适宜网络的库,为构建大型分布式应用程序提供基础设施,其目标也是成为一个构建快速、可伸缩的网络应用平台。它自身非常简单,通过通信协议来组织许多Node,非常容易通过扩展来达成构建大型网络应用的目的。每个Node进程都构成这个网络应用中的一个节点,这是它名字所含意义的真谛。

Node给JavaScript带来的意义

Chrome浏览器和Node的组件构成如下图1-1所示。
avatar
浏览器中除了V8作为JavaScript引擎外,还有一个WebKit布局引擎。HTML5在发展过程中定义了更多更丰富的API。在实现上,浏览器提供了越来越多的功能暴露给JavaScript和HTML标签。

Node的结构与Chrome十分相似。它们都是基于事件驱动的异步架构,浏览器通过事件驱动来服务界面上的交互,Node通过事件驱动来服务I/O。

node的特点

1.异步I/O

发起Ajax调用就是一个异步的过程

1
2
3
4
$.post('/url', { title: '深入浅出Node.js' }, function (data) {
console.log('收到响应');
});
console.log('发送ajax结束');

avatar
在node中,异步I/O也很常见。以读取文件为例,我们看到它与前端ajax调用的方法是极其类似的:

1
2
3
4
5
6
var fs = require('fs');

fs.readFile('/path', function (err, file) {
console.log('读取文件完成');
});
console.log('发起读取文件');

avatar
在node中,绝大多数的操作都以异步的方式进行调用,从文件读取到网络请求等。我们可以在语言层面很自然地进行并行I/O操作。每个调用之间无须等待之前的I/O调用结束,在编程模型上可以极大提升效率。

2.事件与回调函数

node将前端浏览器中应用广泛且成熟的事件引入后端,配合异步I/O,将事件点暴露给业务逻辑。

下面举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var http = require('http');
var querystring = require('querystring');

// 侦听服务器的request事件
http.createServer(function (req, res) {
var postData = '';
req.setEncoding('utf8');

// 侦听请求的data事件
req.on('data', function (chunk) {
postData += chunk;
});

// 侦听请求的end事件
req.on('end', function () {
res.end(postData);
});
}).listen(8080);

console.log('服务器启动完成');

node创建了一个web服务器,并侦听8080端口。对于服务器,我们为其绑定了request事件,对于请求对象,我们为其绑定了data事件和end事件。

事件的编程具有轻量级、松耦合、只关注事务点等优势,但是在多个异步任务的场景下事件与事件之间各自独立,如何协作是一个问题。

node除了异步和事件外,回调函数是一大特色。纵观下来,回调函数也是最好的接受异步调用返回数据的方式。在流程控制方面,因为穿插了异步方法和回调函数,与常规的同步方式相比,变得不那么一目了然了。然而,在转变为异步编程思维后,通过对业务的划分和对事件的提炼,在流程控制方面处理业务的复杂度与同步方式实际上是一致的。

3.单线程

node是单线程的。在node中,javascript与其余线程是无法共享任何状态的。单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

但单线程也有它自身的弱点:
1.无法利用多核CPU
2.错误会引起整个应用退出,应用的健壮性值得考验。
3.大量计算占用CPU导致无法继续调用异步I/O。

在Node中,长时间的CPU占用会导致后续的异步I/O发不出调用、已完成的异步I/O的回调函数也会得不到及时执行。

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

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

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

4.跨平台

node基于libuv实现跨平台的架构示意图如下。
avatar

兼容windows和*nix平台主要得益于node在架构层面的改动,它在操作系统与node上层模块系统之间构建了一层平台层架构,即libuv。目前,libuv已经成为许多系统实现跨平台的基础组件。通过良好的架构,node的第三方C++模块(除了没有保持更新的模块外)也可以借助libuv实现跨平台。

node的应用场景

1.I/O密集型

node擅长I/O密集型的应用场景,它面向网络且擅长并行I/O,能够有效地组织起更多的硬件资源。I/O密集的优势主要在于node利用事件循环的处理能力,而不是启动每一个线程为每一个请求服务,资源占用极少。

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

在CPU密集的应用场景中,node是否能胜任呢?实际上,单以执行效率来做评判,V8的执行效率是毋庸置疑的。
计算斐波那契数列的耗时排行(n=40)
avatar

CPU密集型应用给node带来的挑战主要是:由于javascript单线程的原因,如果长时间运行的计算,将会导致CPU时间片不能释放,使得后续的I/O无法发起。但是适当调整和分解大型运算任务为多个小任务,使得运算能够适当释放,不阻塞I/O调用的发起,这样既可同时享受到并行异步I/O的好处,又能充分利用CPU。对于长时间运行的计算,如果它的耗时超过普通阻塞I/O的耗时,那么应用场景就需要重新评估了,此类场景或许应该采用多线程的方式进行计算。

node虽然没有提供多线程用于计算支持,但是还是有以下两个方式来充分利用CPU。
1.通过编写C/C++扩展的方式更有效地利用CPU,上图中C/C++拓展的方式计算速度比java还要快。
2.如果还不够满足需求,可以通过子进程的方式,将一部分Node进程当做常驻服务进程用于计算,然后利用进程间的消息来传递结果,将计算与I/O分离,这样还能充分利用多CPU。

与遗留系统和平共处

LinkedIn旧有的系统具有非常稳定的数据输出,Node将该数据源当做数据接口,发挥异步并行的优势,而不用关心它背后是用什么语言实现的。

雪球财经从旧有的java项目中分离出一个node项目,使得前端工程师在HTTP协议栈的两端能够高效灵活地开发,避免了Java烦琐的表达;另一方面,又利用java作为后端接口和中间件,使其具有良好的稳定性。

分布式应用

分布式应用意味着对可伸缩性的要求非常高,比如阿里巴巴的NodeFox,将数据库集群做了划分和映射,查询调用依旧是针对单张表,中间层分解查询SQL,并行地去多台数据库中获取数据并合并。这个案例其实也是高效利用并行I/O的例子。对于node,这个行为只是一次普通的I/O。对于数据库而言,却是一次复杂的计算,所以也是进而充分压榨硬件资源的过程。

node的使用者

node的使用者主要有以下几类:
1.前后端编程语言环境统一,可以减少不同语言上下文交换的负担
2.node带来的高性能I/O用于实时应用,实时语音、scoket.io实时通知等
3.并行I/O使得使用者可以更有效的利用分布式环境。如阿里巴巴的NodeFox借助Node并行I/O的能力,更高效地使用已有的数据。
3.并行I/O,有效利用稳定接口提升web渲染能力。
4.云计算平台提供node支持。
5.游戏开发领域。对实时和并发有很高的要求。
6.工具类应用。比如一些前端工具。

模块机制

javascript先天就缺乏模块机制,通过script标签引入代码的方式显得杂乱无章,语言自身毫无组织和约束能力,人们不得不用命名空间等方式人为地约束代码,以求达到安全和易用的目的。

随着javascript不断发展,社区制定了相应的规范,尤其是CommonJS规范的提出。

CommonJS规范

CommonJS规范为JavaScript制定了一个美好的愿景——希望JavaScript能够在任何地方运行。

对于javascript自身而言,它的规范依然是薄弱的,还有以下缺陷。

  1. 没有模块系统
  2. 标准库较少。ECMAScript仅定义了部分核心库,对于文件系统,I/O流等常见需求却没有标准的API。
  3. 没有标准接口。几乎没有定义过如web服务器或者数据库之类的标准统一接口。
  4. 缺乏包管理系统。导致javascript应用中基本没有自动加载和安装依赖的能力。

CommonJS规范的提出,主要是为了弥补当前javascript没有标准的缺陷,从而具备开发大型应用的基础能力,他们期望那些用CommonJS API写出的应用可以具备跨宿主环境的能力,这样不仅可以用javascript开发富客户端应用,而且还可以编写以下应用。

1.服务器端javascript应用程序
2.命令行工具
3.桌面图形界面应用程序。
4.混合应用。

目前,该规范依旧在成长,它涵盖了模块、二进制、Buffer、字符集编码、I/O流、进程环境、文件系统、套接字、单元测试、web服务器网关接口、包管理等。
avatar
node借鉴CommonJS的Modules规范实现了一套非常易用的模块系统,NPM对Packages规范的完好支持使得Node应用在开发中事半功倍。

CommonJS的模块规范

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

1.模块引用

1
var math = require('math');

require这个方法接受模块标识,以此引入一个模块的API到当前上下文中。

2.模块定义

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// math.js
exports.add = function () {
var sum = 0,
i = 0,
args = arguments,
l = args.length;

while (i < l) {
sum += args[i++];
}
return sum;
};

// program.js
var math = require('./math');
console.log(math.add(1, 2));// 3

3.模块标识

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

模块的意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出功能以顺畅地连接上下游依赖。

node的模块实现

Node在实现中并非完全按照规范实现。

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

  1. 路径分析
  2. 文件定位
  3. 编译执行

在node中,模块分为两类:一类是node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

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

接下来,我们展开详细的模块加载过程。

1.优先从缓存加载

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

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

2.路径分析和文件定位

2.1模块标识符分析

require()方法接受一个标识符作为参数,node正是基于这样一个标识符进行模块查找的。模块标识符在node中主要分为几类。

  1. 核心模块,如http、fs、path等
  2. 以.或..开始的相对路径文件模块
  3. 以/开始的绝对路径文件模块
  4. 非路径形式的文件模块,如自定义的connect模块

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

  6. 路径形式的文件模块
    以.、..和/开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。由于文件模块给node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块。

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

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

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

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

1
2
3
4
['/home/jackson/research/node_modules',
'/home/jackson/node_modules',
'/home/node_modules',
'/node_modules']

在window下,也许是这样:

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

可以看出,模块路径的生成规则如下所示。
1.当前文件目录下的node_modules目录
2.父目录下的node_modules目录
3.父目录的父目录下的node_modules目录
4.沿路径向上逐级递归,直到根目录下的node_modules目录
在加载过程中,node会逐个尝试模块路径中的路径,直到找到目标文件为止。当前文件路径越深,模块查找耗时越多,这是自定义模块的加载速度最慢的原因。

2.1文件定位

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

但在文件定位过程中,还有一些细节需要注意,这主要包括文件拓展名的分析、目录和包的处理。
2.1.1文件扩展名分析
CommonJS模块规范允许在标识符中不包含文件扩展名,这种情况下node会按.js、.json、.node的次序补足扩展名,依次尝试。在尝试过程中,需要调用fs模块同步阻塞式判断文件是否存在。因为node是单线程,这里是一个会引起性能问题的地方。小诀窍是:标识符带上扩展名,这样会加快一点速度。另一个诀窍是:同步配合缓存,也可以大幅度缓解Node单线程阻塞式调用的缺陷。

2.1.2目录分析和包

在分析标识符的过程中,require()通过分析拓展名之后,可能没有查找到对应文件,但却得到一个目录,此时Node会将目录当做一个包来处理。

Node会在当前目录下查找package.json(包描述文件),通过JSON.parse()解析包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。而如果main属性指定的文件名错误,或者压根没有package.json文件,node会将index当做默认文件名,然后依次查找index.js、index.json、index.node。

如果没有定位成功,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依旧没有查找到,则会抛出查找失败的异常。

模块编译

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

1
2
3
4
5
6
7
8
9
10
11
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会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,其载入方法也有所不同。

  1. .js文件 通过fs模块同步读取文件后编译执行
  2. .node文件 这是C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件
  3. .json文件 通过fs模块同步读取文件后,用JSON.parse()解析返回结果
  4. 其余扩展名文件 都被当做.js文件载入
    每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能
    .json文件的调用如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 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可以知道系统中已有的扩展加载方式:

1
console.log(require.extensions);

结果如下:

1
[Object: null prototype] { '.js': [Function], '.json': [Function], '.node': [Function] }

如果想对自定义的扩展名进行特殊的加载,可以通过类似require.extensions[‘.ext’]的方式实现。早期的CoffeeScript文件就是通过添加require.extensions[‘.coffee’]扩展的方式来实现加载的。但是从V0.10.6开始,官方不鼓励通过这种方式进行加载,而是期望先将其他语言或文件编译成JavaScript文件后再加载,这样做的好处在于不将烦琐的编译加载等过程引入Node的执行过程中。

在确定文件的扩展名之后,Node将调用具体的编译方式来将文件执行后返回给调用者。
1.javaScript模块的编译
每个模块文件都有exports、require、、module、filename、dirname这些变量存在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
'0': {},
'1':
{ [Function: require]
resolve: { [Function: resolve] paths: [Function: paths] },
main:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/jiangchaofei/Desktop/study/node-test/module_path.js',
loaded: false,
children: [],
paths: [Array] },
extensions:
[Object: null prototype] { '.js': [Function], '.json': [Function], '.node': [Function] },
cache:
[Object: null prototype] {
'/Users/jiangchaofei/Desktop/study/node-test/module_path.js': [Module] } },
'2':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/jiangchaofei/Desktop/study/node-test/module_path.js',
loaded: false,
children: [],
paths:
[ '/Users/jiangchaofei/Desktop/study/node-test/node_modules',
'/Users/jiangchaofei/Desktop/study/node_modules',
'/Users/jiangchaofei/Desktop/node_modules',
'/Users/jiangchaofei/node_modules',
'/Users/node_modules',
'/node_modules' ] },
'3': '/Users/jiangchaofei/Desktop/study/node-test/module_path.js',
'4': '/Users/jiangchaofei/Desktop/study/node-test' }

在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了:

1
2
3
4
5
6
(function (exports, require, module, __filename, __dirname) {
var content = "content";
exports.content = function () {
console.log(content);
};
})

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

在执行之后,模块的exports属性被返回给了调用方。exports属性上的任何方法和属性都可以被外部调用到。但模块中的其余变量或属性则不可直接被调用。

那么存在exports的情况下,为何存在module.exports.理想情况下,只要赋值给exports即可

1
2
3
exports = function () {
// My Class
};

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

1
2
3
4
5
6
7
8
9
var change = function (exp) {
exp = function () {
// My Class
};
console.log(exp);// [Function: exp]
}
var exp = {};
change(exp);
console.log(exp);// {}

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

1
2
3
// a.js
// exports = function () {};
module.exports = function () {};

1
2
3
// b.js
var a = require('./a.js');
console.log(a);// [Function]

2.C/C++模块的编译
Node调用process.dlopen()方法进行加载和执行。在Node的架构下,dlopen()方法在Windows和*nix平台下分别有不同的实现,通过libuv兼容层进行了封装。
实际上,.node的模块文件并不需要编译,因为它是编写C/C++模块之后编译生成的,所有这里只有加载和执行的过程。在执行的过程中,模块的exports对象与.node模块产生联系,然后返回给调用者。

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

核心模块

Node的核心模块在编译成可执行文件的过程中被编译进了二进制文件。核心模块分为C/C++模块和JavaScript编写的两部分

  1. JavaScript核心模块的编译过程

在编译所有C/C++文件之前,编译程序需要将所有的JavaScript模块文件编译为C/C++代码。
1.1转存为C/C++代码
Node采用了V8附带的js2c.py工具,将内置的JavaScript代码转换成C++里的数组,生成node_natives.h头文件
avatar
JavaScript代码以字符串的形式存储在node命名空间中,是不可直接执行的。在启动Node进程时,JavaScript代码直接加载进内存中。在加载的过程中,JavaScript核心模块经历标识符分析后直接定位到内存中,比普通的文件模块从磁盘中一处一处查找要快很多。
2.编译JavaScript核心模块
在引入JavaScript核心模块的过程中,也经历了头尾包装的过程,然后才执行和导出了exports对象。与文件模块有区别的地方在于:获取源代码的方式(核心模块是从内存中加载的)以及缓存执行结果的位置。
JavaScript核心模块的定义如下,源文件通过process.binding(‘natives’)取出,编译成功的模块缓存到NativeModule._cache对象上,文件模块则缓存到Module._cache对象上:

1
2
3
4
5
6
7
8
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能够提高性能的常见方式。通常,脚本语言的开发速度优于静态语言,但是其性能则弱于静态语言。这种复合模式可以在开发速度和性能之间找到平衡点。

我们将那些由纯C/C++编写的部分称为内建模块,它们通常不被用户直接调用。Node的buffer、crypto、evals、fs、os等模块是部分通过C/C++编写的
1.内建模块的组织形式
内建模块的内部结构定义如下:

1
2
3
4
5
6
7
struct node_module_struct {
int version;
void *dso_handle;
const char *filename;
void (*register_func) (v8::Handle<v8::Object> target);
const char *modname;
}

每一个内建模块在定义之后,都通过NODE_MODULE宏将模块定义到node命名空间中,模块的具体初始化方法挂载为结构的register_func成员
avatar
node_extensions.h文件将这些散列的内建模块统一放进了一个叫node_module_list的数组中,这些模块有:
avatar
Node提供了get_builtin_module()方法从node_module_list数组中取出这些模块。
内建模块的优势在于:性能上优于脚本语言;其次,在进行文件编译时,它们被编译进二进制文件。一旦Node开始执行,它们被直接加载进内存中,无需再次做标识符定位、文件定位、编译等过程,直接就可执行。

2.内建模块的导出
在Node的所有模块类型中,存在一种依赖层级关系,即文件模块可能会依赖核心模块、核心模块可能会依赖内建模块

不推荐文件模块直接调用内建模块。如需调用,直接调用核心模块即可,因为核心模块中基本都封装了内建模块。那么内建模块是如何将内部变量或方法导出,以供外部JavaScript核心模块调用的呢?

Node在启动时,会生成一个全局变量process,并提供Binding()方法来协助加载内建模块。

在加载内建模块是,我们先创建一个exports空对象,然后调用get_builtin_module()方法取出内建模块对象,通过执行register_func()填充exports对象,最后将exports对象按模块名缓存,并返回给调用方完成导出。

这个方法不仅可以导出内建方法,还能导出一些别的内容。前面提到的JavaScript核心文件被转换为C/C++数组存储后,便是通过process.binding(‘natives’)取出放置在NativeModule._source中的:

1
NativeModule._source = process.binding('natives');

该方法将通过js2c.py工具转换出的字符串数组取出,然后重新转换为普通字符串,以对JavaScript核心模块进行编译和执行。

核心模块的引入流程

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

编写核心模块(???暂略)

模块调用栈

下面明确一下各种模块之间的调用关系
C/C++内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript模块调用。如果你不是非常了解要调用的C/C++模块,请尽量避免通过process.binding()方法直接调用,这是不推荐的。
JavaScript核心模块主要扮演的职责有两类:一类是作为C/C++内建模块的封装层和桥接层,供文件模块调用;一类是纯粹的功能模块,它不需要跟底层打交道,但是又十分重要。
avatar
文件模块通常是由第三方编写,包括普通JavaScript模块和C/C++扩展模块,主要调用方向为普通JavaScript模块调用扩展模块。

包与NPM

包和NPM是将模块联系起来的一种机制。
Node对CommomJS模块规范的实现,一定程度上解决了变量依赖、依赖关系等代码组织性问题,它也对CommomJS的包规范做了实现,在模块的基础上进一步组织JavaScript代码。
avatar

它由包结构和包描述文件组成,前者用于组织包中的各种文件,后者则用于描述包的相关信息,以供外部读取分析。

包结构

包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。完全符合CommonJS规范的包目录包含以下文件:

  1. packages.json: 包描述文件
  2. bin: 用于存放可执行二进制文件的目录
  3. lib: 用于存放JavaScript代码的目录
  4. doc: 用于存放文档的目录
  5. test: 用于存放单元测试用例的代码

包描述文件与NPM

包描述文件是一个JSON格式的文件——package.json,位于包的根目录下。

package.json文件的必要字段:

  1. name 包名。包名必须是唯一的,以免对外公布时产生重名冲突的误解。
  2. description 包简介
  3. version 版本号。通茶为major.minor.version
  4. keywords 关键词数组。NPM中主要用来做分类搜索。
  5. maintainers 包维护者列表。NPM通过该属性进行权限认证。
  6. contributors 贡献者列表
  7. bugs 一个可以反馈bug的网页地址或邮件地址
    avatar
    包规范的定义可以帮助Node解决依赖包安装的问题,而NPM正是基于该规范进行了实现。在v0.6.3版本时集成进Node中作为默认包管理器。

在包描述文件的规范中,NPM实际需要的字段主要有name、version、description、keywords、repositorier、author、bin、main、script、engines、dependencies、devDependencies
与包规范的区别多了author、bin、main和devDependencies

  1. author 包作者
  2. bin 一些包作者希望包可以作为命令行工具使用。配置好bin字段后,通过npm install package_name -g命令可以将脚本添加到执行路径中,之后可以在命令行中直接执行。通过-g命令安装的模块包称为全局模式
  3. main 模块引入方法require()在引入包时,会优先检查这个字段,并将其作为包中其余模块的入口。如果不存在这个字段,会查找包目录下的index.js、index.node、index.json文件作为默认入口
  4. devDependencies 开发时需要的依赖

NPM常用功能

NPM帮助Node完成了第三方模块的发布、安装和依赖等。借助NPM、Node与第三方模块之间形成了很好地一个生态系统。

下面介绍一些npm的用法

  1. 查看npm版本

    1
    npm -v
  2. 安装依赖包

    1
    npm install express

npm会在当前目录下创建node_modules,然后在node_modules下创建express,接着将包解压到这个目录下。
require()方法在做路径分析的时候会通过模块路径查找到express所在的位置。

  1. 全局模式安装
    如果包中含有命令行工具,那么需要执行npm install express -g命令进行全局安装。全局模式并不是将一个模块包安装为一个全局包的意思,它并不意味着可以从任何地方通过require()来引用它。
    它根据包描述文件中的bin字段配置,将实际脚本链接到与Node可执行文件相同的路径下:
    1
    2
    3
    "bin": {
    "express": "./bin/express"
    },

事实上,通过全局模式安装的所有模块的包都被安装进了一个统一的目录下,这个目录可以通过如下方式推算出来:

1
path.resolve(process.execPath, '..', '..', 'lib', 'node_modules');

如果Node可执行文件的位置是/usr/local/bin/node,那么模块目录就是/usr/local/lib/node_modules,最后通过软连接的方式将bin字段配置的可执行文件链接到Node的可执行目录下。

  1. 从本地安装
    本地安装只需为NPM指明package.json文件所在位置即可:它可以是一个包含package.json的存档文件,也可以是一个url地址,也可以是一个目录下有package.json文件的目录位置。具体参数如下:
    npm install
    npm install
    npm install
  2. 从非官方源安装
    通过镜像源安装。在执行命令时,添加–registry=http://registry.url
    1
    npm install underscore --registry=http://registry.url

如果使用过程中几乎都采用镜像源安装,可以执行以下命令指定默认源:

1
npm config set registry http://registry.url

NPM钩子命令

script字段的提出就是让包在安装或者卸载等过程中提供钩子机制:

1
2
3
4
5
6
"script": {
"preinstall": "preinstall.js",
"install": "install.js",
"uninstall": "uninstall.js",
"test": "test.js"
}

在以上字段中执行npm install <package>,preinstall指向的脚本将会被加载执行,然后install指向的脚本会被执行。

发布包

为了将整个NPM的流程串联起来,这里演示如何编写一个包,将其发布到NPM仓库中,并通过NPM安装回本地。

1.编写模块

1
2
3
4
// hello.js
exports.sayHello = function () {
return 'Hello, world.';
}

2.初始化包描述文件
npm init可以帮助你生成package.json文件
3.注册包仓库账号
注册账号的命令是npm adduser
4.上传包
npm publish <folder>

1
npm publish .

在这个过程中,npm会将目录打包为一个存档文件,然后上传到官方源仓库中。
5.安装包
npm install hell_test_jackson
6.包管理权限
通常,一个包只有一个人拥有权限进行发布,如果需要多人进行发布,可以使用npm owner命令帮助你管理包的所有者。

1
2
3
4
$ npm owner ls eventproxy
npm http GET https://registry.npmjs.org/eventproxy
npm http 200 https://registry.npmjs.org/eventproxy
jacksontian <shyvo1987@gmail.com>

可以添加包的拥有者,删除一个包的拥有者:

1
2
3
npm owner ls <package name>
npm owner add <user> <package name>
npm owner rm <user> <package name>

7.分析包
npm ls可以分析出当前路径下能够通过模块路径找到的所有包,并生成依赖树
avatar

局域NPM ???暂略

NPM潜在问题

  1. 每个人都可以分享包到平台上,NPM平台上包的质量良莠不齐。
    判断参考:
    1.npm首页上的依赖榜
    2.github项目的观察者数量、分支数量
    3.包的测试用例和文档状况
  2. Node代码可以运行在服务器端,需要考虑安全问题

前后端共用模块

JavaScript在Node出现之后,比别的编程语言多了一项优势,那就是一些模块可以在前后端实现共用,这是因为很多API在各个宿主环境下都提供。但是在实际情况中,前后端的环境是略有差别的

模块的侧重点

浏览器端的JavaScript需要经历从同一个服务器端分发到多个客户端执行,而服务器端JavaScript则是相同的代码需要多次执行。前者的瓶颈在于带宽,后者的瓶颈在于CPU和内存等资源。前者需要通过网络加载代码,后者从磁盘中加载,两者的加载速度不在一个数量级上。

纵观Node的模块引入过程,几乎都是同步的。尽管与Node强调异步的行为有些相反,但它是合理地。但是如果前端模块也采用同步的方式引入,那么会在用户体验上造成很大的问题。UI在初始化过程中需要花费很多时间来等待脚本加载完成。

CommonJS为后端制定的规范并不完全适合前端的应用场景。AMD规范最终在前端应用场景中胜出,它全称Asynchronous Module Definition,即是“异步模块定义”,除此之外还有玉伯定义的CMD规范。

AMD规范

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

1
define(id?, dependencies?, factory);

它的模块id和依赖是可选的,与Node模块相似的地方在于factory的内容就是实际代码的内容。
简单例子

1
2
3
4
5
6
7
define(function() {
var exports = {};
exports.sayHello = function () {
alert("hello" + module.id);
};
return exports;
});

不同之处在于AMD模块需要用define来明确定义一个模块,而Node实现中是隐式包装的,它们的目的是进行作用域隔离,仅在需要的时候被引入,避免掉过去那种通过全局变量或者全局命名空间的方式,以免变量污染和不小心被修改。另一个区别则是内容需要通过返回的方式实现导出。

CMD规范

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

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

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

1
define(factory);

在依赖部分,CMD支持动态引入

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

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

兼容多种模块规范

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

异步I/O

在众多高级语言或运行平台中,将异步作为主要编程方式和设计理念的,Node是首个。
伴随着异步I/O的还有事件驱动和单线程,它们构成Node的基调。

为什么要异步I/O

在跨网络的结构下,并发已经是现代编程中的标配了。具体到实处,则可以从用户体验和资源分配这两个方面说起。

用户体验

提高响应速度,一个资源的获取不会阻塞另一个资源。

下表列出了从CPU一级缓存到网络的数据访问所需要的开销。
avatar

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

资源分配

计算机在发展过程中将组件进行了抽象,分为I/O设备和计算设备。
假设业务场景中有一组互不相关的任务需要完成,现在的主流方法有以下两种
1.单线程串行依次执行
2.多线程并行完成
如果创建多线程的开销小于并行执行,那么多线程的方式是首选。多线程的代价在于创建线程和执行期线程上下文切换的开销较大。另外,在复杂的业务中,多线程编程经常面临锁、状态同步等问题。但是多线程在多核CPU上能够有效提升CPU的利用率。

单线程串行执行易于表达,缺点在于性能,任意一个略慢的任务都会导致后续执行代码被阻塞,在计算机资源中,通常I/O与CPU计算之间是可以并行进行的。但是同步编程模型导致的问题是,I/O的进行会让后续任务等待,这造成资源不能被更好的利用。

操作系统会将CPU的时间片分配给其余进程,以公平而有效地利用资源。基于这一点,有的服务器为了提升响应能力,会通过启动多个工作进程来为更多的用户服务。但是对于这一组任务而言,它无法分发任务到多个进程上,所以依然无法高效利用资源,结束所有任务所需的时间将会较长。这种模式类似于加三倍服务器,达到占用更多资源来提升服务速度,它并没有真正改善问题。

添加硬件资源是一种提升服务质量的方式,但它不是唯一的方式。

Node给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以更好地使用CPU.

为了弥补单线程无法利用多核CPU的缺点,Node提供了类似前端浏览器中Web Workers的子进程,该子进程通过工作进程高效利用CPU和I/O。

异步I/O的调用示意图如下:
avatar

异步I/O实现现状

下面我们来看看操作系统对异步I/O实现的支持状况

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

操作系统内核对于I/O只有两种方式:阻塞与非阻塞。在调用阻塞I/O一定要等到系统内核层面完成所有操作后,调用才结束。
avatar

阻塞I/O造成CPU等待I/O,浪费等待时间。为了提高性能,内核提供了非阻塞I/O。非阻塞I/O跟阻塞I/O的差别为调用之后会立即返回
avatar

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

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

但是由于完整I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用I/O操作判断是否完成技术——轮询。

非阻塞带来的麻烦是需要轮询确认是否完全完成数据获取,它会让CPU处理状态判断,是对CPU资源的浪费。

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

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

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

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

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

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

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

理想的非阻塞异步I/O

epoll休眠期间CPU几乎是闲置的,对于当前线程而言利用率不够。

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

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

现实中的异步I/O

前面我们将场景限定在了单线程的状况下,多线程的方式可以通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就实现了异步I/O(尽管它是模拟的)
avatar

glibc的AIO便是典型,但它存在很多缺陷,不推荐采用。libeio实际上依然是采用线程池与阻塞I/O模拟异步I/O。在Node v0.9.3中,自行实现了线程池来完成异步I/O.

windows下的IOCP,在某种程度上提供了理想的异步I/O,但是它的内部其实仍然是线程池原理,不同之处在于这些线程池由系统内核接手管理。

由于Windows平台和nix平台的差异,Node提供了libuv作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成。
avatar
这里I/O不仅仅只限于磁盘文件的读写。
nix将计算机抽象了一番,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为了文件。另外Node是单线程的,仅仅只是JavaScript执行在单线程中。无论是*nix还是Windows平台,内部完成I/O任务的另有线程池。

Node的异步I/O

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

事件循环

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

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

观察者

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

浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在Node中,事件主要来源于网路请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网路I/O观察者等。观察者将事件进行了分类。
事件循环是一个典型的生产者/消费者模型,异步I/O、网络请求等则是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

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

请求对象

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

从JavaScript发起调用到内核执行完I/O操作,回调函数执行的过渡过程中,存在一种中间产物,它叫请求对象

我们以fs.open()方法为例:

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

fs.open的作用是根据指定路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有I/O操作的初始操作。从前面的代码中可以看到,JavaScript层面的代码通过调用C++核心模块进行下层的操作
avatar
从JavaScript调用Node的核心模块,核心模块调用C++内建模块,内建模块通过libuv进行系统调用。在uv_fs_open()的调用过程中,我们创建了一个FSReqWrap请求对象。从JavaScript层传入的参数和当前方法都被封装在这个请求对象中,其中我们最为关注的回调函数则被设置在这个对象的oncomplete_sym属性上:

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

对象包装完毕后,在Windows下,则调用QueueUserWorkItem()方法将这个FSReqWrap对象推入线程池中等待执行。

1
QueueUserWorkItem(&uv_fs_thread_proc,req,WT_EXECUTEDEFAULT)

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

至此,JavaScript调用立即返回,javascript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不管它是否阻塞I/O,都不会影响到JavaScript线程的后续执行。

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

执行回调

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

1
PostQueuedCompletionStatus(loop)->locp,0,0,&((req)=>overlapped))

PostQueuedCompletionStatus方法的作用是向IOCP提交执行状态,并将线程归还线程池。可以通过GetQueuedCompletionStatus提取提交的状态。
在每次Tick的执行中,它会调用IOCP相关的GetQueuedCompletionStatus方法检查线程池中是否有执行完的请求,如果有,会将请求对象加入到I/O观察者的队列中,然后将其当做事件处理。
I/O观察者回调函数的行为就是取出请求对象的result属性作为参数,取出oncomplete_sym属性作为方法,然后调用执行,以此达到调用JavaScript中传入的回调函数的目的。
至此,整个异步I/O的流程完全结束
avatar
事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素。

非I/O的异步API

Node中还存在与I/O无关的异步API

定时器

setTimeout()和setInterval()与浏览器中的API是一致的,分别用于单次和多次定时执行任务。调用它们创建的定时器会被插入到定时器观察者内部的一个红黑树中,每次Tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数将立即执行。

下图主要是setTimeout的行为,setInterval的区别是后者是重复性的检测和执行。

定时器并非精确的。尽管事件循环十分快,但是如果某一次循环占用的时间较多,那么下次循环时,它也许已经超时很久了。
avatar

process.nextTick()

很多人也许为了立即异步执行一个任务,会这样调用:

1
2
3
setTimeout(function () {
// TODO
}, 0);

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

1
2
3
4
5
6
7
8
9
10
11
12
13
process.nextTick = function(callback) {
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)

setImmediate()

相关代码:

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

和nextTick的区别

1
2
3
4
5
6
7
process.nextTick(function () {
console.log('nextTick延迟执行');
});
setImmediate(function () {
console.log('setImmediate延迟执行');
});
console.log('正常执行');

结果如下:
正常执行
nextTick延迟执行
setImmediate延迟执行

原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check观察者。在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者

在具体实现上,process.nextTick的回调函数保存在一个数组中,setImmediate()的结果则是保存在链表中。在行为上,process.nextTick在每轮循环中会将数组中的回调函数全部执行,而setImmediate在每轮循环中执行链表中的一个回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
process.nextTick(function () {
console.log('nextTick延迟执行1');
});
process.nextTick(function () {
console.log('nextTick延迟执行2');
});
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

事件驱动与高性能服务器

异步的实现原理也基本勾勒出了事件驱动的实质,即通过主循环加事件触发的方式来运行程序。

利用Node构建Web服务器,正是在这样一个基础上实现的。
avatar

几种经典服务器模型的优缺点

  1. 同步式 对于同步式的服务,一次只能处理一个请求,并且其余请求都处于等待状态
  2. 每进程/每请求 为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多
  3. 每线程/每请求 为每个请求启动一个线程来处理。尽管线程比进程要轻量,但是由于每个线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢。

Node通过事件驱动的方式处理请求,无需为每个请求创建额外的线程,可以节省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。这使得服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受线程上下文切换开销的影响,这是Node高性能的原因之一。

异步编程

函数式编程

在开始异步编程之前,先得知晓JavaScript现今的回调函数和深层嵌套的来龙去脉。

高阶函数

高阶函数是可以将函数作为参数,或是将函数作为返回值得函数

1
2
3
4
5
funtio foo(x) {
return function () {
return x;
}
}

对于程序编写,高阶函数除了普通意义的函数调用返回外,还形成了一种后续传递风格的结果接收方式。后续传递风格的程序编写将函数的业务重点从返回值转移到了回调函数中:

1
2
3
function foo(x, bar) {
return bar(x);
}

对于相同的foo函数,传入的bar参数不同,则可以得到不同的结果.
结合Node提供的最基本的事件模块可以看到,事件的处理方式正式基于高阶函数的特性来完成的。在自定义事件实例中,通过为相同事件注册不同的回调函数,可以很灵活地处理业务逻辑

1
2
3
4
var emitter = new events.EventEmitter();
emitter.on('event_foo', function() {
// TODO
});

偏函数用法

偏函数用法是指创建一个调用另外一个部分——参数或变量已经预置的函数——的函数的用法。

1
2
3
4
5
6
7
8
var toSring = Object.prototype.toString();

var isString = function (obj) {
return toString.call(obj) == '[object String]'
};
var isFunction = function (obj) {
return toString.call(obj) == '[object Function]'
};

这段代码存在的问题是,我们需要重复定义一些相似的函数,如果有更多的isXXX(),就会出现更多的冗余代码。

1
2
3
4
5
6
7
var isType = function (type) {
return function (obj) {
return toString.call(obj) == '[object ' + type + ']';
}
}
var isString = isType("String");
var isFunction = isType("Function");

这种通过指定部分参数来产生一个新的定制函数的形式就是偏函数。

异步编程的优势与难点

优势

Node带来的最大特性莫过于基于事件驱动的非阻塞I/O模型,使CPU与I/O并不相互依赖等待,让资源得到更好的利用。
avatar

avatar

难点

1.异常处理

过去我们处理异常时,通常使用类Java的try/catch/final进行异常捕获,

1
2
3
4
5
try {
JSON.parse(json);
} catch (e) {
// TODO
}

异步I/O的实现主要包含两个阶段,提交请求和处理结果。这两个阶段有事件循环调度,两者彼此不关联。异步方法则通常在第一个阶段提交请求后立即返回,因为异常并不一定发生在这个阶段,try/catch的功效在此处不会发挥任何作用:

1
2
3
4
5
6
7
8
9
var async = function (callback) {
process.nextTick(callback);
};

try {
async(callback);
} catch (e) {
// TODO
}

调用async()方法后,callback被存放起来,直到下一个事件循环(Tick)才会取出来执行。尝试对异步方法try/catch操作只能捕获当次事件循环内的异常,对callback执行时抛出的异常将无能为力。

Node在处理异常上形成了一种约定,将异常作为回调函数的第一个参数返回,如果为空值,表明没有异常抛出

1
2
3
async(function (err, res) {
// TODO
});

2.难点2:函数嵌套过深

1
2
3
4
5
6
7
fs.readdir(path.join(__dirname, '..'), function (err, files) {
files.forEach(function (filename, index) {
fs.readFile(filename, 'utf8', function (err, file) {
// TODO
});
});
});

3.难点3:阻塞代码
可能有开始者会这样实现sleep(1000)

1
2
3
4
5
var start = new Date();
while (new Date() - start < 1000) {
// TODO
}
// 需要阻塞的代码

这段代码会持续占用CPU进行判断,与真正的线程沉睡相去甚远,完全破坏了事件循环的调度。由于Node单线程的原因,CPU资源全部会用于这段代码服务,导致其余任何请求都会得不到响应。

4.难点4多线程编程

服务器如果是多核CPU,单个Node进程实质上是没有充分利用多核CPU的。
浏览器提出了Web Workers
avatar

Node借鉴了这个模式,child_process是其基础API,cluster模块是更深层次的应用。

5.难点5:异步转同步
偶尔出现的同步需求将会因为没有同步API让开发者突然无所适从。

异步编程解决方案

本节将展开各个典型的解决方案

  1. 事件发布/订阅模式
  2. Promise/Deferred模式
  3. 流程控制库

事件发布/订阅模式

事件监听器模式是一种广泛用于异步编程的模式,是回调函数的事件化,又称发布/订阅模式

events模块是发布/订阅模式的一个简单实现,Node中部分模块都继承自它。它具有addListener/on()、once()、removeListener()、removeAllListeners()和emit()等基本的事件监听模式的方法。
例子

1
2
3
4
5
6
// 订阅
emitter.on("event1", function (message) {
console.log(message);
});
// 发布
emitter.emit("event1", "message");

事件发布/订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数又称为事件侦听器。通过emit()发布事件后,消息会立即传递给当前事件的所有侦听器执行。侦听器可以很灵活地添加和删除,使得事件和具体处理逻辑可以很轻松地关联和解耦。

Node对事件发布/订阅的机制做了一些额外的处理

  1. 如果对一个事件添加了超过10个侦听器,将会得到一条警告。一方面侦听器太多可能会导致内存泄漏,另一方面,由于事件发布会引起一系列侦听器执行,可能存在过多占用CPU的情景。
  2. 为了处理异常,EventEmitter对象对error事件进行了特殊对待,如果运行期间的错误触发了error事件,EventEmitter会检查是否有error事件添加过侦听器。如果有,这个错误由侦听器处理,否则这个错误将会作为异常抛出。如果外部没有捕获这个异常,将会引起线程退出。一个健壮的EventEmitter实例应该对error事件做处理。

1.继承events模块

实现一个继承EventEmitter的类是十分简单的

1
2
3
4
5
6
var events = require('events');

function Stream() {
events.EventEmitter.call(this);
}
util.inherits(Stream, events.EventEmitter);

开发者可以通过这样的方式轻松继承EventEmitter类,利用事件机制解决业务问题

2.利用事件队列解决雪崩问题

在事件订阅/发布模式中,通常有一个once方法,通过它添加的侦听器只能执行一次,在执行之后就会将它与事件的关联移除。

所谓雪崩问题,就是在高访问量、大并发量的情况下缓存失效的情景,此时大量的请求同时涌入数据库中,数据库无法同时承受如此大的查询请求,进而往前影响到网站整体的响应速度。

以下是一条数据库查询语句的调用:

1
2
3
4
5
var select = function (callback) {
db.select("sql", function (results) {
callback(results);
});
};

如果访问量巨大,同一句sql会被发送到数据库中反复查询,会影响服务的整体性能。一种改机方案是添加一个状态锁:

1
2
3
4
5
6
7
8
9
10
var status = "ready";
var select = function (callback) {
if (status === "ready") {
status = "pending";
db.select("sql", function (results) {
status = "ready";
callback(results);
});
}
};

这种方案只有第一次调用是生效的,后续的select是没有数据服务的

1
2
3
4
5
6
7
8
9
10
11
12
var proxy = new events.EventEmitter;
var status = "ready";
var select = function (callback) {
proxy.once("selected", callback);
if (status === "ready") {
status = "pending";
db.select("sql", function (results) {
proxy.emit("selected", results);
status = "ready";
});
}
};

3.多异步之间的协作方案

这里我们尝试用原生代码解决“难点2”中为了最终结果的处理而导致可以并行调用但实际只能串行执行的问题。这里以渲染页面所需的模板读取、数据读取和本地化资源读取为例简要介绍一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 哨兵变量
var count = 0;
var results = {};
var done = function (key, value) {
results[key] = value;
count++;
if (count === 3) {
// 渲染页面
render(results);
}
}

fs.readFile(template_path, "utf8", function (err, template) {
done("template", template);
});
db.query(sql, function (err, data) {
done("data", data);
});
l1ion.get(function (err, resources) {
done("resources", resources);
});

使用偏函数来处理哨兵变量和第三方函数的关系

1
2
3
4
5
6
7
8
9
10
var after = function (times, callback) {
var count = 0, results = {};
return function (key, value) {
results[key] = value;
count++;
if (count === times) {
callback(results);
}
}
}

上述方案实现事件与侦听器多对一的关系,下面可以实现多对多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var emitter = new events.Emitter();
var done = after(times, render);

emitter.on("done", done);
emitter.on("done", other);

fs.readFile(template_path, "utf8", function (err, template) {
emitter.emit("done", "template", template);
});
db.query(sql, function (err, data) {
emitter.emit("done", "data", data);
});
l1ion.get(function (err, resources) {
emitter.emit("done", "resources", resources);
});

4.EventProxy的原理(???略)

EventProxy来自于Backbone的事件模块。

5.EventProxy的异常处理(???略)

Promise/Deferred模式

使用事件的方式时,执行流程需要被预先设定。即便是分支,也需要预先设定,这是由发布/订阅模式的运行机制所决定的。

1
2
3
4
5
$.get('/api', {
success: onSuccess,
error: onError,
complete: onComplete
});

必须严谨地设置目标,那么是否有一种先执行异步调用,延迟传递处理的方式呢?Promise/Deferred模式就是这样,jQuery重写了Ajax部分

1
2
3
4
$.get('/api')
.success(onSuccess)
.error(onError)
.complete(onComplete);

这使得即使不调用success(),error()等方法,Ajax也会执行。在原始的API中,一个事件只能处理一个回调,而通过Deffed对象,可以对事件加入任意的业务逻辑

1
2
3
$.get('api')
.success(onSuccess1)
.success(onSuccess2)

CommonJS草案目前已经抽象出了Promises/A、Promises/B、Promises/D这样典型的异步Promise/Deferred模型。
Promise/Deferred模式在一定程度上缓解了深度嵌套问题

1.Promises/A

Promises/A提议对单个异步操作做出了这样的抽象定义

  1. Promise操作只会处在3种状态的一种:未完成态、完成态和失败态
  2. Promise的状态只会出现从未完成态向完成态或失败态转化,不能逆反。完成态和失败态不能互相转化。
    3.Promise的状态一旦转化,将不能被更改

在API的定义上,一个Promise对象只要具备then方法即可,但是对于then方法,有以下简单的要求

  1. 接受完成态、错误态的回调方法。在操作完成或出现错误时,将会调用对应方法。
  2. 可选地支持progress事件回调作为第三个方法
  3. then()方法只接受function对象,其余对象将被忽略
  4. then()方法继续返回Promise对象,以实现链式调用
1
then(fulfilledHandler, errorHandler, progressHandler)

利用Promises/A提议的模式,我们可以对一个典型的响应对象进行封装

1
2
3
4
5
6
7
8
9
10
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log("BODY:" + chunk);
});
res.on('end', function () {
// Done
});
res.on('error', function () {
// Error
});

可以转换为如下简略形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var promisify = function (res) {
var deferred = new Deferred();
var result = '';
res.on('data', function (chunk) {
result += chunk;
deferred.progress(chunk);
});
res.on('end', function () {
deferred.resolve(result);
});
res.on('error', function (err) {
deferred.reject(err);
});
return deferred.promise;
}

promisify(res).then(function () {
// Done
}, function (err) {
// Error
}, function (chunk) {
console.log('BODY:' + chunk);
});

Deferred主要是用于内部,用于维护异步模型的状态;Promise则作用于外部,通过then方法暴露给外部以添加自定义逻辑。Promise和Deffered的整体关系如下
avatar

2.Promise中的多异步协作
这里给出一个简单的原型实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Deffered.prototype.all = function (promises) {
var count = promises.length;
var that = this;
var results = [];
promises.forEach(function (promise, i) {
promise.then(function (data) {
count--;
results[i] = data;
if (count === 0) {
that.resolve(results);
}
}, function (err) {
that.reject(err);
});
});
return this.promise;
}

在实际使用中可以安装when和Q模块,它们是完整的Promise提议的实现。

3.Promise的进阶知识

Promise模式比原始的事件侦听和触发略微优美,它的缺点则是需要为不同的场景封装不同的API,没有直接的原生事件那么灵活。

1
2
3
4
5
6
7
8
9
obj.api1(function (value1) {
obj.api2(value1, function (value2) {
obj.api3(value2, function (value3) {
obj.api4(value3, function (value4) {
callback(value4);
});
});
});
});

我们通过普通函数展开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var handler1 = function (value1) {
obj.api2(value1, handler2);
}
var handler2 = function (value2) {
obj.api3(value2, handler3);
}
var handler3 = function (value3) {
obj.api4(value3, handler4);
}
var handler4 = function (value4) {
callback(value4);
}

obj.api1(handler1);

1.支持序列执行的Promise

1
2
3
4
5
6
7
8
9
10
11
promise()
.then(obj.api1)
.then(obj.api2)
.then(obj.api3)
.then(obj.api4)
.then(function (value4) {
// Do something with value4
}, function (error) {
// Handle any error from step1 through step4
})
.done();

尝试改造一下代码以实现链式调用
avatar

avatar

avatar

要让Promise支持链式执行,主要通过以下两个步骤

  1. 将所有的回调都存到队列中
  2. Promise完成时,逐个执行回调,一旦检测到了返回了新的Promise对象,停止执行,然后将当前Deferred对象的Promise引用改变为新的Promise对象,并将队列中余下的回调转交给它。

2.将API Promise化
avatar

流程控制库

1.尾触发与Next

还有一类方法是需要手工调用才能持续执行后续调用的,我们将此类方法叫做尾触发,常见的关键词是next。

先看一下Connect的API暴露方式

1
2
3
4
5
6
7
8
9
10
var app = connect();
// Middleware
app.use(connect.staticCache());
app.use(connect.static(__dirname + '/public'));
app.use(connect.cookieParser());
app.use(connect.session());
app.use(connect.query());
app.use(connect.bodyParser());
app.use(connect.csrf());
app.listen(3001);

中间件利用了尾触发的机制

1
2
3
function (req, res, next) {
// 中间件
}

每个中间件传递请求对象,响应对象和尾触发函数,通过队列形成一个处理流
avatar

中间件机制使得在处理网路请求时,可以像面向切面编程一样进行过滤、验证、日志等功能,而不与具体业务逻辑产生关联,以致产生耦合。

1
2
3
4
5
6
7
8
9
10
11
function createServer() {
function app(req, res){ app.handle(req, res); }
utils.merge(app, proto);
utils.merge(app, EventEmitter.prototype);
app.route = '/';
app.stack = [];
for (var i = 0; i < arguments.length; ++i) {
app.use(arguments[i]);
}
return app;
}

stack属性是这个服务器内部维护的中间件队列,通过调用use()方法我们可以将中间件放进队列中。

1
2
3
4
5
app.use = function(route, fn) {
// some code
this.stack.push({ route: route, handle: fn });
return this;
}

接下来结合http模块实现监听

1
2
3
4
app.listen = function () {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
}

最后回到app.handle方法,每一个监听到的网络请求都将从这里开始处理。

1
2
3
4
app.handle = function (req, res, out) {
// some code
next();
}

原始的next方法较为复杂,下面是简化后的内容,其原理十分简单,取出队列中的中间件并执行,同时传入当前方法以实现递归调用,达到持续触发的目的:

1
2
3
4
5
6
7
function next(err) {
// some code
// next callback
layer = stack[index++];

layer.handle(req, res, next);
}

尽管中间件这种尾触发模式并不要求每个中间方法都是异步的,但是如果每个步骤都采用异步来完成,实际上只是串行化的处理,没办法通过并行的异步调用来提升业务的处理效率。流式处理可以将一些串行的逻辑扁平化,但是并行逻辑处理还是需要搭配事件或者Promise完成的,这样业务在纵向和横向都能各自清晰。

尾触发十分适合处理网络请求的场景。将复杂的处理逻辑拆解为简洁、单一的处理单元,逐层次地处理请求对象和响应对象。

2.async
async提供了20多个方法用于处理异步的各种协作模式
1.异步的串行执行
series

1
2
3
4
5
6
7
8
9
10
async.series([
function (callback) {
fs.readFile('file1.txt', 'uft-8', callback);
},
function (callback) {
fs.readFile('file2.txt', 'uft-8', callback);
}
], function (err, results) {
// results => [file1.txt, file2.txt]
});

2.异步的并行执行
当我们需要通过并行来提升性能时,async提供了parallel()方法,用以并行执行一些异步操作

1
2
3
4
5
6
7
8
9
10
async.parallel([
function (callback) {
fs.readFile('file1.txt', 'uft-8', callback);
},
function (callback) {
fs.readFile('file2.txt', 'uft-8', callback);
}
], function (err, results) {
// results => [file1.txt, file2.txt]
});

3.异步调用的依赖处理
当前一个的结果是后一个调用的输入时,async提供了waterfall()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async.waterfall([
function (callback) {
fs.readFile('file1.txt', 'uft-8', function (err, content) {
callback(err, content);
});
},
function (arg1, callback) {
// arg1 => file2.txt
fs.readFile(arg1, 'uft-8', function (err, content) {
callback(err, content);
});
},
function (arg1, result) {
// arg1 => file3.txt
fs.readFile(arg1, 'utf-8', function (err, content) {
callback(err, content);
});
}
], function (err, result) {
// result => file4.txt
})

4.自动依赖处理
在现实的业务环境中,具有许多复杂的依赖关系
假设我们的业务场景如下:

  1. 从磁盘读取配置文件
  2. 根据配置文件连接MongoDB
  3. 根据配置文件连接Redis
  4. 编译静态文件
  5. 上传静态文件到CDN
  6. 启动服务器

auto()方法能根据依赖关系自动分析,以最佳的顺序执行以上业务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var deps = {
readConfig: function (callback) {
// read config file
callback();
},
connectMongoDB: ['readConfig', function (callback) {
callback();
}],
connectRedis: ['readConfig', function () {
callback();
}],
complieAsserts: function (callback) {
callback();
},
uploadAsserts: ['complieAsserts', function (callback) {
callback();
}],
startup: ['connectMongoDB', 'connectRedis', 'uploadAsserts', function (callback) {
// startup
}]
}
async.auto(deps);

3.step(略)

4.wind(略)

异步并发控制

使用下面代码,发起100次异步调用

1
2
3
for (var i = 0; i < 100; i ++) {
async();
}

如果并发量过大,下层服务器将会吃不消。

bagpipe的解决方案

如何对既有的异步API添加过载保护,我们期望的当然不是去改动API。bagpipe的解决方案是这样的

  1. 通过一个队列来控制并发量
  2. 如果当前活跃(指调用发起但未执行回调)的异步调用量小于限定值,从队列中取出执行
  3. 如果活跃调用达到限定值,调用暂时存放在队列中
  4. 每个异步调用结束时,从队列中取出新的异步调用执行
1
2
3
4
5
6
7
8
9
10
11
var Bagpipe = require('bagpipe');
// 设定最大并发数为10
var bagpipe = new Bagpipe(10);
for(var i = 0; i < 100; i++) {
bagpipe.push(async, function () {
// 异步回调执行
});
}
bagpipe.on('full', function (length) {
console.warn('队列拥堵,当前长度为:' + length);
});

实现???暂略

async的解决方案???暂略

总结

从社区和过往的经验而言,JavaScript异步编程的难题已经基本解决,无论是通过事件,还是通过Promise/Deferred模式,或者流程控制库。

内存控制

Node内存控制在海量请求的前提下需要进行探讨。

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

JavaScript由垃圾回收机制来进行自动内存管理,内存管理的好坏、垃圾回收状况是否优良,都会对服务构成影响。这一切都与Node的JavaScript执行引擎V8息息相关。

V8的内存限制

在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB)。

因为Node中使用的JavaScript对象基本上都是通过V8自己的方式进行分配和管理的。而V8会限制使用的内存量。

V8的对象分配

在V8中,所有的JavaScript对象都是通过堆来进行分配的。Node提供了V8中内存使用量的查看方式

1
2
3
4
5
$ node
> process.memoryUsage();
{ rss: 14958592,
heapTotal: 7195904,
heapUsed: 2821496 }

heapTotal和heapUsed是V8的堆内存使用情况,前者是已申请到的堆内存,后者是当前使用的量。

如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。

V8限制堆内存的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。深层原因是V8的垃圾回收机制的限制。以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至需要1s以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。因此,直接限制堆内存是一个好的选择。

当然V8依然提供了选项让我们使用更多的内存。Node在启动时可以传递–max-old-space-size或–max-new-space-size来调整内存限制的大小

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

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

V8的垃圾回收机制

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

V8的垃圾回收策略主要基于分代式垃圾回收机制。因为在实际应用中,对象的生存周期长短不一,不同的算法的算法只能针对特定情况具有良好的效果。

1.1-V8的内存分代

将内存分为新生代和老生代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象
avatar

V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。前面提到的内存限制调整的就是新老生代。这两个最大值需要在启动时就指定,这意味着V8使用的内存没有办法根据使用情况自动扩充,当内存分配过程中超过极限值,就会引起进程出错。

在64位系统和32位系统下会分别只能使用约1.4GB和约0.7GB的大小。这个限制在源码中可以找到。源码中老生代设置在64位系统下为1400MB,在32位系统下为700MB

新生代内存由两个reserved_semispace_size_所构成。reserved_semispace_size_在64和32位系统上分别为16MB和8MB

V8堆内存的最大保留空间为 4 * reserved_semispace_size_ + max_old_generation_size_;在64位系统上为1464MB,32位系统上为732MB.

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

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

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

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

是故,V8的堆内存示意图应当如图
avatar

当一个对象经过多次复制依然存活时,它随后会被移动到老生代中,From空间中存活对象在复制到To空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中,这个过程也叫晋升。

晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制。

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

另一个判断条件是当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代空间中
avatar

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

1.3-Mark-Sweep & Mark-Compact

对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。为此,老生代采用Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。

Mark-Sweep是标记清除的意思。在标记阶段遍历堆中所以对象,并标记活着的对象,清除阶段,清除没有被标记的对象。死对象在老生代中只占较少部分,这是Mark-Sweep能高效处理的原因。下图,黑色部分标记为死亡对象
avatar

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

Mack-Compact是标记整理的意思,是在在Mark-Sweep的基础上演变而来的,它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。下图白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。
avatar

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

在V8的回收策略中两者是结合使用的。
avatar

在取舍中,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。

1.4-Incremental Marking

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

为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,每做完一“步进”将让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。
avatar

V8后续还引入了延迟清理与增量式整理,让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。

总结

想要高性能的执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。

服务端在访问量大、内存大量占用的时候,老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张,甚至溢出。

查看垃圾回收日志

在启动时添加–trace_gc参数。执行结束后,将会在gc.log文件中得到所有垃圾回收信息:

1
node --trace_gc -e "var a = [];for (var i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log

avatar
通过分析垃圾回收日志,可以了解垃圾回收的运行状况,找出垃圾回收的哪些阶段比较耗时,触发的原因是什么。

通过在Node启动时使用–prof参数,可以得到V8执行时的性能分析数据,其中包含了垃圾回收执行时占用的时间。

1
2
3
4
// test01.js
for (var i = 0; i < 1000000; i++) {
var a = {};
}

执行

1
$ node --prof test01.js

得到一个日志文件,该日志文件基本不具备可读性,v8提供了linux-tick-processor工具用于统计日志信息。将该目录添加到环境变量PATH中调用:

1
$ linux-tick-processor v8.log

统计结果
avatar
其中垃圾回收部分

1
2
3
[GC]:
ticks total nonlib name
2 5.4%

由于不断分配对象,垃圾回收所占的时间为5.4%。这意味着事件循环执行1000毫秒要给出54毫秒的时间用于垃圾回收。

高效使用内存

作用域

提到如何触发垃圾回收,第一个要介绍的是作用域。在JavaScript中能形成作用域的有函数调用、with以及全局作用域

1
2
3
var foo = function () {
var local = {};
};

foo函数在每次调用时会创建对应的作用域,函数执行结束后,该作用域将会销毁。同时作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁。只被局部变量引用的对象存活周期较短。在这个示例中,由于对象非常小,将会分配在新生代中的From空间。在作用域释放后,局部变量local失效,其引用的对象将会在下次垃圾回收时被释放。
1.1 标识符查找

与作用域相关的即是标识符查找。所谓标识符,可以理解为变量名。

1
2
3
var bar = function () {
console.log(local);
};

JavaScript在执行时会去查找该变量定义在哪里。它最先查找的是当前作用域,如果在当前作用域中无法找到该变量的声明,将会向上级的作用域里查找,直到查到为止。

1.2 作用域链

1
2
3
4
5
6
7
8
9
10
11
12
var foo = function () {
var local = 'local var';
var bar = function () {
var local = 'another var';
var baz = function () {
console.log(local);// another var
};
baz();
};
bar();
};
foo();

存在作用域链,不断向上查找。了解作用域,有助于我们了解变量的分配和释放。

1.3 变量的主动释放

如果变量是全局变量(不通过var声明或定义在global变量上),由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清理和整理的过程中,会被释放。

1
2
3
4
5
6
global.foo = 'a';
console.log(global.foo);
delete global.foo;// a
// 或者重新赋值
// global.foo = undefined; // or null
console.log(global.foo);

如果在非全局作用域中,想主动释放变量引用的对象,也可以通过这样的方式。虽然delete操作和重新赋值具有相同的效果,但是在V8中通过delete删除对象的属性有可能干扰V8的优化,所以赋值方式较好。

闭包

在JavaScript实现外部作用域访问内部作用域变量的方法叫做闭包

1
2
3
4
5
6
7
8
9
10
11
12
var foo = function () {
var bar = function () {
var local = "局部变量";
return function () {
return local;
};
};
var baz = bar();
console.log(baz());
};

foo();// "局部变量"

在外部作用域中还是无法直接访问local,但是若要访问它,只要通过这个中间函数稍作周转即可。闭包的问题在于,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。除非不再引用,才会逐步释放。

小结

在正常的JavaScript执行中,无法立即回收的内存由闭包和全局变量引用这两种情况。由于V8的内存限制,要注意此类变量的使用。

内存指标

一般而言,应用中存在一些全局性的对象是正常的,而且在正常使用中,变量都会自动释放回收。但是也会存在一些我们认为会回收但是却没有被回收的对象。

查看内存使用情况

除了process.memoryUsage(),os模块的totalmem()和freemem()也可以查看内存使用情况。

1.查看进程的内存占用

调用process.memoryUsage()可以查看Node进程的内存占用情况

1
2
3
4
5
$ node
> process.memoryUsage()
{ rss: 13852672,
heapTotal: 6131200,
heapUsed: 2757120 }

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

heapTotal和heapUsed对应的是V8的堆内存信息。heapTotal是堆中总共申请的内存量,heapUsed表示目前堆中使用中的内存量,单位都是字节。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var showMem = function () {
var mem = process.memoryUsage();
var format = function (bytes) {
return (bytes / 1024 / 1024).toFixed(2) + 'MB';
}
console.log('Process: heapTotal ' + format(mem.heapTotal) + ' heapUsed ' + format(mem.heapUsed) + ' rss ' + format(mem.rss));
console.log('-----------------------------');
}

var useMem = function () {
var size = 20 * 1024 * 1024;
var arr = new Array(size);
for (var i = 0; i < size; i++) {
arr[i] = 0;
}
return arr;
};

var total = [];

for (var j = 0; j < 15; j ++) {
showMem();
total.push(useMem());
}
showMem();

执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Process: heapTotal 6.23MB heapUsed 3.81MB rss 19.66MB
-----------------------------
Process: heapTotal 167.75MB heapUsed 164.33MB rss 181.11MB
-----------------------------
Process: heapTotal 327.76MB heapUsed 324.33MB rss 341.19MB
-----------------------------
Process: heapTotal 487.77MB heapUsed 484.34MB rss 501.32MB
-----------------------------
Process: heapTotal 647.78MB heapUsed 644.34MB rss 661.38MB
-----------------------------
Process: heapTotal 807.79MB heapUsed 804.34MB rss 821.39MB
-----------------------------
Process: heapTotal 967.80MB heapUsed 964.35MB rss 981.41MB
-----------------------------
Process: heapTotal 1130.32MB heapUsed 1123.57MB rss 1141.77MB
-----------------------------
Process: heapTotal 1290.33MB heapUsed 1283.57MB rss 1301.79MB
-----------------------------

<--- Last few GCs --->

[39083:0x103800000] 1338 ms: Mark-sweep 1283.5 (1287.8) -> 1283.5 (1287.8) MB, 110.8 / 0.0 ms (average mu = 0.230, current mu = 0.003) last resort GC in old space requested
[39083:0x103800000] 1450 ms: Mark-sweep 1283.5 (1287.8) -> 1283.5 (1287.8) MB, 111.4 / 0.0 ms (average mu = 0.131, current mu = 0.000) last resort GC in old space requested

可以看到,3个值都在不断增长,在接近1500MB的时候,无法继续分配内存,然后进程内存溢出了。

查看系统的内存占用

os模块的totalmem()和freemem()用于查看操作系统的内存使用情况,它们分别返回系统的总内存和闲置内存,以字节为单位

1
2
3
4
5
6
7
8
9
10
11
12
13
var os = require('os');

var showMem = function () {
var format = function (bytes) {
return (bytes / 1024 / 1024).toFixed(2) + 'MB';
}
console.log('os.totalmem', format(os.totalmem()));
console.log('os.freemem', format(os.freemem()));
}

showMem();
// os.totalmem 8192.00MB
// os.freemem 452.36MB

可以看到我的电脑的总内存为8GB,当前闲置内存为452.36MB

堆外内存

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

我们将Array换成Buffer,将size变大

1
2
3
4
5
6
7
8
var useMem = function () {
var size = 200 * 1024 * 1024;
var buffer = new Buffer(size);
for (var i = 0; i < size; i ++) {
buffer[i] = 0;
}
return buffer;
}

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Process: heapTotal 6.23MB heapUsed 3.81MB rss 19.80MB
-----------------------------
Process: heapTotal 8.23MB heapUsed 4.53MB rss 221.43MB
-----------------------------
Process: heapTotal 8.23MB heapUsed 4.54MB rss 422.14MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 4.01MB rss 593.79MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 4.01MB rss 745.22MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 3.77MB rss 945.27MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1145.29MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1142.21MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1194.72MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1306.51MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1320.05MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.72MB rss 1382.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1582.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1782.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1955.04MB
-----------------------------
Process: heapTotal 7.73MB heapUsed 3.71MB rss 1961.53MB
-----------------------------
(node:39922) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.

heapTotal和heapUsed的变化极小,唯一变化的是rss值,并且该值已经超过V8的限制值。这是因为Buffer对象不同于其他对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制。

小结

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

内存泄漏

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

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

通常由如下原因:

  1. 缓存
  2. 队列消费不及时
  3. 作用域未释放

慎将内存当做缓存

在Node中,一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。

如果需要,只要限定缓存对象的大小,加上完整的过期策略以防止内存无限制增长,还是可以一用的

另一个案例在于模块机制。所有模块都会通过编译执行,然后被缓存起来。由于通过exports导出的函数,可以访问文件模块中的私有变量,这样每个文件模块在编译执行后形成的作用域因为模块缓存的原因,不会被释放

1
2
3
4
5
6
7
(function (exports, require, module, __filename, __dirname) {
var local = "局部变量";

exports.get = function () {
return local;
};
});

由于模块的缓存机制,模块是常驻老生代的。在设计模块时,要小心内存泄漏的出现。

1
2
3
4
var leakArray = [];
exports.leak = function () {
leakArray.push('leak');
};

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

缓存的解决方案

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

目前较好的解决方案是采用进程外的缓存如Redis。外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能且进程间可以共享缓存。

关注队列状态

队列(数组对象)在消费者-生产者模型中经常充当中间产物,如果消费者速度低于生产速度,将会形成堆积。

解决方案是监控队列的长队,一旦堆积,通过报警通知相关人员。另一个解决方案是设置超时机制,通过回调函数传递超时异常,给消费速度一个下限值。

内存泄漏排查

排查内存泄漏的原因主要通过对堆内存进行分析而找到的,有许多工具用来定位Node应用的内存泄漏,如node-heapdump和node-memwatch等。???暂略

大内存应用

在Node中,不可避免地还是会存在操作大文件的场景。好在Node提供了stream模块用于处理大文件。Node中的大多数模块都有stream的应用。

由于V8的内存限制,我们无法通过fs.readFile和fs.writeFile直接进行大文件的操作,而改用fs.createReadStream和fs.createWriteStream方法通过流的方式实现对大文件的操作。

如果不需要进行字符串层面的操作,则不需要借助V8来处理,可以尝试进行纯粹的Buffer操作,这不会受到V8堆内存的限制。

理解Buffer

JavaScript对于字符串(string)的操作十分友好,无论是宽字节字符串还是单字节字符串,都被认为是一个字符串

1
2
3
console.log("0123456789".length);// 10
console.log("零一二三四五六七八九".length);// 10
console.log("\u00bd".length);// 1

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

Buffer结构

Buffer是一个像Array的对象,但它主要用于操作字节。

模块结构

Buffer是典型的JavaScript与C++结合的模块,它将性能相关部分用C++实现,将非性能相关的部分用JavaScript实现。
avatar
Buffer所占用的内存不是通过V8分配的,属于堆外内存。

由于Buffer太过常见,Node在进程启动时就已经加载了它,并将其放在全局对象(global)上。所以无需require()即可直接使用。

Buffer对象

Buffer对象类似于数组,它的元素为16进制的两位数

1
2
3
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>

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

1
2
3
4
5
6
7
8
9
10
11
var buf = new Buffer(100);
console.log(buf.length);// 100
console.log(buf[10]);// 0-255的随机值
buf[10]=100;
console.log(buf[10]);// 100
buf[10]=-100;
console.log(buf[10]);// 156,赋值小于0,逐次加256
buf[10]=300;
console.log(buf[10]);// 44,大于255,逐次减256
buf[10]=3.1415;
console.log(buf[10]);// 3,小数,舍弃小数部分

Buffer内存分配

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

为了高效使用申请来的内存,Node采用了slab分配机制。slab是一种动态内存管理机制,简单而言,slab就是一块申请好的固定大小的内存区域。具有如下3种状态

  1. full: 完全分配
  2. partial: 部分分配状态
  3. empty: 没有被分配状态
    当我们需要一个Buffer对象,可以通过以下方式分配指定大小的Buffer对象
    1
    new Buffer(size);

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

1
Buffer.poolSize = 8 * 1024;

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

1.分配小Buffer对象

如果指定Buffer的大小少于8KB,Node会按照小对象的方式进行分配。Buffer的分配过程中主要使用一个局部变量pool作为中间处理对象,处于分配状态的slab单元都指向它。以下是分配一个全新的slab单元的操作,它会将新申请的SlowBuffer对象指向它:

1
2
3
4
5
6
var pool;

function allocPool() {
pool = new SlowBuffer(Buffer.poolSize);
pool.used = 0;
}

avatar

在图6-2中,slab处于empty状态
构造小Buffer对象时的代码如下:

1
new Buffer(1024);

这次构造将会去检查pool对象,如果pool没有被创建,将会创建一个新的slab单元指向它:

1
if (!pool || pool.length - pool.used < this.length) allocPool();

同时当前Buffer对象的parent属性指向该slab,并记录下是从这个slab的哪个位置(offset)开始使用的,slab对象自身也记录被使用了多少字节

1
2
3
4
this.parent = pool;
this.offset = pool.used;
pool.used = this.length;
if (pool.used & 7) pool.used = (pool.used + 8) & ~7;

avatar

这时候的slab状态为partial

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

1
new Buffer(3000);

avatar

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

这里要注意的事项是,由于同一个slab可能分配给多个Buffer对象使用,只有这些小Buffer对象在作用域释放并都可以回收时,slab的8KB空间才会被回收。

2.分配大Buffer对象

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

1
2
3
Big buffer, just alloc one
this.parent = new SlowBuffer(this.length);
this.offset = 0;

这里的SlowBuffer类是在C++中定义的,虽然引用Buffer模块可以访问到它,但是不推荐直接操作它。

上面提到的Buffer对象都是JavaScript层面的,能够被V8的垃圾回收标记回收。但是其内部的parent属性指向的SlowBuffer对象却来自Node自身C++中的定义,所用内存不在V8的堆中。

3.小结

当进行小而频繁的Buffer操作时,采用slab的机制进行预先申请和事后分配,使得JavaScript到操作系统之间不必有过多的内存申请方面的系统调用。对于大块的Buffer而言,则直接使用C++层面提供的内存,而无需细腻的分配操作。

Buffer的转换

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

  1. ASCII
  2. UTF-8
  3. UTF-16LE/UCS-2
  4. Base64
  5. Binary
  6. Hex

字符串转Buffer

主要通过构造函数完成:

1
new Buffer(str, [encoding]);

通过构造函数转换的Buffer对象,存储的只能是一种编码类型。encoding参数不传递时,默认按UTF-8编码进行转码和存储。

一个Buffer对象可以存储不同编码类型的字符串转码的值

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

需要小心的是,每种编码所用的字节长度不同,将Buffer反转回字符串时需要谨慎处理。

Buffer转字符串

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

Buffer不支持的编码类型

isEncoding()函数来判断编码是否支持转换

1
Buffer.isEncoding(encoding);

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

Buffer的拼接

1
2
3
4
5
6
7
8
9
10
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对象。一旦输入流中有宽字节编码时,问题就会暴露出来。

1
data += chunk;

这句代码里隐藏了toString()操作,等价于

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

为了重现这个问题,我们将文件可读流的每次读取的Buffer长度限制为11

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

输出

1
床前明��光,疑���地上霜;举头��明月,���头思故乡。

由于我们限定了Buffer对象的长度为11,而中文字在UTF-8下占3个字节。所以会形成一些乱码。

setEncoding()与string_decoder()

可读流还有一个设置编码的方法

1
readable.setEncoding(encoding)

该方法的作用是让data事件中传递的不再是一个Buffer对象,而是编码后的字符串。
我们修改之前的程序

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

输出

1
床前明月光,疑是地上霜;举头望明月,低头思故乡。

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

事实上,在调用setEncoding()时,可读流对象在内部设置了一个decoder对象。每次data事件都通过该decoder对象进行Buffer到字符串的解码,然后传递给调用者。是故设置编码后,data不再收到原始的Buffer对象。decoder对象来自于string_decoder模块StringDecoder的实例对象,它的神奇之处在于:
avatar

StringDecoder在得到编码后,知道宽字节字符串在UTF-8编码下是以3个字节的方式存储的,所以第一个write()时,只输出前9个字节转码形成的字符,“月”字的前两个字节被保留在StringDecoder实例内部。第二次write()时,会将这2个剩余字节和后续11个字节组合在一起,再次用3的整数倍数字节进行转码。

它目前只能处理UTF-8、Base64和UCS-2/UTF-16LE这三种比编码。能解决解决大部分的乱码问题,但并不能从根本上解决该问题。

正确拼接Buffer

淘汰掉setEncoding方法后,剩下的解决方案只有将多个小Buffer对象拼接为一个Buffer对象,然后通过iconv-lite一类的模块来转码这种方式。

1
2
3
4
5
6
7
8
9
10
11
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.concat()方法生成一个合并的Buffer对象。

Buffer与性能

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

测试

1
2
3
4
5
6
7
8
9
10
11
12
var http = require('http');
var helloworld = "";
for (var i = 0; i < 1024 * 10; i ++) {
helloworld += "a";
}

// helloworld = new Buffer(helloworld);

http.createServer(function (req, res) {
res.writeHead(200);
res.end(helloworld);
}).listen(8001);

通过ab做一个性能测试

1
ab -c 200 -t 100 http://127.0.0.1:8001/

取消掉注释以后,性能提升近一倍。

通过预先转换静态内容为Buffer对象,可以有效减少CPU的重复使用,节省服务器资源。在Node构建构建的web应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通过预先转换为Buffer的方式,使性能得到提升。

.文件读取

Buffer的使用除了与字符串的转换有性能损耗外,在文件的读取时,有一个highWaterMark设置对性能的影响至关重要。在fs.createReadStream(path, opts)时,我们可以传入一些参数,代码如下:

1
2
3
4
5
6
7
{
flags: 'r',
encoding: null,
fd: null,
mode: 0666,
highWaterMark: 64 * 1024
}

还可以传递start和end来指定读取文件的位置范围

1
{start: 90, end: 99}

fs.createReadStream()的工作方式是在内存中准备一段Buffer,然后在fs.read()读取时逐步从磁盘中将字节复制到Buffer中。完成一次读取时,则从这个Buffer中通过slice()方法取出部分数据作为一个小的Buffer对象,再通过data事件传递给调用方。如果Buffer用完,则重新分配一个;如果还有剩余,则继续使用。下面为分配一个新的Buffer对象的操作:

1
2
3
4
5
6
var pool;

function allocNewPool(poolSize) {
pool = new Buffer(poolSize);
pool.used = 0;
}

在理想情况下,每次读取的长度就是用户指定的highWaterMark。但是有可能读到了文件的结尾,或者文件本身就没有指定的highWaterMark那么大,这个预先指定的Buffer对象将会有部分剩余,不过好在这里的内存可以分配给下次读取时使用。pool是常驻内存的,只有当pool单元剩余数量小于128(kMinPoolSpace)字节时,才会重新分配一个新的Buffer对象。Node源码中分配新的Buffer对象的判断条件如下所示:

1
2
3
4
5
if (!pool || pool.length - pool.used < kMinPoolSpace) {
// discard the old pool
pool = null;
allocNewPool(this._readableState.highWaterMark);
}

这里与Buffer的内存分配比较类似,highWaterMark的大小对性能有两个影响

  1. highWaterMark设置对Buffer内存的分配和使用有一定影响
  2. highWaterMark设置过小,可能导致系统调用次数过多

文件流读取基于Buffer分配,Buffer则基于SlowBuffer分配,这可以理解为两个维度的分配策略。如果文件较小(小于8KB),有可能造成slab未能完全使用。

由于fs.createReadStream()内部采用fs.read()实现,将会引起对磁盘的系统调用,对于大文件而言,highWaterMark的大小决定会触发系统调用和data事件的次数。

读取一个相同的大文件时,hightWaterMark值的越大,读取速度越快。

网路编程

Node只需要几行代码即可构建服务器,无需额外的容器。

TCP

TCP全名为传输控制层协议,在OSI模型(由七层组成)中属于传输层协议。
avatar

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

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

创建TCP服务器端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var net = require('net');

var server = net.createServer(function (socket) {
// 新的连接
socket.on('data', function (data) {
socket.write('你好');
});

socket.on('end', function () {
console.log('连接断开');
});

socket.write("welcome!");
});

server.listen(8124, function () {
console.log('server bound');
});

我们通过net.createServer(listener)即可创建一个TCP服务器,listener是连接事件connection的侦听器,也可以采用如下的方式进行侦听:

1
2
3
4
5
6
var server = net.createServer();

server.on('connection', function (socket) {
// 新的连接
});
server.listen(8124);

除了端口外,同样我们也可以对Domain Socket进行监听

1
server.listen('/tmp/echo.sock');

测试

1
$ nc -U /tmp/echo.sock

通过net模块构造客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var net = require('net');
var client = net.connect({port: 8124}, function () {
// 'connect' listener
console.log('client connected');
client.write('world!');
});

client.on('data', function (data) {
console.log(data.toString());
client.end();
});

client.on('end', function () {
console.log('client disconnected');
});

如果是Domain Socket

1
var client = net.connect({path: '/tmp/echo.sock'});

TCP服务事件

在上述的示例中,代码分为服务器事件和连接事件
avatar

另外,由于TCP套接字是可写可读的Stream对象,可以利用pipe()方法巧妙地实现管道操作,如下代码实现了一个echo服务器

1
2
3
4
5
6
7
8
var net = require('net');

var server = net.createServer(function (socket) {
socket.write('Echo server');
sccket.pipe(socket);
});

server.listen(1337, '127.0.0.1');

avatar

构建UDP服务(???暂略)

构建HTTP服务(???不全,暂略)

http模块

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

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

1.HTTP请求

对于TCP连接的读操作,http模块将其封装为ServerRequest对象。报文头将会通过http_parser进行解析。

报文头第一行GET/ HTTP/1.1被解析之后分解为如下属性

  1. req.method属性
  2. req.url 属性
  3. req.httpVersion 属性

其余报头是很规律的Key:Value格式,被解析后放置在req.headers属性上传递给业务逻辑以供调用

1
2
3
4
5
headers:
{
'user-agent': '...',
host: '127.0.0.1:1337',
accept: '*/*' },

报文体部分则抽象为一个只读流对象,如果业务逻辑需要读取报文体中的数据,则要在这个数据流结束后才能进行操作

1
2
3
4
5
6
7
8
9
10
function (req, res) {
var buffers = [];
req.on('data', function (trunk) {
buffers.push(trunk);
}).on('end', function () {
var buffer = Buffer.concat(buffers);
// TODO
res.end('Helle world');
});
}

2.HTTP响应

它封装了对底层连接的写操作,可以将其看成一个可写的流对象。它影响响应报文头部信息的API为res.setHeader()和res.writeHead()

1
res.writeHead(200, {'Content-Type': 'text/plain'});

我们可以调用setHeader进行多次设置,但只有调用writeHead后,报头才会写入到连接中,http模块还会自动帮你设置一些头信息
avatar

报文体部分则是调用res.write()和res.end()方法实现,res.end()会先调用write()发送数据,然后发送信号告知服务器这次响应结束。

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

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

3.HTTP服务的事件

avatar

HTTP客户端

avatar

avatar

avatar

avatar

构建WebSocket服务(???暂略)

网络服务与安全(???暂略)

构建web应用(???暂略)

玩转Node.js进程

Node在选型时决定在V8引擎之上构建,就意味着它的模型和浏览器类似,JavaScript是运行在单个进程的单个线程上。它带来的好处是:程序状态是单一的,在没有多线程的情况下没有锁、线程同步问题,操作系统在调度时也因为较少上下文的切换,可以很好地提高CPU的使用率。

但是一个Node进程只能利用一个核,无法充分利用多核CPU服务器。

另外,一旦单线程上抛出的异常没有被捕获,将会引起整个进程的崩溃。

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

服务模型的变迁

web服务器的架构经历了几次变迁。服务器处理客户端请求的并发量,就是每个里程碑的见证。

石器时代:同步

一次只为一个请求服务,所有请求都得按次序等待服务器。假设每次响应服务耗用的时间稳定为N秒,这类服务的QPS为1/N。

青铜时代:复制进程

通过进程的复制同时服务更多的请求和用户。每个连接都需要一个进程来服务,这是非常昂贵的代价。在进程复制的过程中,需要复制进程内部的状态,相同的状态将会在内存中存在很多份,造成浪费。并且这个过程由于要复制较多的数据,启动是较为缓慢的。

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

假设通过进行复制和预复制的方式搭建的服务器有资源的限制,且进程数上限为M,那这类服务的QPS位M/N.

白银时代:多线程

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

如果忽略掉多线程上下文切换的开销,假设线程所占用的资源为进程的1/L,受资源上限的影响,它的QPS为M*L/N

黄金时代:事件驱动

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

Node与Nginx则是基于事件驱动的服务模型实现的,采用单线程避免了不必要的内存开销和上下文切换开销。

基于事件驱动的服务模型存在前面提及的CPU利用率和进程健壮性的两个问题。

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

多进程架构

面对单进程单线程对多核CPU使用不足的问题,前人的经验是启用多进程即可。每个进程利用一个CPU,以此实现多核CPU的利用。Node提供了child_process模块,并且也提供了child_process.fork()函数供我们实现进程的复制。

1
2
3
4
5
6
// worker.js
var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {"Content-Type":"text/plain"});
res.end('Hello World\n');
}).listen(Math.round(1 + Math.random()) * 1000, '127.0.0.1');

通过 node mater.js启动它

1
2
3
4
5
6
// mater.js
var fork = require('child_process').fork;
var cpus = require('os')_cpus();
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js');
}

这就是著名的Master-Workers模式,又称主从模式。这是典型的分布式架构中用来并行处理业务的模式,具备较好的可伸缩性和稳定性。主进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋于稳定的。工作进程负责具体的业务处理。

avatar

通过fork()复制的进程都是一个独立的进程,这个进程中有着独立而全新的V8实例。它需要至少30毫秒的启动时间和至少10MB的内存。fork()进程是昂贵的,好在Node通过事件驱动的方式在单线程上解决了大并发的问题,这里启动多个进程只是为了充分将CPU资源利用起来,而不是为了解决并发问题。

创建子进程

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

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

spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性设置超时时间,一但创建的进程运行超过设定的时间将会被杀死。

exec()和execFile()不同的是,exec()适合执行已有的命令,execFile()适合执行文件。

node worker.js分别用上述4种方法实现如下:

1
2
3
4
5
6
7
8
9
10
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个方法在创建子进程之后均会返回子进程对象。

它们主要在是否有回调/异常、进程类型、执行类型、是否可设置超时上有差别。

avatar

这里的可执行文件是指可以直接执行的文件,如果是JavaScript文件通过execFile()运行,它的首行内容必须添加如下代码:

1
#!/usr/bin/env node

事实上其他三种方法都是spawn()的延伸。

进程间通信

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

和HTML5的WebWorker API一样,Node可以通过消息传递内容,而不是共享或直接操作相关资源,这是较为轻量和无依赖的做法。

1
2
3
4
5
6
7
8
9
// parent.js
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');

n.on('message', function (m) {
console.log('PARENT get message:', m);
});

n.send({ hello: 'world' });
1
2
3
4
5
6
// sub.js
process.on('message', function (m) {
console.log('CHILD got message:', m);
});

process.send({ foo: 'bar' });

父进程与子进程之间将会创建IPC通道。通过IPC通道,父子进程之间才能通过message和send()传递消息。

进程间通信原理

IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。Node中实现IPC通道的是管道(pipe)技术,但此管道非彼管道,在Node中管道是个抽象层面的称呼,具体细节实现由libuv提供,在Windows下由命名管道实现,*nix系统则采用Unix Domain Socket实现。表现在应用层上的进程间通信只有简单的message事件和send()方法。

avatar

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

avatar

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

句柄传递

如果让服务器都监听到相同的端口会抛EADDRINUSE异常,这个问题破坏了我们将多个进程监听同一个端口的想法。通常的做法是让每个进程监听不同的端口,主进程监听主端口,主进程对外接收所有的网络请求,再将这些请求分别代理到不同端口的进程中。

avatar

在代理进程上可以做适当的负载均衡,使每个子进程可以较为均衡地执行任务。由于进程每收到一个连接,将会用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符。操作系统的文件描述符是有限的,这影响了系统的扩展能力。

Node在版本v0.5.9引入了进程间发送句柄的功能。send()方法除了能通过IPC发送数据外,还能发送句柄,第二个可选参数就是句柄:

1
child.send(message, [sendHandle]);

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

我们可以替换掉代理方法,主进程接收到socket请求后,将这个socket直接发送给工作进程,而不是重新与工作进程之间建立新的socket连接来转发数据。

1
2
3
4
5
6
7
8
9
var child = require('child_process').fork('child.js');

var server = require('net').createServer();
server.on('connection', function (socket) {
socket.end('handled by parent');
});
server.listen(1337, function () {
child.send('server', server);
});

子进程

1
2
3
4
5
6
7
process.on('message', function (m, server) {
if (m === 'server') {
server.on('connection', function (socket) {
socket.end('handled by child');
});
}
});

示例中直接将一个TCP服务器发送给了子进程。

我们通过curl命令测试:

1
curl "http://127.0.0.1:1337/"

命令行响应结果是很不可思议的,这里子进程和父进程都有可能处理我们客户端发起的请求。

试试将服务发送给多个子进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
// parent.js
var cp = require('child_process');
var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');

var server = require('net').createServer();
server.on('connection', function (socket) {
socket.end('handle by parent\n')
});
server.listen(1337, function() {
child1.send('server', server);
child2.send('server', server)
});

在子进程中将进程ID打印出来

1
2
3
4
5
6
7
8
// child.js
process.on('message', function (m, server) {
if (m === 'server') {
server.on('connection', function (socket) {
socket.end('handle by child,pid is ' + process.pid + '\n')
})
}
})

每次测试出现的结果都可能不同

1
2
3
handle by child,pid is 24673
handle by parent
handle by child,pid is 24672

这是在TCP层面上完成的事情,我们尝试将其转化到HTTP层面来试试。对于主进程,我们想让它更轻量一些,将服务器句柄发送给子进程之后,关掉服务器的监听,让子进程来处理请求。

1
2
3
4
5
6
7
8
9
10
11
// parent.js
var cp = require('child_process');
var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');

var server = require('net').createServer();
server.listen(1337, function () {
child1.send('server', server);
child2.send('server', server);
server.close();
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// child.js
var http = require('http');
var server = http.createServer(function (req, res) {
res.writedHead(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);
})
}
})

测试结果

1
2
handle by child,pid is 24673
handle by child,pid is 24672

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

先是主进程将请求发送给工作进程,主进程发送完句柄并关闭监听之后,多个子进程直接监听相同端口,并且不会抛EADDRINUSE异常。

1.句柄发送与还原

前面的句柄发送虽然看上去跟直接将服务器对象发送给子进程没有差别,但其实它并不是真的发送了服务器对象。

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

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

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

1
2
3
4
5
{
"cmd": "NODE_HANDLE",
"type": "net.Server",
"msg": message
}

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

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

avatar

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

1
2
3
4
5
6
7
8
function(message, handle, emit) {
var self = this;

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

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

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

2.端口共同监听

抛EADDRINUSE异常的原因,是我们独立启动的进程中,TCP服务器端socket套接字的文件描述符并不相同,导致监听到相同的端口时会抛异常。

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

1
setsockopt(tcp->io_watcher.fd, SQL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

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

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

通过这些基础技术,用child_process模块在单机上搭建Node集群是件相对容易的事情,因此在多核CPU的环境下,让Node进程能够充分利用资源不再是难题。

集群稳定之路

搭建好了集群,可以充分利用多核CPU资源了。但是,我们还有一些细节需要考虑。

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

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

进程事件

再次回到子进程对象上,除了send()方法和message事件,Node还有如下事件:

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

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

1
2
3
4
5
// 子进程
child.kill([signal]);

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

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

avatar

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

1
2
3
4
5
6
7
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');

自动重启

有了父子进程之间的相关事件之后,就可以在这些关系之间创建出需要的机制了。

至少我们能够通过监听子进程的exit事件来获知其退出的信息,接着前文的多进程架构,我们主进程上加入一些进程管理的机制,比如某个工作进程退出了,重新启动一个工作进程来继续服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 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();
}
})

我们可以通过kill [pid]命令对以上代码进行测试。

其实,实际业务中更可能是存在bug导致工作进程退出,我们需要仔细处理这种异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// worker.js
var http = require('http');
var server = http.createServer(function (req, res) {
res.writeHead(200, {"Conten-Type": "text/plain"});
res.end('handle by child, pid is ' + process.pid + '\n')
})

var worker;
process.on('message', function (m, tcp) {
if (m === 'server') {
worker = tcp;
worker.on('connection', fuction(socket) {
server.emit("connection", socket)
})
}
})

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

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

自杀信号

0%