## Node.js 异步编程之难
文/黄鼎恒
>在很多人看来,Node.js 开发服务端程序比较难。作者认为,这不能完全归咎于 Node.js ,更本质原因是服务端程序开发本身的难度。本文将从多方面对这些难点进行综合解析,并分享诸多应用开发经验。
前一阵子 Node.js 之父 Ryan Dahl 在访谈中表示 Node.js 不是构建庞大服务器网络的最佳系统,引起许多的争议。许多人也觉得 Node.js 的开发难度比较大,那么使用 Node.js 开发服务端的难度从何而来,本文从控制流、异常处理、状态机、性能、队列等方面进行一次综合的探讨。
### 控制流
对于从事主流编程语言开发的开发者而言,异步的控制流程在直觉上有一定的区别,容易导致出现误解的情况。我们先来看一个简单的代码。
```
function test (callback) {
callback()
}
test(function () {
console.log('b')
})
console.log('a')
```
这是一道很简单的面试题,题目是“先输出 a 还是先输出 b”。答案是“先输出 b 再输出 a”,虽然很简单但是却能很轻松的帮你区分出来一个人是不是了解 JavaScript 和异步的基础。
粗窥异步编程的开发者,提到异步的印象基本都是 callback,这种印象让相当一部分开发者看到 callback 就以为是异步,从而回答“先输出 a 后输出
b”(以为 b 的输出过程被异步了)。不要轻视这种顺序的错误,在实际的项目中由于顺序的问题导致错误绝对不会少。
实际上,业内有一项共识,即在不能明确接口是否异步的时候,均按异步的方式来封装处理。这样统一之后在编码流程上问题简化了不少。这种简化的流程控制在 Node.js 中有很多的方案,比如原生 callback(async.js,eventproxy 等)、 Promise、 Generator、 async/await 等等。
最后的 async/await 号称编写异步流程的终极方案,极大地简化了异步控制流的情况。不过实际开发中 async/await 并不能满足所有的异步需求,由于偏向同步的写法 async/await 的灵活性上比起 callback 差了很多,当碰到复杂的异步需求时还是会需要 callback、promise。所以实际项目开发的过程中 Node.js 的异步控制流是多种方案并行使用。
虽然写法上可以避免混淆,但是对于一个有经验的 Node.js 开发者而言,一个函数实际上是不是异步依旧是值得商榷的。
以 Node.js 的 core 模块提供的 dns.lookup(hostname[,options], callback)接口为例,这个接口看起来是传 callback 的异步方式,但实际上内部实现是个同步调用 getaddrinfo(3) 的过程。
这种问题乍看起来不起眼,但实际上在细致业务中很容易埋下祸根,在你以为它是异步 non-blocking 接口的时候,它可能实际上是个同步的过程,从而 block 住你的整个进程。反之以为是同步但实际上异步的操作,也可能由于顺序的问题导致数据变脏,甚至是一些更糟糕的问题。对于一段 Node.js 代码异步与否,在直达异步的本质之前直觉和反直觉将会是 Node.js 开发者碰到的第一个难点。
当然,就好比写 Lisp、 Haskell 之类的开发者会对大量的括号习以为常一样,这个难点并不是一个痛点,只是一个学习成本的问题,并非不能接受。
### 异常处理
在 Node.js 中错误处理主要有以下几种方法。
1. callback(err, data)回调约定;
2. throw/try/catch;
3. EventEmitter 的 error 事件。
在最开始使用异步编程,人们碰到异常的时候可能有如下情况的代码。
```
try {
setTimeout(() => {
throw new Error('Oops')
})
} catch(err) {
console.log('Caught error', err) // not work
}
```
这个简单的例子告诉我们,在同步的 Node.js 代码中没法直接 catch 异步流程中的错误。该共识达成之后,try/catch 最常用的方式仅仅只剩下检查 JSON.parse 的错误。而人们都通过 callback 来传递错误,并且遵循 error first 的约定,如下。
```
function fn(cb) {
cb(new Error('Oops'))
}
```
不通过 throw 抛出异常而是通过 callback 的第一个参数(error first)来传递错误。这个约定一定程度上制止了引起混乱的局面,并且有大量封装的解决方案应运而生。然而这种形式的错误处理起来繁琐,并不具备强制性,并且当错误本身引起异常时就无法通过 callback 传递,而是直接截断整个异步的流程。
Promise.catch 的出现以及 co、async/await 加 try/catch 的方式很好地解决了这个问题。但是这几种方式的错误处理与 EventEmitter 的 error 处理并不能很好的结合。我们需要多种控制流并存的方式来处理异常。
通过 EventEmitter 的错误监听形式为各大关键的对象加上错误监听的回调。例如监听 HTTP Server,TCP Server 等对象的 error 事件以及 process 对象提供的 uncaughtException 和 unhandledRejection 等等。这种形式的错误处理容易破坏原有 try/catch 代码结构,让你不得不从 async/wait 中重回
callback、Promise 的怀抱。
如果说由于多种异步流程控制并存,导致在某些交汇点会存在混乱的话,那么错误信息的丢失倒是另一个令 Node.js 开发者头疼的问题。
```
function test() {
throw new Error('test error');
}
function main() {
test();
}
main();
可以收获的报错,如下。
/data/node-interview/error.js:2
throw new Error('test error');
^
Error: test error
at test (/data/node-interview/error. js:2:9)
at main (/data/node-interview/error.js:6:3)
at Object. (/data/node-interview/error.js:9:1)
at Module._compile (module.js:570:32)
at Object.Module._extensions..js (module.js:579:10)
at Module.load (module.js:487:32)
at tryModuleLoad (module.js:446:12)
at Function.Module._load (module.js:438:3)
at Module.runMain (module.js:604:10)
at run (bootstrap_node.js:394:7)
```
可以发现报错的行数,test 函数,main 函数的调用关系都在 stack 中清晰的体现。当你使用 setImmediate 等定时器来设置异步的时候,代码如下。
```
function test() {
throw new Error('test error');
}
function main() {
setImmediate(() => test());
}
main();
```
我们会发现如下错误。
```
/data/node-interview/error.js:2
throw new Error('test error');
^
Error: test error
at test (/data/node-interview/error.js:2:9)
at Immediate.setImmediate (/data/node-interview/error.js:6:22)
at runCallback (timers.js:637:20)
at tryOnImmediate (timers.js:610:5)
at processImmediate [as _immediateCallback] (timers.js:582:5)
```
错误栈中仅输出 test 函数调用的位置,再往上 main 调用信息就丢失了。也就是说如果你的函数调用深度比较深的话,内部某个异步调用出错的追溯将是件很困难的事情,因为其之上的栈都已经丢失了。比如你使用某个 DB 的 Driver,如 MySQL 的驱动,由于其内部通过异步的方式维护了一个请求的队列,当这个队列报错的时候,由于栈的信息被截断,导致你只能看到一个驱动内部的报错而无法直观的找到是自己的什么操作触发了这个异常,从而增加调试的难度。
### 状态机
初步了解了异步的复杂,以及异常处理的麻烦之后,再来看复杂的状态机就会发现,使用异步的方式编写复杂的状态机容易导致一些很复杂的问题。
一个异步流程,如果中间某次出现异常,导致下一个 callback 被多调用了一次,那么整个流程就可能出现分叉,从一个流程分叉为多个流程。这也是 Promise 呼声特别高的原因,因为 Promise 本身存在一个执行状态,只能调用 then 一次。然而 EventEmitter 形式的异步触发并不讲道理,他们本身就可能触发多次异步回调,这跟 Promis 先天存在矛盾。所以 EventEmitter 方式的异步通常都是处理好之后再封装给 Promise 调用。
Promise 通过执行状态控制执行次数及链式调用的方式,确实让开发者更加专注流程的处理,避免了异步带来的干扰。然而这样的链式逻辑同样存在着分叉困难的问题。async/await 可以解开 promise 互相之间的链条,自由地控制异步逻辑的分支,循环极大的改善了这个问题。唯一美中不足的是,使用 async/await 削弱了异步编程并行处理的能力。
举个 TCP 处理的例子,当一个中间件,同时与上下游建立 TCP 长连接,需要对上游的数据处理之后再传递给下游。那么当上游的数据下来时,最重要的事情是确认下游的连接状态是否正常。如果下游的连接状态不正常,就需要着手清理之前的连接,并尝试重新建立连接。由于 TCP 连接是 EventEmitter 方式触发的异步,所以并不能简单的使用 Promise、async/await 的方式来封装。同时流程中操作的每一步,都可能因为上下游双方触发的异常而调整异步流程,回退到某个状态重头处理,这样最好是维护连接池,每次均取可用连接来走流程。
由于 EventEmitter 触发的异步依旧是嵌套的 callback 异步很容易破坏 Promise 等封装方式的线性流程,导致并存时异步逻辑的处理变得复杂,如果此时流程上再有一些选择、循环的异步操作会让流程编写比较烧脑。而传统多线程方式,由于可以加锁并且更多关注的是数据同步,流程上反而会简单一些。可以说异步与多线程是各有特点,并没有单纯的说谁会比较简单。
另一方面,为了应对较为复杂的异步流程,开发者们往往会引入公共的对象。这看似方便,但也使内存的 Ownership 变得难以捉摸。如果内存泄漏了,定位起来就比较困难。所以如果有 Node.js 偏底层的开发,一般建议使用成熟的模块。
### 内存
讲到内存,我们首先来看一下常见的编码阶段的内存泄漏,如下。
1. 全局变量。当变量挂靠在 root 以及 module.exports 等全局变量上时,对象的内存始终不会释放。
2. 闭包。闭包因为写法的问题,在复杂状态机的情况下,容易使得闭包的引用一直被持有而导致内存泄漏。
3. 异常处理。上文中提到过,异常出现之后,如果没有进行正确的恢复操作可能导致内存泄漏。其根本原因在于一些异常产生之后 Node.js 的行为是未定义的。官方有明确表示,process 的 uncaughtException 事件是为了让你准备之后再 exit 进程。异常产生之后没有正确恢复状态可能导致内存泄漏。
4. 事件监听。例如对同一个事件重复监听,忘记移除(removeListener),可能造成内存泄漏。
5. 事件监听导致内存泄漏的情况很容易在复用对象上添加事件时出现,所以事件重复监听可能收到如下警告。
```
(node:2752) Warning: Possible EventEmitter memory leak detected。 11 myTest listeners added。 Use emitter。
setMaxListeners() to increase limit
```
例如, Node.js (v6.9)中 Http.Agent 可能造成的内存泄漏。当 Agent keepAlive 为 true 或者短时间有大量情况的时候,都会复用之前使用过的 Socket,如果此时在 Socket上 添加事件监听,忘记清除的话,由于 socket 的复用,将导致事件重复监听从而产生内存泄漏。
### 性能
服务端应用一般使用 non-blocking I/O 提高 I/O 并发度。当 I/O 并发度很低时,non-blocking IO 不一定比 blocking I/O 更高效,因为后者完全由内核负责,而 read/write 这类系统调用已高度优化,效率显然高于一般的多个线程协作的 non-blocking I/O 。
但当 I/O 并发度愈发提高时,blocking I/O 阻塞一个线程的弊端便显露出来:内核不停地在线程间切换才能完成有效的工作,一个 CPU core 上可能只做了一点点事情,就马上又换成了另一个线程, CPU cache没得到充分利用。另外,大量的线程会使整体性能随之下降。
而 non-blocking I/O 一般由少量 event dispatching 线程和一些运行用户逻辑的 worker 线程组成,这些线程往往会被复用,event dispatching和 worker 可以同时在不同的核运行(流水线化),内核不用频繁的切换就能完成有效的工作。线程总量也不用很多,所以对 thread-local 的使用也比较充分。这时候 non-blocking I/O 往往就比 blocking I/O 快了。
为了维护 Node.js 的 non-blocking I/O 足够简单,所以 Node.js 只暴露了单线程以供开发者使用。这是一个优点也是一个缺点。优点是开发者不用理解更复杂的“多线程+异步编程”模型,也可以达到客观的 I/O 密集型性能,缺点是如果单线程被阻塞,整个应用也会随之被阻塞掉。另外,即使单个线程没有被阻塞,只要中间有一个任务的速度变慢,影响的是整条链路上的请求速度。而由于异步的并发量高,这种影响并不会直观的影响到 QPS 的数目以及平均响应时间,而是会影响部分请求的响应时间,产生长尾请求,从而影响访问质量。而多线程则由于线程之间的互相独立,某一个线程变慢并不会对整个流程引起连锁反应。
对于 Node.js 而言,这方面的优化比较难做。因为 Node.js 本身的计算能力并不强,开发 CPU 密集型业务也属于自废武功。有的开发者提出使用 C++ addon 来处理耗 CPU 的请求,使用 Node.js 来处理 I/O 。这是比较理想的情况,实际开发过程中一个对象从 C++ addon 到 V8 的传递具有着十分高昂的代价。以生成图片验证码的功能为例,传统的 C++ addon 如果用纯 JavaScript 改写,性能会获得翻倍提升,并且可以使用 Node.js 跨平台。
所以当你的 Node.js 应用性能瓶颈在 JSON.parse 上时,不用想着使用 C++ addon 的方式来优化,由于 addon 与 V8 的交互成本高昂。实际上通过 C++ 来实现的 JSON,处理速度都不如在 V8 内部的情况。同时这个 JSON.parse 的性能问题在碰到与 Java 之类的语言数据交互时会变得更差。主要原因是 JavaScript 语言层面上不支持 int64,如果与其交互需要在 JavaScript 使用支持 int64 的 JSON.parse 模块,那么性能会差10倍左右(非 V8 自带无优化)。
在性能问题上,Node.js 将处理限制在单核上,有时候我们为了获得更好的性能,会使用多进程的方式来进行开发,这样性能就不受单核的限制了。不过在多进程的使用上,需要注意的是 Node.js 内置的 IPC 通信存在传输大的单条数据的性能问题(内置的 cluster 逻辑交互数据较小,不会触发这个问题),另外我们在线上部署维护的过程中,碰到过机器的 Swap 内存爆满情况。排查发现多进程模式中存在 Master 死亡后,没有通知 Worker 终止进程,使得 Worker 成为孤儿进程被系统 init 领养。在长时间无请求的情况下,将 Worker 的内存折叠进入 Swap 内存,当 Swap 内存爆满时也可能导致系统宕机。
大家在通过 Node.js 创建子进程的时候,正常情况都只会想到 .on 去 listen 子进程的 exit,而很少会考虑到在子进程中去 .on 父进程的异常 crash。简单的说,手动 wait 回收子进程虽然麻烦,但设计的时候就会考虑处理 master 挂了没回收的情况。而 Node.js 的子进程通过 IPC 实现隐藏了这个细节,出现了这种问题反而没有那么方便处理。
在处理上,大家可以考虑在子进程中做健康检查。在子进程与父进程之间维持一个心跳,心跳断了(master 异常crash)就让子进程做一些资源回收,然后优雅的 process.exit。或者考虑使用 ZooKeeper 之类的工具来存每一个节点的情况,也可以系统地注意到所有节点。
### 队列
当流量超出服务的最大 QPS 时,服务将无法正常服务,当流量恢复正常时(小于服务的处理能力),积压的请求会被处理。虽然其中很大一部分可能会因为处理的不及时而超时,但服务本身一般还是会恢复正常的。这就相当于一个水池有一个入水口和一个出水口,如果入水量大于出水量,水池子终将盛满,多出的水会溢出来。但如果入水量降到出水量之下,一段时间后水池总会排空。
在实际开发中,这种满负荷的情况最容易命中程序的弱点,使服务宕机。究其原因,主要是因为一个流程的成功与否就如同木桶理论一样,由最短的那块木板决定。我们可以看看一个 TCP 链接过程中存在的木板,见图1。
图1 TCP 链接过程中存在“木板”队列
我们会发现, TCP 的三次握手中,第一个 SYN 包上来时,服务端其实是有一个 SYNs 队列的,而 establish 阶段等到 accept 的过程中也有一个 accept 队列。由此可见,我们的一个服务端请求,其实充满了各式各样的木板。拓展开来,你会发现作为一个服务端你需要去检查很多“木板”队列,例如 socket 最大连接数、agent.maxSockets 默认上限、数据库的 max connections 等等。对应到代码中来,异步本身处理的队列,很多人并没有加一个统计来观察。在实际的请求中,每个异步操作都会加入到 Node.js 的事件队列中。如果你没有控制这个队列的长度(异步的数量),那么可能出现事件队列过长,导致早已完成的流程在队列中长时间等待,从而影响性能,严重时可能会导致内存泄漏。
比如,曾经碰到过一个莫名其妙的内存泄漏 Bug,排查了很长时间后发现是 Http 请求内部源代码中调用了 DNS 请求代码,这中间的 DNS 请求没有缓存,所以当请求量大到一定程度后 Node.js(v6.9)内部的 DNS 对象大量堆积造成内存泄漏。
这些队列是进行服务端开发不可不了解的基础。如果某个队列过短可能导致很多请求被拒绝影响服务质量,队列过长则可能导致内存泄漏或者等待时间过长(假定连接数并发量是65525,以 QPS 5000为例,处理完约耗时13s,而这段时间的连接可能早已被 nginx 或者客户端断开,那么我们再去处理也失去了意义)。
值得一提的是,目前 Node.js v8.x 版本正在这个方向上努力,新增的 Async Hook 功能就是专门针对异步的一种监控手段。你可以通过它的 Hook 来统计当前流程中的异步生命周期以及同时处理的异步数目。
关于这些问题需要针对相应的业务场景进行压力测试,收集足够的数据。除非你评估过业务量,确认流量不会超过目前服务的处理能力,否则一定要检查并设定好这些队列的上下限。
### 小结
了解了以上问题后,我们再回过头来看发现很多问题都可能导致内存问题。
1. 引用问题:不论是闭包、复杂的状态机编写,还是事件监听没注意释放。需要知道什么情况下,引用是被持有的,什么情况下又不是。所有引用问题归结到最后,是对 V8 内存释放原理了解不透彻导致的。
2. 队列问题:一个流程调用的过程中可能经历了多个过程,包括通信层、业务层、数据层等,其中每一个层级对于事务的处理都存在队列问题。当流程中某个环节的负载超过其能处理的上限也可能导致内存问题甚至宕机。
3. CPU 问题:引用的释放(GC 消耗 CPU )、业务逻辑的编写问题(死循环)都可能导致 CPU 资源紧张,而 CPU 资源紧张同样会导致内存泄漏(没有足够的 CPU 执行 GC 操作,释放速度赶不上生产速度)。
我们可以发现整个应用的每个部分都不是孤立的,而是时时刻刻在互相影响。Node.js 服务端开发的困难本质上也是服务端的困难,这是一个综合的工程问题,而并不完全是 Node.js 的问题。