深入浅出 Node. Js
这绝对是一本实践性极强的技术书,不管是否学习过 Node,只要你爱好 技术,都推荐你阅读它。
Node 特点
异步 I/O
Node 保存了 JavaScript 在浏览器中单线程的特点。而且在 Node 中,JavaScript 与其余线程是无法共享线程的。单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题。
单线程的弱点有以下几点
- 无法利用多核 cpu
- 错误会引起整个应用退出,应用的健壮性值得考验
- 大量计算占用 cpu 导致无法继续调用异步 I/O
跨平台
node 基于 libuv 实现跨平台
模块机制
在经历十多年的发展后,社区为 js 制定了 CommonJS 方案
Node 的模块实现
在 Node 中引入模块,需要经历如下 3 个步骤
- 路径分析
- 文件定位
- 编译执行
在 node 中,模块分为两类
- 核心模块。核心模块在 Node 源代码的编译过程中,编译进了二进制执行文件。在 node 进程启动时,部分核心模块就直接加载进内存中,所以这部分模块在引入时,第二步和第三步会直接省略,在路径分析中优先判断,所以加载速度最快
- 文件模块。在运行时加载,需要走完整的三步,加载速度较慢
node 会对引入的模块进行二次缓存,以减少引入时的开销。缓存的内容是编译和执行后的对象。而且 require 方法对相同模块的加载都一律采用缓存优先的方式
路径分析 require 接收一个标识符作为参数,模块标识符分为以下几类
- 核心模块。比如 http、fs、path 等。核心模块的优先级仅次于缓存加载,在 Node 源代码编译过程中就已经成为二进制代码,加载速度最快。同名标识符不会加载成功
- .或..开始的相对路径文件模块或是绝对路径模块。会被当做文件模块处理,require 会把它转为真实路径,并以真实路径为索引,将编译后的结果放在缓存中。由于指明了路径,所以加载速度仅次于核心模块
- 非路径形式的文件模块,也就是自定义模块。它最特殊,可能是一个文件或是包的形式,查找速度最慢,它会按以下路径逐级查找
- 当前文件夹下的 node_modules 文件夹
- 父目录下 node_modules 文件夹
- 沿路径向上递归,直到根目录下的 node_modules 文件夹
文件定位 缓存优化可以让二次引入时不需要三步的过程,但还是有一些细节需要注意
- 文件后缀。如果文件标识符没有后缀,会按 .js、.json、.node 的顺序依次尝试
- 目录分析和包。在分析路径之后,如果定位到一个文件夹,则 node 会把目录当成一个包来处理。首先会查找目录下 package.json 的 main 属性指定的文件名进行定位。如果 main 指定错误,或是没有 package.json,会把 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 = [];
}对于不同的文件扩展名,其加载的方式也不同
- .js。通过 fs 模块同步读取后编译执行
- .node。这是使用 C/C++ 编写的扩展文件,通过 dlopen() 方法加载最后编译的文件
- .json。通过 fs 模块同步读取后,用 JSON.parse() 解析后返回结果
- 其他扩展名当做 .js 文件载入
每一个编译成功的模块都会将其文件路径作为索引缓存在 Module.__cache 对象上
Module._extensions 会被赋值到 require()的 extensions 属性,所以通过在代码中访问 require.extensions 可以获取系统中已有的扩展加载方式,比如以下代码
console.log(require.extensions);如果想对自定义的扩展名进行特殊的加载,可以通过类似 require.extensions['.ext']的方法实现。但官方不鼓励,最后先编译成 .js 文件再进行加载
- js 模块的编译
在编译的过程中,Node 对 JavaScript 文件内容进行了包装。在头部添加了
(function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n});。包装结果如下
(function(exports, require, module, __filename, __dirname) {
var math = require("math");
exports.area = function(radius) {
return Math.PI * radius * radius;
};
});这就是这些变量并没有定义却能使用的原因。在执行之后,模块的 exports 属性返回给了调用方。
-
c++ 模块的编译 Node 调用 process.dlopen()方法进行加载和执行。在 Node 的架构下,dlopen()方法在 Windows 和 Linux 下分别有不同的视线,通过 libuv 兼容层进行了封装。
实际上,.node 模块是编写 C/C++ 模块之后编译生成的,所以只有加载和执行的过程。
-
JSON 模块的编译 Node 使用 fs 模块读取 JSON 文件的内容之后,调用 JSON.parse() 得到对象,之后赋值给 exports 对象
包与 npm
Node 对模块规范的实现,一定程度上解决了变量依赖、依赖关系等组织性问题。包的出现,是在模块的基础上进一步组织代码
包结构
包实际上是一个存档文件,即一个目录打包为 .zip 或 .gzip 的文件,安装后解压还原为目录。完全符合 CommonJS 规范的包应该包含以下目录
- package.json: 包描述文件
- bin: 用于存放可执行二进制文件的目录
- lib: 用于存放 js 代码的目录
- doc: 用于存放文档的目录
- test: 用于存放单元测试用例的代码
包描述文件与 NPM
- name: 包名。由小写的字母和数字组成,可以包含., _和-。包名是唯一的
- description: 包简介
- version: 版本号
- keywords: 关键词数组
- matainers: 包维护者列表
- constributors: 贡献者列表,第一个是包的作者本人
- bugs: 一个可以反馈 bug 的网页地址或邮件地址
- licenses: 当前包所使用的许可证列表
- repositories: 托管源代码到位置列表,表明可以通过哪些方式和地址访问包的源代码
- dependencies: 使用当前包所需要依赖的包列表。NPM 会通过这个属性帮助自动加载依赖的包
- homepage: 当前包的网站地址
- os: 操作系统支持列表
- cpu: cpu 架构支持列表
- engine: 支持的 js引擎列表
- builtin: 标志当前包是否是内建在底层系统的标准组件
- directories: 包目录说明
- implements: 实现规范的列表
- scripts: 脚本说明对象
在包描述文件规范中,NPM实际需要的字段主要有name、version、description、keywords、 repositories、author、bin、main、scripts、engines、dependencies、devDependencies。 与包规范的区别在于多了author、bin、maindevDependencies这4个字段。
- author。包作者。
- bin。一些包作者希望包可以作为命令行工具使用。配置好bin字段后,通过npm install
package_name -g 可以将脚本添加到执行路径中,之后可以在命令行中直接执行。
- main。模块引入方法require()在引入包时,会优先检查这个字段,并将其作为包中其余模块的入口。如果不存在这个字段,require()会查找包目录下的index.js、index.node、index.json文件作为默认入口。
- devDependencies。开发依赖包
NPM 常用功能
- -g 是将一个包安装为全局可用的可执行命令,根据包描述文件中的 bin 字段配置
"bin": {
"express": "./bin/express"
}-
Node可执行文件的位置是/usr/local/bin/node, 模块目录就是/usr/local/lib/node_ modules。最后, 通过软链接的方式将bin字段配置的可执行文件链接到Node可执行目录下
-
发布包
- npm init
- npm adduser
- npm publish
-
npm ls 可以展示包的文件目录结构,生成依赖树
异步 I/O
PHP语言从头到脚都是以同步阻塞的方式来执行的。
与 Node 的事件驱动、异步I/O设计理念比较相近的一个知名产品为 Nginx
为什么要异步 I/O
采用异步请求,在下载资源期间,JS和UI的执行都不会出于等待状态,可以继续响应用户的交互请求
资源分配
假如业务场景中有一组互不相关的任务需要完成,现行的主流方法有以下两种
- 单线程串行一次执行。单线程比较符合编程人员按顺序思考的思维方式,但它的缺点在于性能,I/O 的进行会让后续任务等待,者会造成资源不能更好地利用
- 多线程并行完成。多线程的代价在于创建线程和执行期线程上下文切换的开销较大。在复杂的业务中,多线程会面临锁、状态同步等问题。但是多线程可以更有效地利用CPU
Node 在两者之间给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,更好地利用CPU
异步I/O实现现状
操作系统内核对于I/O只有两种方式:阻塞和非阻塞。在调用阻塞I/O时,应用程序需要等待I/O完成才返回结果。非阻塞I/O和阻塞I/O的差别为调用之后会立即返回
非阻塞I/O也存在一些问题,由于完整的I/O并没有完成,立即返回的斌不是业务层期望的数据。而仅仅是当前调用的状态,此时应用程序需要通过轮询调用来确认I/O操作是否完成
现在的轮询技术有以下几种
- read。最原始的一种,通过重复调用来检查I/O的状态来完成完整数据的读取
- select。在read的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断
- poll。相较select有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查。
- epoll。是Linux下效率最高的I/O事件通知机制,在进入轮询时没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒
- kqueue。仅在FreeBSD系统下存在
现实的异步I/O通过让部分线程进行阻塞I/O或非阻塞I/O加轮询来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递
Node 的异步I/O
完成整个异步I/O环节的有事件循环、观察者和请求对象等
- 事件循环。当进程启动时,Node会启动一个类似于While(true)的循环,每执行一次循环体的过程我们称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。典型的生产者/消费者模型
- 观察者。每个事件循环中有一个或多个观察者,而判断是否有事件处理的过程就是向这些观察者询问是否有要处理的事件。
- 请求对象。从Js发起调用到内核执行完成I/O操作的过渡过程中,存在一种中间产物,它叫做请求对象。请求对象是异步I/O过程中的重要产物,所有的状态都保存在这个对象中
组装好请求对象、送入I/O线程池中等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分
事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素
非I/O的异步API
在 Node 中存在一些与 I/O无关的异步API,它们分别是setTimeout(), setInterval(), setImmediate()和process.nextTick()
setTimeout()和setInterval()与浏览器中的API是一致的,分别用于单次和多次执行任务。实现原理与异步I/O比较类似,但是不需要I/O线程池的处理。调用setTimeout()或setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中。
定时器的问题在于,它并非精确的
process.nextTick()的操作较为轻量,它只会将回调函数放入队列中,在下一轮Tick时取出执行。而定时器采用红黑树的操作时间复杂度为O(lg(n)),nextTick()的时间复杂度为O(1)。它属于idle观察者
setImmediate() 的优先级低于 process.nextTick()。它属于check观察者
具体实现上,process.nextTick()的回调函数保存在一个数组中,而setImmediate()的结果则是保存在链表中。process.nextTick()在每轮循环中会将数组中的回调函数全部执行,而setImmediate()在每轮循环中执行链表中的一个回调函数
事件驱动与高性能服务器
事件驱动的实质,就是通过主循环加事件触发的方式允许程序
以下为几种经典的服务模型
- 同步式。一次只能处理一个请求,并且其余请求都处于等待状态
- 每进程/每请求。为每个请求启动一个进程,这样可以处理多个请求,但是不具备扩展性
- 每线程/每请求。为每个请求启动一个线程,当大并发请求到来时,内存将很快用光,导致服务器缓慢。Apache 就是这种方式
Node 通过事件驱动的方式处理请求,不需要为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销
异步编程
难点
-
异常处理。我们通常使用 try/catch/final 进行异常捕获。而异常并不会发生在try/catch内。所以Node在处理异常上形成了一种约定,将异常作为回调函数的第一个实参范湖,如果为空值,表明异步调用没有异常抛出
-
函数嵌套过深。
-
阻塞代码。没有sleep()这样的线程沉睡功能
-
多线程编程。在浏览器中JS执行线程与UI渲染共用一个线程,同时提出了 Web Workers,通过 JS 执行与 UI渲染分离来利用多核CPU。而在 Node 则是使用 child_process 作为基础 API,而 cluster 则是更深层次的应用
-
异步转同步。
异步编程的解决方案
- 事件发布/订阅模式
- Promise
- async
内存控制
内存控制正是在海量请求和长时间允许的前提下探讨的
在 Node 中通过 JS 使用内存时就会发现只能使用部分内存。在这样的限制下,将会导致Node无法直接操作大内存对象。这是因为 Node 基于 v8 构建,而 v8 的内存管理机制在浏览器下绰绰有余,在Node中却成为了限制
在v8中,所有js对象都是通过堆来进行内存分配
process.memoryUsage() 能得到内存信息
v8 的垃圾回收机制
v8 的垃圾回收策略主要基于分代式垃圾回收机制。
- v8的内存分代。在 v8 中,主要讲内存分为新生代和老生代两代,新生代为存活时间较短的对象,老生代中的对象为存回时间较长或常驻内存的对象
- Scavenge 算法。新生代的对象主要通过 Scavenge 算法进行垃圾回收。它会将对内存一分为二,一个处于使用中称为 From,一个处于空闲状态称为 to。
当分配对象时,会先在 From 空间中分配,垃圾回收时,会检查From空间中的存活对象,这些存货对象将会被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间会和To空间角色发生互换
Scavenge 算法是典型的空间换时间算法。
一定条件下,会将新生代的对象移动到老生代中,也就是完成对象晋升。晋升的条件有两个。1. 对象是否经历过Scavenge回收。2. To空间的内存占比超过限制,如果复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代空间中
- Mark-Sweep & Mark-Compact。标记清除会遍历堆中所有活着的对象,并标记活着的对象,而之后清除阶段时,只清理没有被标记的对象。标记整理会在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
- Incremental Marking。垃圾回收的三种基本算法都需要将应用逻辑暂停下来,在执行完垃圾回收后再恢复执行应用逻辑。而为了降低这种停顿时间,V8 会将原本一口气停顿完成的动作改为增量标记,也就是拆分为许多小的步进。
高效使用内存
- 作用域。在JS中能够形成作用域的有函数调用、with以及全局作用域。如果在某个函数的作用域中查找不到变量,就继续向上查找,一直到全局作用域。全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存。
- 闭包。一旦有变量引用这个中间函数,这个中间函数将不会释放,同时原始的作用域不会得到释放,除非不再有引用,才会逐步得到释放
内存指标
- process.memoryUsage() 查看 Node 进程的内存占用情况
- os.totalmem() 和 os.freemem() 查看操作系统的内存使用情况,分别返回系统的总内存和闲置内存,以字节为单位
内存泄漏
内存泄漏的实质就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代内存中的对象。造成内存泄漏的原因有以下几个
-
缓存
-
队列消费不及时
-
作用域未释放