Node——深入浅出Node.Js(读书笔记)
@
Node简介
概述
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。 Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型。
特点
异步I/O(非堵塞I/O)
在Node中绝大多数操作都以异步的方式进行调用;包括:文件读取,网络请求等;
事件驱动(利用回调函数)—异步编程
通过利用“事件循环”机制,以事件形式驱动所有请求;
单线程
Node保留了单线程的特定;并且该线程无法与其他线程共享任何状态的。
单线程解决了其他多线程编程的多线程同步问题,也没有线程切换带来的开销。
弱点也相对明显:
- 无法利用多核CPU(可通过child_process解决)
- 错误引起整个应用退出,应用的健壮性值得考验。
- 大量计算占用CPU导致无法继续调用异步I/O
扩展:
像浏览器中JS引擎和UI共用一个线程一样,JS长时间执行为会导致UI的渲染和响应被中断。
在Node中,长时间的CPU占用也会导致后续的异步I/O发不出调用,已完成的异步I/O的回调函数也会得不到及时执行。
两种平台的解决方案:
浏览器:WebWorker;
WebWorker能够创建工作线程来进行工作,以解决JS大计算阻塞UI渲染的个问题;
工作线程为了不阻塞主线程,通过消息传递的放置来传递运行结果,这也使得工作线程不能访问到主线程中的UINode:child_process
Node采用了和WebWorkers相同的思路
子进程的出现,意味着Node可以从容地应对单线程在健壮性和无法利用多核CPU方面的问题。
通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来 传递结果,这可以很好地保持应用模型的简单和低依赖。
垮平台
应用场景
善于I/O,不善于计算。因为Node.js最擅长的就是任务调度
NodeJS适合运用在高并发、I/O密集、少量业务逻辑的场景。
- I/O密集型
- 不适合CPU密集型应用
扩展:是否不擅长CPU密集型应用 (方案:分解大型运算任务为多个小任务,使得运算能够适时释放,不阻塞I/O调用的发起)
单以执行效率来做评判,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。此类应用场景或许应当采用多线程的方 式进行计算。
模块机制
CommonJS
Node借鉴CommonJS的Modules规范实现了一套非常易用的模块系统,NPM对Packages规范 的完好支持使得Node应用在开发过程中事半功倍。
CommonJS对模块的定义十分简单,主要分为模块引用、模块定义和模块标识3个部分。
模块引用
在CommonJS规范中,存在require()方法,这个方法接受模块标识,以此引入一个模块的API 到当前上下文中。
var math = require('math');
模块定义
在模块中,上下文提供require()方法来引入外部模块。对应引入的功能,上下文提供了 exports对象用于导出当前模块的方法或者变量,并且它是唯一导出的出口。在模块中,还存在 一个module对象,它代表模块自身,而exports是module的属性。在Node中,一个文件就是一个 模块,将方法挂载在expor ts对象上作为属性即可定义导出的方式:
// 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);
};
模块标识
模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者 以•、••开头的相对路径,或者绝对路径。它可以没有文件名后缀js。
Node的模块加载过程
在Node中引入模块,需要经历如下3个步骤。
- 路径分析
- 文件定位
- 编译执行
在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。
- 核心模块
核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。- 文件模块
文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。
Node模块加载过程分析
与前端浏览器会缓存静态脚本 文件以提高性能一样,Node对引入过的模块都会进行缓存,以减少二次引入时的开销。
不同的地方在于,浏览器仅仅缓存文件,而Node缓存的是编译和执行之后的对象。
不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的 方式,这是第一优先级的。
不同之处在于核心模块的缓存检查先于文件模块的缓存检查。
路径分析
对于不同的标识符,模块的查找和定位有不同程度上的差异。
前面提到过,require()方法接受一个标识符作为参数。在Node实现中,正是基于这样一个标识符进行模块查找的。
模块标识符在Node中主要分为以下几类。
- 核心模块,如http、fs、path等。
- •或••开始的相对路径文件模块。
- 以/开始的绝对路径文件模块。
- 非路径形式的文件模块,如自定义的connec t模块。
核心模块
核心模块的优先级仅次于缓存加载,它在Node的源代码编译过程中已经编译为二进制代码, 其加载过程最快。如果试图加载一个与核心模块标识符相同的自定义模块,那是不会成功的。如果自己编写了 一个http用户模块,想要加载成功,必须选择一个不同的标识符或者换用路径的方式。
路径形式的文件模块
以•、••和/开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二 次加载时更快。
由于文件模块给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块。
自定义模块
自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能 是一个文件或者包的形式。这类模块的查找是最费时的,也是所有方式中最慢的一种。
在加载的过程中,Node 会逐个尝试模块路径中的路径,直到找到目标文件为止。前文件的路径越深,模块查找耗时会越多,这是自定义模块的加载速度是最慢的原因。
- 模块路径是Node在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组。
模块路径的生成规则如下所示
- 当前文件目录下的node_modules目录。
- 父目录下的node_modules目录。
- 父目录的父目录下的node_modules目录。
- 沿路径向上逐级递归,直到根目录下的node_modules目录。
文件定位
从缓存加载的优化策略使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大 提高了再次加载模块时的效率。
文件的定位过程中,有一些细节需要注意,这主要包括文件扩展名的分析、目录和包的处理。
- 文件扩展名分析
require在分析标识符的过程中,会出现标识符中不包含文件扩展名的情况。CommonJS模块规范也允许在标识符中不包含文件扩展名,这种情况下,Node会按js、json、.node的次序补足扩展名,依次尝试。
在尝试的过程中,需要调用fs模块同步阻塞式地判断文件是否存在。因为Node是单线程的, 所以这里是一个会弓I起性能问题的地方。> 小诀窍是:如果是.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。
- 如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。
编译执行(文件模块-自定义模块)
在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文件的调用如下:
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] }
编译
在确定文件的扩展名之后,Node将调用具体的编译方式来将文件执行后返回给调用者。
- JavaScript模块的编译
- 在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。
在头部添加 了 (func tion (expor ts, require, module, _filename, _dirname) {\n,在尾部添加 了\n});。- 包装之后的代码会通过vm原生模块的 runInThisContext()方法执行(类似eval,只是具有明确上下文,不污染全局),返回一个具体的 function对象。
- 最后,将当前模块对象的exports属性、require()方法、module (模块对象自身), 以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个function()执行。
- 在执行之后,模块的exports 属性被返回给了调用方。
exports属性上的任何方法和属性都可以被外部调用到,但是模块中的 其余变量或属性则不可直接被调用。
// 一个正常的JS文件会被包装成如下:
(function(exports, require, module, __filename, __dirname) {
var math = require('math');
exports.area = function(radius) {
return Math.PI * radius * radius;
};
});
- C/C++模块的编译(核心模块)
Node调用process.dlopen()方法进行加载和执行。在Node的架构下,dlopen()方法在Windows 和*nix平台下分别有不同的实现,通过libuv兼容层进行了封装。
实际上,.node的模块文件并不需要编译,因为它是编写C/C++模块之后编译生成的,所以这里只有加载和执行的过程。在执行的过程中,模块的exports对象与.node模块产生联系,然后返回给调用者。
C/C++模块给Node使用者带来的优势主要是执行效率方面的,劣势则是C/C++模块的编写门 槛比 JavaScript 高。
- JSON文件的编译
.json文件的编译是3种编译方式中最简单的。Node利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将它赋给模块对象的exports,以供外部调用。
JSON文件在用作项目的配置文件时比较有用。如果你定义了一个JSON文件作为配置,那就 不必调用fs模块去异步读取和解析,直接调用require()引入即可。此外,你还可以享受到模块缓 存的便利,并且二次引入时也没有性能影响。
Node的核心模块
Node的核心模块在编译成可执行文件的过程中被编译进了二进制文件。核心模块其实分为C/C++编写的和JavaScript编写的两部分,其中C/C++文件存放在Node项目的src目录下, JavaScript文件存放在lib目录下。
JavaScript的核心模块的编译过程
- 转存为C/C++代码
Node采用了V8附带的js2c.py工具,将所有内置的JavaScript代码(src/node.js和lib/*.js)转换成C++里的数组,生成node_natives.h头文件,
在这个过程中,JavaScript代码以字符串的形式存储在node命名空间中,是不可直接执行的。
在启动Node进程时,JavaScript代码直接加载进内存中。在加载的过程中,JavaScript核心模块经历标识符分析后直接定位到内存中,
比普通的文件模块从磁盘中一处一处查找要快很多。- 编译JavaScript核心模块
lib目录下的所有模块文件也没有定义require、module、exports这些变量。在引入JavaScript核心模块的过程中,也经历了头尾包装的过程,然后才执行和导出了exports对象。
与文件模块有区别的地方在于:获取源码的方式(核心模块是从内存中加载的)以及缓存执行结果的位置。
JavaScript核心模块的定义如下面的代码所示:
源文件通过process.binding('natives')取出,编译成功的模块缓存到NativeModule._cahce对象上,文件模块则要缓存到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++模块主内完成核心,JavaScrpt主外实现封装的模式是Node能够提高性能的常见方式。
通常,脚本语言的开发速度优于静态语言,但是其性能则弱于静态语言。而Node的这种复合模式可以在开发速度和性能之间找到平衡点。
这里我们将那些由纯C/C++编写的部分统一称为内建模块,因为它们通常不被用户直接调用。
Node的buffer、crypto、evals、fs、os等模块都是部分通过C/C++编写的。
- 内建模块的组织形式
每一个内建模块在定义之后,都通过NODE_MODULE宏将模块定义到 node 命名空间中,模块的具体初始化方法挂载为结构的register_func成员。
node_extensions.h文件将这些散列的内建模块统一放进了一个叫node_module_list的数组中。
这些内建模块的取出也十分简单。Node提供了get_builtin_module()方法从node_module_list数组中取出这些模块。内建模块的优势在于:首先,它们本身由C/C++编写,性能行优于脚本语言;其次,在进行文件编译时,它们被编译进二进制文件。一旦Node开始执行,它们被直接加载进内存中,无需再次做标识符定位、文件定位、编译等过程,直接就可执行。
- 内建模块的导出
在Node的所有模块类型中,存在着如下图所示的一种依赖层级关系,即文件模块可能会依赖核心模块,核心模块可能会依赖内建模块。
通常,不推荐文件模块直接调用内建模块。如需调用,直接调用核心模块即可,因为核心模块中基本都封装了内建模块。那么内建模块是如何将内部变量或方法导出,以供外部JavaScript核心模块调用呢?
Node在启动时,会生成一个全局变量process,并提供Binding()方法来协助加载内建模块。
在加载内建模块时,我们先创建一个exports空对象,然后调用get_builtin_module()方法取出内建模块对象,通过执行register_func()填充exports对象,最后将exports对象按模块名缓存,并返回给调用方完成导出。
这个方法不仅可以导出内建方法,还能导出一些别的内容。前面提到的JavaScript核心文件被转换为C/C++数组存储后,便是通过process.binding('natives')取出放置在NativeModule._source中的:
NativeModule._source = process.binding('natives');
核心模块的引入流程
从下图所示的os原生模块的引入流程可以看到,为了符合CommonJS规范,从JavaScript到C/C++的过程是相当复杂的,它要经历C/C++层面的内建模块定义、(JavaScript)核心模块的定义和引入以及(JavaScript)文件模块层面的引入。但是对于用户而言,require()十分简洁、友好:
模块调用栈
C/C++内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript文件模块调用。如果你不是非常了解要调用的C/C++内建模块,请尽量避免通过process.binding()方法直接调用,这是不推荐的。
JavaScript核心模块主要扮演的职责有两类:
- 一类是作为C/C++内建模块的封装层和桥接层,供文件模块调用;
- 一类是纯粹的模块功能,它不需要跟底层打交道,但是又十分重要。
文件模块通常由第三方编写,包括普通JavaScript模块和C/C++扩展模块,主要调用方向为普通JavaScript模块调用扩展模块。
包与NPM(第三方模块管理)
Node组织了自身的核心模块,也使得第三方模块可以有序的编写和使用。但是在第三方模块中,模块与模块之间仍然是散列在各地的,相互之间不能直接引用。而在模块之外,包和NPM则是将模块联系在一起的一种机制。
JavaScript不似Java或其它语言那样,具有模块和包结构。
Node对模块规范的实现,一定程度上解决了变量依赖、依赖关系等代码组织性问题。
包的出现,则是在模块的基础上进一步组织JavaScript代码。
CommonJS的包规范定义其实也十分简单。它由包结构和包描述文件两个部分组成,
- 包结构用于组织包中的各种文件,
- 包描述文件则用于描述包的相关信息,以供外部读取分析。
包结构
包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。
完全符合CommonJS规范的包目录应该包含如下这些文件:
- package.json:包描述文件
- bin:用于存放可执行二进制文件的目录
- lib:用于存放JavaScript代码的目录
- doc:用于存放文档的目录
- test:用于存放单元测试用例的代码
可以看到,CommonJS包规范从文档、测试等方面都做过考虑。当一个包完成后向外公布时,用户看到单元测试和文档的时候,会给他们一种踏实可靠的感觉。
包描述文件
包描述文件用于表达非代码相关的信息,它是一个JSON格式的文件-package.json,位于包的根目录下,是包的重要组成部分。
而NPM的所有行为都与包描述文件的字段信息相关。
NPM
CommonJS包规范是理论,NPM是其中的一种实践。
NPM之于Node,相当于gem之于Ruby,pear之于PHP。
对于Node而言,NPM帮助完成了第三方模块的发布、安装和依赖等。
借助NPM,Node与第三方模块之间形成了很好的一个生态系统。
注意:
全局模式安装:
如果包中含有命令行工具,那么需要执行 npm install express -g 命令进行全局模式安装。
需要注意的是,全局模式并不是将一个模块包安装为一个全局包的意思,它并不意味着可以从任何地方通过require()来引用到它。
全局模式这个称谓其实并不精确,存在诸多误导,实际上,-g 是将一个包安装为全局可用的可执行命令。它根据包描述文件中的bin字段配置,将实际脚本链接到与Node可执行文件相同的路径下:
"bin":{
"express":"./bin/express"
},
NPM钩子命令:
另一个需要说明的是C/C++模块实际上是编译后才能使用的。
package.json中scripts字段的提出就是让包安装或者等过程中提供钩子机制,示例如下:
"scripts":{
"preinsatll":"preinstall.js",
"install":"install.js",
"uninstall":"uninstall.js",
"test":"test.js"
}
在以上字段中执行 npm install 时,preinstall 指向的脚本会被加载执行,然后install指向的脚本会被执行。
在执行 npm uninstall 时,uninstall指向的脚本也许会做一些清理工作等。
当在一个具体的包目录下执行 npm test 时,将会运行 test 指向的脚本。
NPM潜在问题
- 在NPM平台上,每个人都可以分享包到平台上,鉴于开发人员水平不一,上面包的质量也良莠不齐。
- 另一个问题是,Node代码可以运行在服务器端,需要考虑安全问题。
异步I/O
关于异步
异步最早因AJAX大规模流行;不光存在于软件编程中;
但事实上,异步早就存在于操作系统的底层。在底层系统中,异步通过信号量、消息等方式有了广泛的应用。
在绝大多数高级编程语言中,异步并不多件,疑似被屏蔽了一般。
造成这个现象的主要原因也许令人惊讶:程序员不太适合通过异步来进行程序设计。
Node和异步关系
在众多高级编程语言或运行平台中,将异步作为主要编程方式和设计理念的,Node是首个。
伴随着异步I/O的还有事件驱动和单线程,它们构成Node的基调。
与Node的事件驱动、异步I/O设计理念比较相近的一个知名产品为Nginx。Nginx采用纯C编写,性能表现非常优异。
它们的区别在于:
- Nginx具备面向客户端管理连接的强大能力,但是它的背后依然受限于各种同步方式的编程语言。(仅仅作为服务端使用性能明显)
- 但Node却是全方位的,既可以作为服务器端去处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。
Web的含义是网,Node的表现就如它的名字一样,是网络中灵活的一个节点。
Node的异步概述
常用编程方式问题:
- 单线程同步编程模型会阻塞I/O导致硬件资源得不到更优的使用。
- 多线程编程模型也因为编程中的死锁、状态同步等问题让开发人员头疼。
Node异步特点:
Node在两者之间给出了它的方案:
- 利用单线程,远离多线程死锁、状态同步等问题;
- 利用异步I/O,让单线程远离阻塞,以更好的利用CPU。
注意:
为了弥补单线程无法利用多核CPU的缺点,Node提供了类似前端浏览器中Web worker的子进程,该子进程可以通过工作进程高效的利用CPU和I/O。
计算机的I/O
在听到Node的介绍时,我们时常会听到异步、非阻塞、回调、事件这些词语混合在一起推介出来,其中异步与非阻塞听起来似乎是同一回事。从实际效果而言,异步和非阻塞都达到了我们并行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返回之后,CPU的时间片可以用来处理其它事务,此时的性能提升是明显的。
但非阻塞I/O也存在一些问题。由于完整的I/O并没有完成,立即返回的不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整数据,应用程序需要重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术叫做轮询
任意技术都并非完美的。阻塞I/O造成的CPU等待浪费,非阻塞带来的麻烦却是需要轮询去确认是否完全完成数据获取,它会让CPU处理状态判断,是对CPU资源的浪费。
非阻塞I/O的轮询操作
现存的轮询技术:
- read:它是最原始的、性能最低的一种,通过重复调用来检查I/O的状态来完成完整数据的读取。在得到最终数据前,CPU一直耗用在等待上。
- select:它是在read基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断。
- poll:该方案较select有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查。但是当文件描述符较多的时候,它的性能还是十分低下的。
- epoll:该方案是Linux下效率最高的I/O事件通知机制,在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高。
- kqueue:该方案的实现方式与epoll类似,不过它仅在FreeBSD系统下存在。
轮询总结
轮询技术满足了非阻塞I/O确保获取完整数据的要求,但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要等待I/O完全返回,依旧花费了很多时间来等待。
等待期间,CPU要么用于遍历文件描述符的状态,要么休眠等待事件发生。
同步,异步,阻塞,非阻塞总结
- 阻塞与非阻塞针对的是应用程序处理IO时的行为。
阻塞IO,这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。- 同步与异步是针对应用程序与内核的交互而言的。
同步:进程会等到内核完成所有操作,才会进行下面的操作,如果此时操作时间过长(如IO,不管是通过堵塞或者非堵塞都会等待IO进行完毕),程序会暂停,不会做任何其他操作;这也是为什么会说:同步IO不等于阻塞IO。
异步:进程不需要等待内核对该操作;会进行后面的任务;完成后内核通知进程完成该操作。- 同步IO和异步IO
同步IO:用户进程发出IO调用,去获取IO设备数据,双方的数据要经过内核缓冲区同步,完全准备好后,再复制返回到用户进程。而复制返回到用户进程会导致请求进程阻塞,直到I/O操作完成。
异步IO:用户进程发出IO调用,去获取IO设备数据,并不需要同步,内核直接复制到进程,整个过程不导致请求进程阻塞。如图:
堵塞和非堵塞发生在:等待数据阶段;堵塞(等待数据,CPU等待,不做任何操作),非堵塞(等待数据,CPU轮询,不等待,但是依然是在处理IO阶段,会让CPU处理状态判断,是对CPU资源的浪费,无法处理其他进程。)
同步和异步发生在:数据到用户空间(进程);同步需等到数据复制到空间(进程一直等待),异步不需要等到数据复制到空间(进程继续运行)
参考:简述同步IO、异步IO、阻塞IO、非阻塞IO之间的联系与区别 阻塞与非阻塞IO,同步与异步IO
理想的非阻塞异步I/O
我们期望的完美异步I/O应该是应用程序发起非阻塞调用,无须通过遍历或事件唤醒等方式轮询,可以直接处理下一个任务,只需要在I/O完成后通过信号或回调将数据传递给应用程序即可。
理想中的异步I/O示意图 :
现实的异步I/O
幸运的是,在Linux下存在这样一种方式,它原生提供的一种异步I/O方式(AIO)就是通过信号或回调来传递数据的。
但不幸的是,只有Linux下有,而且它还有缺陷——AIO仅支持内核I/O中的O_DIRECT方式读取,导致无法利用系统缓存。
其他解决方案
现实比理想要骨感一些,但是要达成异步I/O的目标,并非难事。前面我们将场景限定在了单线程的状况下,多线程的方式会是另一番风景。通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O(尽管这是模拟的),示意图如下:
Node的解决方案
由于Windows平台和*nix平台的差异,Node提供了libuv作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,并保证上层的Node与下层的自定义线程池及IOCP之间各自独立。Node在编译期间会判断平台条件,选择性编译unix目录或是win目录下的源文件到目标程序中,架构如下图:
- *nix平台
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。- Windows平台
Windows下的IOCP,它在某种程度上提供了理想的异步I/O:调用异步方法,等待I/O完成之后的通知,执行回调,用户无须考虑轮询。但是它的内部其实仍然是线程池原理,不同之处在于这些线程池由系统内核接手管理。
IOCP的异步I/O模型与Node的异步调用模型十分近似。在Windows平台下采用了IOCP实现异步I/O。
注意:
- 这里的I/O不仅仅只限于磁盘文件的读写。
*nix将计算机抽象了一番,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为了文件,因此这里描述的阻塞和非阻塞的情况同样能适合于套接字等。- 我们时常提到的Node是单线程的,这里的单线程仅仅只是JavaScript执行在单线程中罢了。
在Node中,无论是 *nix还是Windows平台,内部完成I/O任务的另有线程池。
Node的异步I/O实现
完成整个异步I/O环节的有事件循环、观察者和请求对象等。
事件循环
首先,我们着重强调一下Node自身的执行模型——事件循环,正是它使得回调函数十分普遍。
在进程启动时,Node便会创建一个类似于while(true)的循环,每执行一次循环体的过程我们称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它。然后进入下个循环,如果不再有事件处理,就退出流程。
观察者
在每个Tick的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者。
每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。
解释:
这个过程如厨师做菜,厨师做菜需要询问点餐员;询问点餐员是否有什么菜需要做;如果没有就打烊下班;
点餐员就是观察者,而客人的订单就是回调函数;
一个餐馆可以有多个点餐员,如同有多个事件观察者;收到一个下单是一个事件,一个观察者里可能有多个时间。
浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时的产生,而这些产生的事件都有对应的观察者。
在Node中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。
观察者将事件进行了分类。
事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。在Windows下,这个循环基于IOCP创建,而在 nix 下,则基于多线程创建 。
请求对象
从JavaScript发起调用到内核执行完I/O操作的过渡过程中,存在一种中间产物,它叫做请求对象。
非异步回调函数和异步回调函数
对于一般的(非异步)回调函数,函数由我们自行调用,如下所示:
var forEach = function(list, callback){ for(var i=0;i<list.length;i++){ callback(list[i],i,list); } };
对于Node中的异步I/O调用而言,回调函数却不由开发者来调用。
Node与底层执行异步I/O调用以及回调函数被调用执行过程——请求对象的产生
例子代码如下:
fs.open=function(path, flags, mode, callback){
//...
binding.open(pathModule._makeLong(path),
stringToFlags(flags),
mode,
callback);
};
- fs.open()的作用是根据指定路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有I/O操作的初始操作。
- 从JavaScript调用Node的核心模块,核心模块调用C++内建模块,内建模块通过libuv进行系统调用,这是Node里经典的调用方式。
这里libuv作为封装层,有两个平台实现,实质上是调用了uv_fs_open()方法。- 在uv_fs_open()的调用过程中,我们创建了一个FSReqWrap请求对象。
从JavaScript层传入的参数和当前方法都被封装在这个请求对象中,其中我们最为关注的回调函数则被设置在这个对象的oncomplete_sym属性上:req_wrap->object->Set(oncomplete_sym, callback);
- 对象包装完毕后,在Windows下,则调用QueueUserWorkItem()方法将这个FSReqWrap对象推入线程池中等待执行,该方法的代码如下所示:
QueueUserWorkItem(&uv_fs_thread_proc, \第一个参数是将要执行的方法的引用,这里引用的是uv_fs_thread_proc req, \ WT_EXECUTEDEFAULT)
- 当线程池中有可用线程时,我们会调用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的流程:
Node的异步I/O-总结
Node描述的异步I/O实现,其主旨是使I/O操作与CPU操作分离。
从前面实现异步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
尽管我们在介绍Node的时候,多数情况下都会提到异步I/O,但是Node中其实还存在一些与I/O无关的异步API,这一部分也值得略微关注一下,它们分别是
- setTimeout()
- setInterval()
- setImmediate()
- process.nextTick()
定时器
setTimeout()和setInterval()和浏览器中的API是一致的,分别用于单次和多次定时执行任务。
它们的实现原理与异步I/O比较类似,只是不需要I/O线程池的参与。
调用setTimeout()或者setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中。
每次Tick执行时,会从该红黑树中迭代取出定期时对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数将立即执行。
process.nextTick()
每次调用process,nextTick()方法,只会将回调函数放入队列中,在下一轮Tick时取出执行。
注意:
在未了解process.nextTick()之前,很多人也许为了立即异步执行一个任务,会这样调用 setTimeout()来达到所需的效果:
setTimeout(function () { // TODO }, 0);
由于事件循环自身的特点,定时器的精确度不够。而事实上,采用定时器需要动用红黑树, 创建定时器对象和迭代等操作,而setTimeout(fn, 0)的方式较为浪费性能。
实际上, process.nextTick()方法的操作相对较为轻量。
定时器中采用红黑树的操作时间复杂度为O(lg(n)), nextTick()的时间复杂度为0(1)。相较之下, process.nextTick()更高效。
setImmediate()
setImmediate()方法与process.nextTick()方法十分类似,都是将回调函数延迟执行。
两者之间其实是有细微差别的
- process.nextTick()中的回调函数执行的优先级要高于setImmediate。
这里的原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者, setImmediate属于check观察者。在每一个轮循环检查中,idle观察者先于I/O观察者,I/O观察者 先于check观察者。- 在具体实现上,process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果 则是保存在链表中。
在行为上,process,nextTick()在每轮循环中会将数组中的回调函数全部执 行完,而setImmediate()在每轮循环中执行链表中的一个回调函数。
事件驱动与高性能服务器
事件驱动
事件驱动的实质:即通过主循环加事件触发的方式来运行程序。
说明:
尽管只用了 fs.open()方法作为例子来阐述Nod e如何实现异步I/O。而实质上,异步I/O 不仅仅应用在文件操作中。对于网络套接字的处理,Node也应用到了异步I/O,网络套接字上侦听到的请求都会形成事件交给I/O观察者。事件循环会不停地处理这些网络I/O事件。如果 JavaScript有传入回调函数,这些事件将会最终传递到业务逻辑层进行处理。利用Node构建Web服务器,正是在这样一个基础上实现的,其流程图如图所示:
高性能服务器
几种经典的服务器模型,优缺点对比
- 同步式。对于同步式的服务,一次只能处理一个请求,并且其余请求都处于等待状态。
- 每进程/每请求。为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展 性,因为系统资源只有那么多。
- 每线程/每请求。为每个请求启动一个线程来处理。尽管线程比进程要轻量,但是由于每线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢。 每线程/每请求的扩展性比每进程/每请求的方式要好,但对于大型站点而言依然不够。
说明:
每线程/每请求的方式目前还被Apache所采用。
Node通过事件驱动的方式处理请求,无须为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。
这使得服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受线程上下文切换开销的影响,这是Node高性能的一个原因。
事件驱动带来的高效已经渐渐开始为业界所重视。
知名服务器Nginx,也摒弃了多线程的方 式,采用了和Node相同的事件驱动。如今,Nginx大有取代Apache之势。
Node具有与Nginx相同 的特性,不同之处在于Nginx采用纯C写成,性能较高,但是它仅适合于做Web服务器,用于反向代理或负载均衡等服务,在处理具体业务方面较为欠缺。
Node则是一套高性能的平台,可以利用它构建与Nginx相同的功能,也可以处理各种具体业务,而且与背后的网络保持异步畅通。
两者相比,Node没有Nginx在Web服务器方面那么专业,但场景更大,自身性能也不错。在实际项目 中,我们可以结合它们各自优点,以达到应用的最优性能。
异步编程
有异步I/O,必有异步编程。
Node是首个将异步大规模带到应用层面的平台,它从内在运行机制到API 的设计,无不透露出异步的气息来。异步的高性能为它带来了高度的赞誉,而异步编程也为其带 来部分的诋毁。
异步I/O在应用层面不流行的原因,那便是异步编程在流程控制中,业 务表达并不太适合自然语言的线性思维习惯。
函数式编程
JavaScript中,函数(function)作为一等公民,使用上非常自由,无论调用它,或者作为参数, 或者作为返回值均可。
它是JavaScript异步编程的基础。
函数式编程的基础为:
- 高阶函数
- 偏函数
高阶函数
高阶函数则是可以把函数作为参数,或是将函数作为返回值的函数。
除了通常意义的函数调用返回外,还形成了一种后续传递风格的结果接收方式,而非单一的返回值形式。
后续传递风格的程序编写将函数的业务重点从返回值转移到了回调函数。
例子:
var points = [40, 100, 1, 5, 25, 10];
points.sort(function(a, b) {
return a - b;
});
// [ 1, 5, 10, 25, 40, 100 ]
一个经典的例子便是数组的sort()方法,它是一个货真价实的高阶函数,可以接受一个方法作为参数参与运算排序。
通过改动sort()方法的参数,可以决定不同的排序方式,从这里可以看出高阶函数的灵活性来。
结合Node提供的最基本的事件模块可以看到,事件的处理方式正是基于高阶函数的特性来完成的。
在自定义事件实例中,通过为相同事件注册不同的回调函数,可以很灵活地处理业务逻辑。
事件可以十分方便地进行复杂业务逻辑的解耦,它其实受益于高阶函数。
高阶函数在JavaScript中比比皆是,其中ECMAScript5中提供的一些数组方法(forEach()、 map()、reduce()、reduceRight()、filter()、every()、some())十分典型。
偏函数
偏函数用法是指创建一个调用另外一个部分一参数或变量已经预置的函数一的函数的用法。
异步编程的优势与难点
曾经的单线程模型在同步I/O的影响下,由于I/O调用缓慢,在应用层面导致CPU与I/O无法重叠进行。
为了照顾编程人员的阅读思维习惯,同步I/O盛行了很多年。
提升性能的方式解决方案:
- 多用多线程的方式解决
但是多线 程的引入在业务逻辑方面制造的麻烦也不少。从操作系统调度多线程的上下文切换开销,到实际 编程里的锁、同步等问题,让开发人员头疼的时候也并不少。- 通过 C/C++调用操作系统底层接口,自己手工完成异步I/O。
这能够达到很高的性能,但是调试和开发门槛均十分高,在帮助业务解决问题上,需要花费较大的精力。- Node利用JavaScript及其内部异步库,将异步直接提升到业务层面,这是一种创新。
优势
Node带来的最大特性莫过于基于事件驱动的非阻塞I/O模型,这是它的灵魂所在。
非阻塞I/O 可以使CPU与I/O并不相互依赖等待,让资源得到更好的利用。
对于网络应用而言,并行带来的想象空间更大,延展而开的是分布式和云。
并行使得各个单点之间能够更有效地组织起来,这也是Node在云计算厂商中广受青睐的原因。
解释:
我们讨论过Node实现异步I/O的原理。利用事件循环的方式,JavaScript线程像一个分配任务和处理结果的大管家,I/O线程池里的各个I/O线程都是小二,负责兢兢业业地完成分配来的任务,小二与管家之间互不依赖,所以可以保持整体的高效率。
这个利用事件循环的经典调度方式在很多地方都存在应用,最典型的是UI编程,如iOS应用开发等。
这个模型的缺点则在于管家无法承担过多的细节性任务,如果承担太多,则会影响到任务的调度,管家忙个不停,小二却得不到活干,结局则是整体效率的降低。
言之,Node是为了解决编程模型中阻塞I/O的性能问题的,采用了单线程模型,这导致Node 更像一个处理I/O密集问题的能手,而CPU密集型则取决于管家的能耐如何。(尽管Node的处理性能相当高)
由于事件循环模型需要应对海量请求,海量请求同时作用在单线程上,就需要防止任何一个 计算耗费过多的CPU时间片。至于是计算密集型,还是I/O密集型,只要计算不影响异步I/O的调 度,那就不构成问题。
建议对CPU的耗用不要超过10 ms,或者将大量的计算分解为诸多的小量计算,通过setlmmediate。进行调度。
只要合理利用Node的异步模型与V8的高性能,就可以充分发挥CPU和I/O资源的优势。
难点
异常处理
过去我们处理异常时,通常使用类Java的try/catch/final语句块进行异常捕获,示例代码如下:
try {
JSON.parse(json);
} catch (e) {
// TODO
}
但是这对于异步编程而言并不一定适用。
异步I/O的实现主要包含两个阶段: 提交请求和处理结果。
这两个阶段中间有事件循环的调度,两者彼此不关联。
异步方法则通常在第一个阶段提交请求后立即返回,因为异常并不一定发生在这个阶段,try/catch的功效在此处不会发挥任何作用。
异步方法定义如下:
var async = function (callback) {
process.nextTick(callback);
};
调用async()方法后,callback被存放起来,直到下一个事件循环(Tick)才会取出来执行。 尝试对异步方法进行try/catch操作只能捕获当次事件循环内的异常,对callback执行时抛出的异 常将无能为力。代码如下:
try {
async(callback);
} catch (e) {
// TODO
}
Node在处理异常上形成了一种约定,将异常作为回调函数的第一个实参传回,如果为空值, 则表明异步调用没有异常抛出:
async(function (err, results) {
// TODO
});
在我们自行编写的异步方法上,也需要去遵循这样一些原则:
- 原则一:必须执行调用者传入的回调函数;
- 原则二:正确传递回异常供调用者判断。
示例代码如下:
var async = function (callback) {
process.nextTick(function() {
var results = something;
if (error) {
return callback(error);
}
callback(null, results);
});
};
在异步方法的编写中,另一个容易犯的错误是对用户传递的回调函数进行异常捕获,示例代 码如下:
try {
req.body = JSON.parse(buf, options.reviver);
callback();
} catch (err){
err.body = buf;
err.status = 400;
callback(err);
}
上述代码的意图是捕获JSON.parse()中可能出现的异常,但是却不小心包含了用户传递的回调函数。这意味着如果回调函数中有异常抛出,将会进入catch()代码块中执行,于是回调函数 将会被执行两次。这显然不是预期的情况,可能导致业务混乱。正确的捕获应当为:
try {
req.body = JSON.parse(buf, options.reviver);
} catch (err){
err.body = buf;
err.status = 400;
return callback(err);
}
callback();
总结
在编写异步方法时,只要将异常正确地传递给用户的回调方法即可,无须过多处理。
函数嵌套过深
这或许是Node被人诟病最多的地方。
在前端开发中,D0M事件相对而言不会存在互相依赖 或需要多个事件一起协作的场景,较少存在异步多级依赖的情况。
下面的代码为彼此独立的D0M 事件绑定:
$(selector).click(function (event) {
// TODO
});
$(selector).change(function (event) {
// TODO
});
但是对于Node而言,事务中存在多个异步调用的场景比比皆是。比如一个遍历目录的操作, 其代码如下:
fs.readdir(path.join(__dirname, '..'), function(err, files) {
files.forEach(function(filename, index) {
fs.readFile(filename, 'utf8', function(err, file) {
// TODO
});
});
});
对于上述场景,由于两次操作存在依赖关系,函数嵌套的行为也许情有可原。
那么,在网页 渲染的过程中,通常需要数据、模板、资源文件,这三者互相之间并不依赖,但最终渲染结果中 三者缺一不可。
如果采用默认的异步方法调用,程序也许将会如下所示:
fs.readFile(template_path, 'utf8', function(err, template) {
db.query(sql, function(err, data) {
l10n.get(function(err, resources) {
// TODO
});
});
});
这在结果的保证上是没有问题的,问题在于这并没有利用好异步I/O带来的并行优势。
这是异步编程的典型问题,为此有人曾说,因为嵌套的深度,未来最难看的代码必将从Node中诞生。
但是实际情况没有想象得那么糟糕,且看后面如何解决该问题。(利用Promise等)
阻塞代码
对于进入JavaScript世界不久的开发者,比较纳闷这门编程语言竟然没有sleep()这样的线程 沉睡功能,唯独能用于延时操作的只有setlnterval()和setTimeout()这两个函数。
但是让人惊讶 的是,这两个函数并不能阻塞后续代码的持续执行。
所以,有多半的开发者会写出下述这样的代 码来实现sleep(1000)的效果:
// TODO
var start = new Date();
while (new Date() - start < 1000) {
// TODO
}
// 阻塞的代码
但是事实是糟糕的,这段代码会持续占用CPU进行判断,与真正的线程沉睡相去甚远,完全破坏了事件循环的调度。
由于Node单线程的原因,CPU资源全都会用于为这段代码服务,导致其 余任何请求都会得不到响应。
遇见这样的需求时,在统一规划业务逻辑之后,调用setTimeout()的效果会更好。
多线程编程
我们在谈论JavaScript的时候,通常谈的是单一线程上执行的代码,这在浏览器中指的是 JavaScript执行线程与UI渲染共用的一个线程;在Node中,只是没有UI渲染的部分,模型基本相同。
对于服务器端而言,如果服务器是多核CPU,单个Node进程实质上是没有充分利用多核CPU的。 随着现今业务的复杂化,对于多核CPU利用的要求也越来越高。浏览器提出了Web Workers,它通 过将JavaScript执行与UI渲染分离,可以很好地利用多核CPU为大量计算服务。同时前端Web Workers也是一个利用消息机制合理使用多核CPU的理想模型。
Web Workers能解决利用CPU和减少阻塞UI渲染,但是不能解决UI渲染的效率问题。
Node借鉴了这个模式,child_process是其基础API,cluster模块是更深层次的应用。
异步转同步
习惯异步编程的同学,也许能够从容面对异步编程带来的副产品,比如嵌套回调、业务分散等问题。
Node提供了绝大部分的异步API和少量的同步API,偶尔出现的同步需求将会因为没有同步API让开发者突然无所适从。
目前,Node中试图同步式编程,但并不能得到原生支持,需要借助库或者编译等手段来实现。
但对于异步调用,通过良好的流程控制,还是能够将逻辑梳理成顺序式的形式。
异步编程解决方案
目前,异步编程的主要解决方案有如下3种。
- 事件发布/订阅模式。
- Promise/Deferred模式。
- 流程控制库。
事件发布/订阅模式
事件监听器模式是一种广泛用于异步编程的模式,是回调函数的事件化,又称发布/订阅模式。
说明:
发布/订阅模式是事件监听模式(类似浏览器的事件模型,用户可以自定义事件模型,实现事件的监听和触发)的升级版;
这种方法的性质与“事件监听”类似,但是明显优于后者。因为可以通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,从
而监控程序的运行。
Node 自身提供的events模块是发布/订阅模式的 一个简单实现,Node中部分模块都继承自它;
这个模块比前端浏览器中的大量DOM事件简单, 不存在事件冒泡,也不存在preventDefault()、stopPropagation()和stopImmediatePropagation() 等控制事件传递的方法。
它具有 addListener/on()、once()、removeListener()、 removeAllListeners()和emit()等基本的事件监听模式的方法实现。
// 订阅
emitter.on("event1", function (message) {
console.log(message);
});
// 发布
emitter.emit('event1', "I am message!");
注意:
- 事件发布/订阅模式自身并无同步和异步调用的问题,但在Node中,emit()调用多半是伴随事件循环而异步触发的,
所以我们说事件发布/订阅广泛应用于异步编程。- 事件发布/订阅模式常常用来解耦业务逻辑,事件发布者无须关注订阅的侦听器如何实现业务逻辑,甚至不用关注有多少个侦听器存在,数据通过消息的方式可以很灵活地传递。一些典型场景中,可以通过事件发布/订阅模式进行组件封装,将不变的部分封装在组件内部,将容易变化、需自定义的部分通过事件暴露给外部处理,这是一种典型的逻辑分离方式。在这种事件发 布/订阅式组件中,事件的设计非常重要,因为它关乎外部调用组件时是否优雅,从某种角度来说事件的设计就是组件的接口设计。
- 从另一个角度来看,事件侦听器模式也是一种钩子(hook)机制,利用钩子导出内部数据或 状态给外部的调用者。Node中的很多对象大多具有黑盒的特点,功能点较少,如果不通过事件钩 子的形式,我们就无法获取对象在运行期间的中间值或内部状态。这种通过事件钩子的方式,可 以使编程者不用关注组件是如何启动和执行的,只需关注在需要的事件点上即可。
例子:
var options = {
host: 'www.google.com',
port: 80,
path: '/upload',
method: 'POST'
};
var req = http.request(options, function (res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log('BODY: ' + chunk);
});
res.on('end', function () {
// TODO
});
});
req.on('error', function (e) {
console.log('problem with request: ' + e.message);
});
// write data to request body
req.write('data\n');
req.write('data\n');
req.end();
// 在这段HTTP请求的代码中,程序员只需要将视线放在error、data、end这些业务事件点上 即可,至于内部的流程如何,无需过于关注。
Promise/Deferred模式
流程控制库
说明:
书中所说的一些流行库现在已经在ES6中有相应的API;主要是Generator和async / await
小结
这里简单对比下几种方案的区别:
- 事件发布/订阅模式相对算是一种较为原始的方式,
- Promise/Deferred模式贡献了一个非常不错的异步任务模型的抽象。而上述的这些异步流程控制方案与Promise/Deferred模式的思路不同,Promise/Deferred的重头在于封装异步的调用部分,流程控制库则显得没有模式,将处理重点放置在回调函数的注入上。
- 从自由度上来讲,async、Step 这类流控库要相对灵活得多
内存控制
内存控制是在海量请求和长时间运行的前提下进行探讨的。在服务器端,资源向来就寸土寸金,要为海量用户服务,就得使一切资源都要高效循环利用。
V8的垃圾回收机制与内存限制
JavaScript与Java 一样,由垃圾回收机制来进行自动内存管理, 这使得开发者不需要像C/C++程序员那样在编写代码的过程中时刻关注内存的分配和释放问题。
浏览器开发中,很少遇见垃圾回收机制对应用程序性能的影响。
当主流应用场景从客户端延伸到服务器端之后,我们就能发现,对于性能敏感的服务器端程序,内存管理的好坏、垃圾回收状况是否优良,都会对服务构成影响。而在Node中,这一切都与Node的JavaScript执行引擎V8息息相关。
Node与V8
Node在JavaScript的执行上直接受益于V8,可以随着V8的升级就能享受到更好的性能或新的语言特性(如ES5和ES6 )等,同时也受到V8的一些限制,尤其是本章要重点讨论的内存限制。
V8的内存限制
在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript 使用内存时就会发现只能使用部分内存(64位系统下约为1.4 GB, 32位系统下约为0.7 GB )。在这样的限制下,将会导致Node无法直接操作大内存对象(可使用stream对象和Buffer对象:buffer所占用的内存其实不是堆中的,而是堆外内存。关于Buffer参考:Node.js之Buffer对象浅析),
比如无法将一个2 GB的文件读入内存 中进行字符串分析处理,即使物理内存有32 GB。这样在单个Node进程的情况下,计算机的内存 资源无法得到充足的使用。
造成这个问题的主要原因在于Node基于V8构建,所以在Node中使用的JavaScript对象基本上 都是通过V8自己的方式来进行分配和管理的。
V8的对象分配
在V8中,所有的JavaScript对象都是通过堆(heap)来进行分配的。(普通基本类型,是通过栈-stack内存分配;堆栈内存即为Node占用内存之和)
可以通过一下方式查看内存信息:$ node process.memoryUsage(); { rss: 14958592, heapTotal: 7195904, heapUsed: 2821496 }
在memoryUsage()方法返回的3个属性中,heapTotal和heapUsed是V8的堆内存 使用情况,前者是已申请到的堆内存,后者是当前使用的量。rss是resident set size的缩写,即进程的常驻内存部分。进程的内存总共有几部分,一部分是 rss,其余部分在交换区(swap)或者文件系统(filesystem)中。
当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲 内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。
至于V8为何要限制堆的大小:
- 表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。对于网页来说,V8的限制值已经绰绰有余。
- 深层原因是V8的垃圾回收机制的限制。
按官方的说法,以1.5 GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这时垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。这样的情况不仅仅后端服务无法接受, 前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存是一个好的选择。
V8的垃圾回收机制
V8主要的垃圾回收算法
V8的垃圾回收策略主要基于分代式垃圾回收机制。在自动垃圾回收的演变过程中,人们发现没有一种垃圾回收算法能够胜任所有的场景。因为在实际的应用中,对象的生存周期长短不一,不同的算法只能针对特定情况具有最好的效果。为此,统计学在垃圾回收算法的发展中产生了较大的作用,现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。
V8的内存分代
在V8中,主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象, 老生代中的对象为存活时间较长或常驻内存的对象。
V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。
前面我们提及的 --max-old-space-size命令行参数可以用于设置老生代内存空间的最大值,--max-new-space-size 命令行参数则用于设置新生代内存空间的大小的。
对于新生代内存,它由两个reserved_semispace_size_所构成,后面将描述其原因。
按机器位数不同,reserved_semispace_size_在64位系统和32位系统上分别为16 MB和8 MB。所以新生代内存的最大值在64位系统和32位系统上分别为32 MB和16 MB。
Scavenge算法
在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收。
它将堆内存一分为二,每一部分空 间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。
- 当我们分配对象 时,先是在From空间中进行分配。
- 当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。
- 完成复制后,From空间和To空间的角色发生对换。
简而言之,在垃圾回收的过程中,就是通过将存活对象在两个 semispace空间之间进行复制。
Scavenge的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。
但 Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。
由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收 中。
但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰 适合这个算法。
关于晋升:
当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。
这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。
对象从新生代中移动到老生代中 的过程称为晋升。
对象晋升的条件主要有两个:
一个是对象是否经历过Scavenge回收:对象从From空间中复制到To空间时, 会检查它的内存地址来判断这个对象是否已经经历过一次
Scavenge回收。如果已经经历过了,会 将该对象从From空间复制到老生代空间中,如果没有,则复制到To空间中。
一个是To空间的内存占用比超过限制:当要从From空间复制一个对象到To空间时,如果 To空间已经使用了超过25%,则这个对象直接晋
升到老生代空间中
对象晋升后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收算法处理。
Mark-Sweep & Mark-Compact 算法
对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:
- 一个是存活对象较多,复制存活对象的效率将会很低;
- 另一个问题依然是浪费一半空间的问题。
这两个问题导致应对生命周期较长的对象时Scavenge会显得捉襟见肘。
为此,V8在老生代中主要采用了 Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
- Mark-Sweep
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。
与Scavenge相比,Mark-Sweep 并不将内存空间划分为两半,所以不存在浪费一半空间的行为。
与Scavenge复制活着的对象不同, Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。
活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处 理的原因。
老生代空间中标记后的示意图,黑色部分标记为死亡的对象。
Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。- Mark-Compact
Mark-Compact是标记整理 的意思,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。这里将Mark-Sweep和Mark-Compact结合着介绍不仅仅是因为两种策略是递进关系,在V8的回收策略中两者是结合使用的。
3种垃圾回收算法的简单对比
在Mark-Sweep和Mark-Compact之间,由于Mark-Compact需要移动对象, 所以它的执行速度不可能很快,所以在取舍上,V8主要使用
Mark-Sweep,在空间不足以对从新 生代中晋升过来的对象进行分配时才使用Mark-Compact。
Incremental Marking 算法
为了避免出现J avaScript应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。
在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。
但V8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(fUll垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。
为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小"步进",每做完一"步进" 就让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。
增量标记示意图:
V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
高效使用内存
在V8面前,开发者所要具备的责任是如何让垃圾回收机制更高效地工作。
在正常的J avaScript执行中,无法立即回收的内存有闭包和全局变量引用这两种情况。
由于V8的内存限制,要十分小心此类变量是否无限制地增加,因为它会导致老生代中的对象增多。
内存查看
pocess.memoryUsage()可以查看内存使用情况。
除此之外,os模块中的 totalmem ()和freemem()方法也可以查看内存使用情况。
查看进程的内存占用——Node进程内存使用情况
调用process.memoryUsage()可以看到Node进程的内存占用情况,示例代码如下:
$ node
> process.memoryUsage()
{ rss: 13852672,
heapTotal: 6131200,
heapUsed: 2757120 }
rss是resident set size的缩写,即进程的常驻内存部分。
进程的内存总共有几部分,一部分是 rss,其余部分在交换区(swap)或者文件系统(filesystem)中。
除了rss外,heapTotal和heapUsed对应的是V8的堆内存信息。
heapTotal是堆中总共申请的内存量,heapUsed表示目前堆中使用中的内存量。
查看系统的内存占用——系统内存使用情况
与process.memoryUsage()不同的是,os模块中的totalmem()和freemem()这两个方法用于查看操作系统的内存使用情况,它们分别返回系统的总内存和闲置内存,以字节为单位。
堆外内存
通过process.momoryUsage()的结果可以看到,堆中的内存用量总是小于进程的常驻内存用量,
这意味着Node中的内存使用并非都是通过V8进行分配的。我们将那些不是通过V8分配的内存称为堆外内存。
利用堆外内存可以突破内存限制的问题
Buffer对象并非通过V8分配;这在于Node并不同于浏览器的应用场景。
在浏览器中, JavaScript直接处理字符串即可满足绝大多数的业务需求,而Node则需要处理网络流和文件I/O流, 操作字符串远远不能满足传输的性能需求。
小结
Node的内存构成主要由通过V8进行分配的部分和Node自行分配 部分。受V8的垃圾回收限制的主要是V8的堆内存。
内存泄漏
通常,造成内存泄漏的原因有如下几个。
- 缓存。
- 队列消费不及时。
- 作用域未释放。
慎将内存当做缓存
在Node中,缓存并非物美价廉。一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。
以在Node中,任何试图拿内存当缓存的行为都应当被限制。当然,这种限制并不是不允许 使用的意思,而是要小心为之。
关注队列状态
在解决了缓存带来的内存泄漏问题后,另一个不经意产生的内存泄漏则是队列。
在JavaScript中可以通过队列(数组对象)来完成许多特殊的需求。
队列在消费者-生产者模型中经常充当中间产物。
这是一个容易忽略的情况,因为在大多数应用场景下,消费的速度远远大于生产的速度,内存泄漏不易产生。
但是一旦消费速度低于生产速度, 将会形成堆积。
大内存应用
在Node中,不可避免地还是会存在操作大文件的场景。由于Node的内存限制,操作大文件也需要小心,好在Nde提供了 stream模块用于处理大文件。
stream模块是Node的原生模块,直接引用即可。stream继承自EventEmitt er,具备基本的自定义事件功能,同时抽象出标准的事件和方法。它分可读和可写两种。Node中的大多数模块都有 st ream的应用,比如fs的createReadStream ()和createWriteStream ()方法可以分别用于创建文件的可读流和可写流,process模块中的stdin和stdout则分别是可读流和可写流的示例。
由于V8的内存限制,我们无法通过fs.readFile()和fs.writeFile()直接进行大文件的操作, 而改用fs. createReadStream()和fs. createWriteS tream()方法通过流的方式实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入到另一个文件的过程:
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不限制堆内存的大小,物理内存依然有限制。
关于Buffer和Stream的区别
- buffer:为数据缓冲对象,是一个类似数组结构的对象,可以通过指定开始写入的位置及写入的数据长度,往其中写入二进制数据
- stream:是对buffer对象的高级封装,其操作的底层还是buffer对象。
stream可以设置为可读、可写,或者即可读也可写,在nodejs中继承了EventEmitter接口,可以监听读入、写入的过程。
具体实现有文件流,httpresponse等
Buffer
文件和网络I/O对于前端开发者而言都是不曾有的应用场景,因为前端只需做一些简单的字符串操作或 D0M操作基本就能满足业务需求,在ECMAScript规范中,也没有对这些方面做任何的定义,只 有CommonJ S中有部分二进制的定义。
Node中,应用需要处理网络协议、 操作数据库、处理图片、接收上传文件等,在网络流和文件的操作中,还要处理大量二进制数据, JavaScript自有的字符串远远不能满足这些需求,于是Buffer对象应运而生。
Buffer 结构
Buffer是一个像Array的对象,但它主要用于操作字节。
模块结构
Buffer是一个典型的JavaScript与C++结合的模块,它将性能相关部分用C++实现,将非性能 相关的部分用J avaScript实现
如图所示
Buffer所占用的内存不是通过V8分配的,属于堆外内存。由于V8垃圾回收性能的影响,将常用的操作对象用更高效和专有的内存分配回收策略来管理是个不错的思路。
由于Buffer太过常见,Node在进程启动时就已经加载了它,并将其放在全局对象(global) 上。所以在使用Buffer时,无须通过require。即可直接使用。
Buffer 对象结构
Buffer对象类似于数组,它的元素为16进制的两位数,即0到255的数值。
Buffer内存分配
Buffer对象的内存分配不是在V8的堆内存中,而是在Node的C++层面实现内存的申请的。因为处理大量的字节数据不能采用需要一点内存就向操作系统申请一点内存的方式,这可能造成大 量的内存申请的系统调用,对操作系统有一定压力。为此Node在内存的使用上应用的是在C++ 层面申请内存、在JavaScript中分配内存的策略。
为了高效地使用申请来的内存,Node采用了slab分配机制。slab是一种动态内存管理机制,最早 诞生于SunOS操作系统(Solaris )中,目前在一些*nix操作系统中有广泛的应用,如FreeBSD和Linux。
Buffer的转换
Buffer对象可以与字符串之间相互转换。目前支持的字符串编码类型有如下这几种。
- ASCII
- UTF-8
- UTF-16LE/UCS-2
- Base64
- Binary
- Hex
Buffer 与性能
Buffer在文件I/O和网络I/O中运用广泛,尤其在网络传输中,它的性能举足轻重。
在应用中, 我们通常会操作字符串,但一旦在网络中传输,都需要转换为Buffer,以进行二进制数据传输。
在Web应用中,字符串转换到Buffer是时时刻刻发生的,提高字符串到Buffer的转换效率,可以很 大程度地提高网络吞吐率。
在网络传输中
在网络数据传输中,通过预先转换静态内容为Buffer对象,可以有效地减少CPU的重复使用,节省服务器资源。
在Node构建的Web应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通 过预先转换为Buffer的方式,使性能得到提升。由于文件自身是二进制数据,所以在不需要改变 内容的场景下,尽量只读取Buffer,然后直接传输,不做额外的转换,避免损耗。
进程
Node在选型时决定在V8引擎之上构建,也就意味着它的模型与浏览器类似。我们的JavaScript 将会运行在单个进程的单个线程上。
优点:
- 它带来的好处是:程序状态是单一的,在没有多线程的情况下没有锁、线程同步问题,操作系统在调度时也因为较少上下文的切换,可以很好地提高CPU的 使用率。
问题:- 如何充分利用多核CPU服务器(利用率不足的问题)
单进程单线程并非完美的结构,如今CPU基本均是多核的,真正的服务器(非VPS)往 往还有多个CPU。一个Node进程只能利用一个核- 如何保证进程的健壮性和稳定性(对于实际产品化带来一定的顾虑)
由于Node执行在单线程上,一旦单线程上抛出的异常没有被捕获,将会引起整个进程 的崩溃。
说明:
从严格的意义上而言,Node并非真正的单线程架构,前面有叙述过Node自身还有 一定的I/O线程存在,这些I/O线程由底层
libuv处理,这部分线程对于JavaScript开发者而言是透明 的,只在C++扩展开发时才会关注到。JavaScript代码永远运行在V8上,是单线
程的。
服务模型的变迁
服务器架构的历史变迁
同步
最早的服务器,其执行模型是同步的,它的服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。这意味除了当前的请求被处理外,其余请求都处于耽误的状态。它的处理能力相当低下,假设每次响应服务耗用的时间稳定为N秒,这类服务的Q PS为1/N。
复制进程(多进程)
为了解决同步架构的并发问题,一个简单的改进是通过进程的复制同时服务更多的请求和用户。这样每个连接都需要一个进程来服务,即100个连接需要启动100个进程来进行服务,这是非 常昂贵的代价。在进程复制的过程中,需要复制进程内部的状态,对于每个连接都进行这样的复制的话,相同的状态将会在内存中存在很多份,造成浪费。并且这个过程由于要复制较多的数据, 启动是较为缓慢的。
为了解决启动缓慢的问题,预复制(prefork)被引入服务模型中,即预先复制一定数量的进程。同时将进程复用,避免进程创建、销毁带来的开销。但是这个模型并不具备伸缩性,一旦并发请求过高,内存使用随着进程数的增长将会被耗尽。
假设通过进行复制和预复制的方式搭建的服务器有资源的限制,且进程数上限为M,那这类服务的QPS为M/N。
多线程
为了解决进程复制中的浪费问题,多线程被引入服务模型,让一个线程服务一个请求。线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。但是多线程所面临的并发问题只能说比多进程略好,因为每个线程都拥有自己独立的堆栈,这个堆栈都需要占用一定的内存空间。另外,由于一个CPU核心在一个时刻只能做一件事情,操作系统只能通过将CPU切分为时间片的方法,让线程可以较为均匀地使用CPU资源,但是操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时, 时间将会被耗用在上下文切换中。所以在大并发量时,多线程结构还是无法做到强大的伸缩性。
事件驱动
多线程的服务模型服役了很长一段时间,Apache就是采用多线程/多进程模型实现的,当并发增长到上万时,内存耗用的问题将会暴露出来
为了解决高并发问题,基于事件驱动的服务模型出现了,像Node与Nginx均是基于事件驱动的方式实现的,采用单线程避免了不必要的内存开销和上下文切换开销。
基于事件的服务模型存在的问题即是刚才提及的两个问题:CPU的利用率和进程的健壮性。
由于所有处理都在单线程上进行,影响事件驱动服务模型性能的点在于CPU的计算能力,它的上限决定这类服务模型的性能上限,但它不受多进程或多线程模式中资源上限的影响,可伸缩性远比前两者高。如果解决掉多核CPU的利用问题,带来的性能上提升是可观的。
多进程架构
面对单进程单线程对多核使用不足的问题,前人的经验是启动多进程即可。理想状态下每个 进程各自利用一个CPU,以此实现多核CPU的利用。所幸,Node提供了child_process模块,并且也提供了 child_process.fork()函数供我们实现进程的复制。
// 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');
// master.js 主进程
var fork = require('child_process').fork;
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js');
}
这段代码将会根据当前机器上的CPU数量复制出对应Node进程数。
图9-1就是著名的Master-Worker模式,又称主从模式。图9-1中的进程分为两种:主进程和工作进程。这是典型的分布式架构中用于并行处理业务的模式,具备较好的可伸缩性和稳定性。主 进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋向于稳定的。工作进程负责 具体的业务处理,因为业务的多种多样,甚至一项业务由多人开发完成,所以工作进程的稳定性 值得开发者关注。
通过fork()复制的进程都是一个独立的进程,这个进程中有着独立而全新的V8实例。它需要至少30毫秒的启动时间和至少10 MB的内存。尽管Node提供了fork()供我们复制进程使每个CPU 内核都使用上,但是依然要切记fork()进程是昂贵的。好在Node通过事件驱动的方式在单线程上解决了大并发的问题,这里启动多个进程只是为了充分将CPU资源利用起来,而不是为了解决并发问题。
创建子进程
child_process模块给予Node可以随意创建子进程(child_process )的能力。它提供了4个方法用于创建子进程。
- spawn():启动一个子进程来执行命令。
- exec():启动一个子进程来执行命令,与spawn。不同的是其接口不同,它有一个回调函数获知子进程的状况。
- execFile():启动一个子进程来执行可执行文件。
- fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。
spawn()与exec(),execFile()不同的是,后两者创建时可以指定timeout属性设置超时时间, 一旦创建的进程运行超过设定的时间将会被杀死。
exec()与execFile()不同的是,exec()适合执行已有的命令,execFile()适合执行文件。
以上4个方法在创建子进程之后均会返回子进程对象。它们的差别可以通过表9-1查看。
注意:
这里的可执行文件是指可以直接执行的文件,如果是JavaScript文件通过execFile()运行,它的首行内容必须添加如下代码:
#!/usr/bin/env node
进程间通信
在Master-Worker模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。对于child_process模块,创建好了子进程,然后与父子进程间通信是十分容易的。
// 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()方法,接口十分简洁和消息化。
图9-2为IPC 创建和实现的示意图。
父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通 过环境变量(NODE_CHANNEL_FD)告诉子进程这个IPC通道的文件描述符。
子进程在启动的过程中, 根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。图9-3为创建IPC 管道的步骤示意图。
建立连接之后的父子进程就可以自由地通信了。
由于IPC通道是用命名管道或Domain Socket 创建的,它们与网络socket的行为比较类似,属于双向通信。
不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。
在Node中,IPC通道被抽象为Stream对象,在调用send()时发送数据(类似于write),接收到的消息会通过message事件(类似于data) 触发给应用层。
注意:
只有启动的子进程是Node进程时,子进程才会根据环境变量去连接IPC通道,对于其他类型 的子进程则无法实现进程间通信,除非其他
进程也按约定去连接这个已经创建好的IPC通道。
句柄传递
在多个进程监听同一端口的需求下的解决方案:
代理方案
要解决监听同一端口这个问题,通常的做法是让每个进程监听不同的端口,其中主进程监听主端口(如80),主进程对外接收所有的网络请求,再将这些请求分别代理到不同的端口的进程上。示意图如图9-4所示。
通过代理,可以避免端口不能重复监听的问题,甚至可以在代理进程上做适当的负载均衡, 使得每个子进程可以较为均衡地执行任务。
由于进程每接收到一个连接,将会用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符。
操作系统的文件描述符是有限的,代理方案浪费掉一倍数量的文件描述符的做法影响了系统的扩展能力。
句柄传递方案
Node引入了进程间发送句柄的功能。
send()方法除 了能通过IPC发送数据外,还能发送句柄,第二个可选参数就是句柄,如下所示:
child.send(message, [sendHandle])
什么是句柄
句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。
比如句柄可以用来标识一个服务器端socket对象、一个客户端socket对象、一个UDP套接字、 一个管道等。
发送句柄意味着什么?
在前一个问题中,我们可以去掉代理这种方案,使主进程接收到socket 请求后,将这个socket直接发送给工作进程,而不是重新与工作进程之间建立新的socket连接来转发数据。
文件描述符浪费的问题可以通过这样的方式轻松解决。
代码如下:
// 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);
});
}
});
所有的请求都是由子进程处理了。整个过程中,服务的过程发生了一次改变,如 图9-5所示。
主进程发送完句柄后关闭监听,成为如图9-6所示的结构
解析句柄的发送和还原
目前子进程对象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值和得到的文件描述符一起还原出一个 对应的对象。
这个过程的示意图如图9-7所示。
以发送的TCP服务器句柄为例,子进程收到消息后的还原过程如下所示:
function(message, handle, emit) {
var self = this;
var server = new net.Server();
server.listen(handle, function() {
emit(server);
});
}
上面的代码中,子进程根据message.type创建对应TCP服务器对象,然后监听到文件描述符上。
由于底层细节不被应用层感知,所以在子进程中,开发者会有一种服务器就是从父进程中直接传递过来的错觉。
值得注意的是,Node进程之间只有消息传递,不会真正地传递对象,这种错觉是抽象封装的结果。
目前Node只支持上述提到的几种句柄,并非任意类型的句柄都能在进程之间传递,除非它有完整的发送和还原的过程。
端口共同监听
为什么多个进程可以监听到 相同的端口而不引起EADDRINUSE异常。
其答案也很简单,我们独立启动的进程中,与TCP服务器端 socket套接字的文件描述符并不相同,导致监听到相同的端口时会抛出异常。
由于独立启动的进程互相之间并不知道文件描述符,所以监听相同端口时就会失败。
但对于send()发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引起异常。
多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。
换言之就是网络请求向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进行服务。 这些进程服务是抢占式的。
Cluster 模块
cluster模块,用以解决多核CPU的利用率问题,同时也提供了较完 善的API,用以处理进程的健壮性问题。
Cluster工作原理
事实上cluster模块就是child_process和net模块的组合应用。
cluster启动时,它会在内部启动TCP服务器,在cluster.fork()子进程时,将这个TCP服务器端socke啲文件描述符发送给工作进程。
- 如果进程是通过cluster.fork()复制出来的,那么它的环境变量里就存在NODE_UNIQUE_ID;
- 如果工作进程中存在listen()侦听网络端口的调用,它将拿到该文件描述符,通过SO_REUSEADDR端口重用,从而实现多个子进程共享端口。
对于普通方式启动的进程,则不存在文件描述符传递共享等事情。
在clus ter内部隐式创建TCP服务器的方式对使用者来说十分透明,但也正是这种方式使得它 无法如直接使用child_process那样灵活。在cluster模块应用中,一个主进程只能管理一组工作 进程,如图9-12所示。
对于自行通过child_process来操作时,则可以更灵活地控制工作进程,甚至控制多组工作进程。
其原因在于自行通过child_process操作子进程时,可以隐式地创建多个TCP服务器,使得子进程可以共享多个的服务器端socket,
如图9-13所示
Cluster 事件
对于健壮性处理,cluster模块也暴露了相当多的事件。
- fork:复制一个工作进程后触发该事件。
- online:复制好一个工作进程后,工作进程主动发送一条online消息给主进程,主进程收 到消息后,触发该事件。
- listening:工作进程中调用listen()(共享了服务器端Socket)后,发送一条listening 消息给主进程,主进程收到消息后,触发该事件。
- disconnec t:主进程和工作进程之间IPC通道断开后会触发该事件。
- exi t:有工作进程退出时触发该事件。
- setup: cluster.setupMaster()执行后触发该事件。
这些事件大多跟child_process模块的事件相关,在进程间消息传递的基础上完成的封装。
测试
测试包含单元测试、性能测试、安全测试和功能测试等几个方面,本章将从Node实践的角度来介绍单元测试和性能测试。
单元测试
单元测试主要用于检测代码的行为是否符合预期。
对于开发者而言,单元测试就是最基本的一种方式。
展开介绍单元测试之前,需要提及的问题是代码的可测试性,它是能够为其编写单元测试的前提条件。
简单而言,编写可测试代码有以下几个原则可以遵循。
- 单一职责。
如果一段代码承担的职责越多,为其编写单元测试的时候就要构造更多的输入数据,然后推测它的输出。
比如,一段代码中既包含数据库的连接,也包含查询,那么为它编写测试用例就要同时关注数据库连接和数据库查询。
较好的方式是将这两种职责进行解耦分离,变成两个单一职责的方法,分别测试数据库连接和数据库查询。- 接口抽象。
通过对程序代码进行接口抽象后,我们可以针对接口进行测试,而具体代码实现的变化不影响为接口编写的单元测试。- 层次分离。
层次分离实际上是单一职责的一种实现。
在MVC结构的应用中,就是典型的 次分离模型,如果不分离各个层次,无法想象这个代码该如何切入测试。
通过分层之后,可以逐层测试,逐层保证。对于开发者而言,不仅要编写单元测试,还应当编写可测试代码。
单元测试介绍
单元测试主要包含断言、测试框架、测试用例、测试覆盖率、mock、持续集成等几个方面,;
由于Node的特殊性,它还会加入异步代码测试和私有方法的测试这两个部分。
断言
断言就是单元测试中用来保证最小单元是否正常的检测方法。(通过Node的assert模块)
何谓断言:
在程序设计中,断言(assertion)是一种放在程序中的一阶逻辑(如一个结果为真 或是假的逻辑判断式),目的是为了标示程序开发者预期的
结果一当程序运行到断言的位置时,对应的断言应该为真。若断言不为真,程序会中止运行,并出现错误信息。
一言以蔽之,断言用于检查程序在运行时是否满足期望。
如下代码是assert模块的工作方式:
var assert = require('assert');
assert.equal(Math.max(1, 100), 100);
一旦assert.equal()不满足期望,将会抛出AssertionError异常,整个程序将会停止执行。
没有对输出结果做任何断言检查的代码,都不是测试代码。没有测试代码的代码,都是不可信赖的代码。
在断言规范中,我们定义了以下几种检测方法
- ok():判断结果是否为真。
- equal():判断实际值与期望值是否相等。
- notEqual():判断实际值与期望值是否不相等。
- deepEqual():判断实际值与期望值是否深度相等(对象或数组的元素是否相等)
- notDeepEqual():判断实际值与期望值是否不深度相等。
- strictEqual():判断实际值与期望值是否严格相等(相当于===)。
- notStrictEqual():判断实际值与期望值是否不严格相等(相当于!==)。
- throws():判断代码块是否抛出异常。
除此之外,Node的assert模块还扩充了如下两个断言方法。
- doesNotThrow():判断代码块是否没有抛出异常。
- ifError():判断实际值是否为一个假值(null、undefined、0、’’、false),如果实际值 为真值,将会抛出异常。
目前,市面上的断言库大多都是基于assert模块进行封装和扩展的,这包括著名的should.js 断言库。
测试框架
前面提到断言一旦检查失败,将会抛出异常停止整个应用,这对于做大规模断言检查时并不友好。
更通用的做法是,记录下抛出的异常并继续执行,最后生成测试报告。这些任务的承担者就是测试框架。
测试框架用于为测试服务,它本身并不参与测试,主要用于管理测试用例和生成测试报告, 提升测试用例的开发速度,提高测试用例的可维护性和可读性,以及一些周边性的工作。这里我们要介绍的优秀单元测试框架是mocha。
测试风格
我们将测试用例的不同组织方式称为测试风格,现今流行的单元测试风格主要有TDD (测试 驱动开发)和BDD (行为驱动开发)两种,它们的差别如下所示:
- 关注点不同。
TDD关注所有功能是否被正确实现,每一个功能都具备对应的测试用例; BDD关注整体行为是否符合预期,适合自顶向下的设计方式。- 表达方式不同。
TDD的表述方式偏向于功能说明书的风格;BDD的表述方式更接近于自 然语言的习惯。
mocha对于两种测试风格都有支持
BDD风格:
describe('Array', function(){
before(function(){
// ...
});
describe('#indexOf()', function(){
it('should return -1 when not present', function(){
[1,2,3].indexOf(4).should.equal(-1);
});
});
BDD对测试用例的组织主要采用describe和it进行组织。
describe可以描述多层级的结构, 具体到测试用例时,用it。
另外,它还提供before、after、beforeEach和afterEach这4个钩子方 法,用于协助describe中测试用例的准备、安装、卸载和回收等工作。
before和after分别在进入 和退出describe时触发执行,
beforeEach和afterEach则分别在describe中每一个测试用例(it) 执行前和执行后触发执行。
BDD风格的组织示意图:
TDD风格:
suite('Array', function() {
setup(function() {
// ...
});
suite('#indexOf()', function() {
test('should return -1 when not present', function() {
assert.equal(-1, [1, 2, 3].indexOf(4));
});
});
});
TDD对测试用例的组织主要采用suite和test完成。suite也可以实现多层级描述,测试用例用test。
它提供的钩子函数仅包含setup和teardown,对应BDD中的before和after。
TDD风格的组织示意图
测试报告
作为测试框架,mocha设计得十分灵活,它与断言之间并不耦合,使得具体的测试用例既可 以采用assert原生模块,也可以采用扩展的断言库,如should.js、expect和chai等。但无论采用哪个断言库,运行测试用例后,测试报告是开发者和质量管理者都关注的东西。
测试代码的文件组织
包规范中定义了测试代码存在于test目录中,而模块代码 存在于lib目录下。
除此之外,想让你的单元测试顺利运行起来,请记得在包描述文件(package.json)中添加 相应模块的依赖关系。
由于mocha只在运行测试时需要,所以添加到devDependencies节点即可:
// package.json
"devDependencies": {
"mocha": "*"
}
测试用例
一个行为 或者功能需要有完善的、多方面的测试用例,一个测试用例中包含至少一个断言。
测试用例最少需要通过正向测试和反向测试来保证测试对功能的覆盖,这是最基本的测试用例。
对于Node而言,不仅有这样简单的方法调用,还有异步代码和超时设置需要关注。
异步测试
由于Node环境的特殊性,异步调用非常常见,这也带来了异步代码在测试方面的挑战。
在其他典型编程语言中,如Java、Ruby、Python,代码大多是同步执行的,所以测试用例基本上 只要包含一些断言检查返回值即可。
但是在Node中,检查方法的返回值毫无意义,并且不知道回调函数具体何时调用结束,这将导致我们在对异步调用进行测试时,无法调度后续测试用例的执行。
所幸,mocha解决了这个问题。以下为fs模块中readFile的测试用例:
it('fs.readFile should be ok', function(done) {
fs.readFile('file_path', 'utf-8', function(err, data) {
should.not.exist(err);
done();
});
});
在上述代码中,测试用例方法it()接受两个参数;用例标题(title)和回调函数(fn)。
通过检查这个回调函数的形参长度(fn.length )来判断这个用例是否是异步调用,如果是异步调用, 在执行测试用例时,会将一个函数done()注入为实参,测试代码需要主动调用这个函数通知测试框架当前测试用例执行完成,然后测试框架才进行下一个测试用例的执行。
超时设置
异步方法给测试带来的问题并不是断言方面有什么异同,主要在于回调函数执行的时间无从预期。
通过上面的例子,我们无法知道done()具体在什么时间执行。
如果代码偶然出错,导致done() 一直没有执行,将会造成所有的测试用例处于暂停状态,这显然不是框架所期望的。
mocha给所有涉及异步的测试用例添加了超时限制,如果一个用例的执行时间超过了预期时间,将会记录下一个超时错误,然后执行下一个测试用例。
测试覆盖率
通过不停地给代码添加测试用例,将会不断地覆盖代码的分支和不同的情况。
但是如何判断单元测试对代码的覆盖情况,我们需要直观的工具来体现。
测试覆盖率是单元测试中的一个重要指标,它能够概括性地给出整体的覆盖度,也能明确地给出统计到行的覆盖情况。
mock-异常测试
前面提到开发者常常会遗漏掉一些异常案例,其中相当大一部分原因在于异常的情况较难实现。
大多异常与输入数据并无绝对的关系,比如数据库的异步调用,除了输入异常外,还有可能是网络异常、权限异常等非输入数据相关的情况,这相对难以模拟。
在测试领域里,模拟异常其实是一个不小的科目,它有着一个特殊的名词:mock。
私有方法的测试
对于Node而言,又一个难点会出现在单元测试的过程中,那就是私有方法的测试。
只有挂载在exports或module.exports上的变量或方法才可以被外部通过require引 入访问,其余方法只能在模块内部被调用和访问。
在Java—类的语言里,私有方法的访问可以通过反射的方式实现。
那么,Node该如何实现呢? 是否可以因为它们是私有方法就不用为它们添加单元测试?
答案是否定的,为了应用的健壮性,我们应该尽可能地给方法添加测试用例。
那么除了将这些私有方法通过exports导出外,还有别的方法吗?答案是肯定的。
rewire模块提供了一种巧妙的方式实现对私有方法的访问。
rewire的调用方式与require十分类似。对于如下的私有方法,我们获取它并为其执行测试用例非常简单:
var limit = function(num) {
return num < 0 ? 0 : num;
};
// 测试用例
it('limit should return success', function() {
var lib = rewire('../lib/index.js');
var litmit = lib.__get__('limit');
litmit(10).should.be.equal(10);
});
rewire的诀窍在于它引入文件时,像require—样对原始文件做了一定的手脚。
除了添加 (function(exports, require, module, __filename, __dirname) {和});的头尾包装外,它还注入了部分代码,具体如下所示:
(function(exports, require, module, __filename, __dirname) {
var method = function() {};
exports.__set__ = function(name, value) {
eval(name " = "
value.toString());
};
exports.__get__ = function(name) {
return eval(name);
};
});
每一个被rewire引入的模块都有_set_()和__get__()方法。它巧妙地利用了闭包的诀窍,在 eval()执行时,实现了对模块内部局部变量的访问,从而可以将局部变量导出给测试用例调用执行。
工程化与自动化
Node以及第三方模块提供的方法都相对偏底层,在开发项目时,还需要一定的工具来实现工程化和自动化
(这里我们介绍其中的一种方式——持续集成),以减少手工成本。
工程化
Node在*nix系统下可以很好地利用一些成熟工具,其中Makefile比较小巧灵活,适合用来构 建工程。
自动化
将项目工程化可以帮助我们把项目组织成比较固定的结构,以供扩展。
但是对于实际的项目而言,频繁地迭代是常见的状态,如何记录版本的迭代信息,还需要一个持续集成的环境。
性能测试
单元测试主要用于检测代码的行为是否符合预期。
在完成代码的行为检测后,还需要对已有代码的性能作出评估,检测已有功能是否能满足生产环境的性能要求,能否承担实际业务带来的 压力。换句话说,性能也是功能。
性能测试的范畴比较广泛,包括负载测试、压力测试和基准测试等。
除了基准测试,这里还将介绍如何对Web应用进行网络层面的性能测试和业务指标的换算。
基准测试
基本上,每个开发者都具备为自己的代码写基准测试的能力。
基准测试要统计的就是在多少时间内执行了多少次某个方法。
为了增强可比性,一般会以次数作为参照物,然后比较时间,以此来判别性能的差距。
压力测试
除了可以对基本的方法进行基准测试外,通常还会对网络接口进行压力测试以判断网络接口的性能。
对网络接口做压力测试需要考查的几个指标有吞吐率、响应时间和并发数,这些指标反映了服务器的并发处理能力。
最常用的工具是ab、siege、http_load等
测试数据与业务数据的转换
通常,在进行实际的功能开发之前,我们需要评估业务量,以便功能开发完成后能够胜任实 际的在线业务量。如果用户量只有几个,每天的pv只有几十个,那么网站开发几乎不需要什么 优化就能胜任。如果PV上10万甚至百万、千万,就需要运用性能测试来验证是否能够满足实际 业务需求,如果不满足,就要运用各种优化手段提升服务能力。
假设某个页面每天的访问量为100万。根据实际业务情况,主要访问量大致集中在10个小时 以内,那么换算公式就是: QPS = Pv / 10h
100万的业务访问量换算为QPS,约等于27.7,即服务器需要每秒处理27.7个请求才能胜任业务量。