参考《Node.js开发指南 ByVoid》
一、异步式 I/O 与事件驱动
1.Page4:
Node.js 最大的特点就是采用异步式 I/O 与事件驱动的架构设计。对于高并发的解决方案,传统的架构是多线程模型,也就是为每个业务逻辑提供一个系统线程,通过系统线程切换来弥补同步式 I/O 调用时的时间开销。Node.js 使用的是单线程模型,对于所有 I/O 都采用异步式的请求方式,避免了频繁的上下文切换。Node.js 在执行的过程中会维护一个事件队列,程序在执行时进入事件循环等待下一个事件到来,每个异步式 I/O 请求完成后会被推送到事件队列,等待程序进程进行处理。例如,对于简单而常见的数据库查询操作,按照传统方式实现的代码如下:
res = db.query('SELECT * from some_table');
res.output();
以上代码在执行到第一行的时候,线程会阻塞,等待数据库返回查询结果,然后再继续处理。然而,由于数据库查询可能涉及磁盘读写和网络通信,其延时可能相当大(长达几个到几百毫秒,相比CPU的时钟差了好几个数量级),线程会在这里阻塞等待结果返回。对于高并发的访问,一方面线程长期阻塞等待,另一方面为了应付新请求而不断增加线程,因此会浪费大量系统资源,同时线程的增多也会占用大量的 CPU 时间来处理内存上下文切换,
而且还容易遭受低速连接攻击。看看Node.js是如何解决这个问题的:
db.query('SELECT * from some_table', function(res) {
res.output();
});
这段代码中 db.query 的第二个参数是一个函数,我们称为 回调函数 。进程在执行到db.query 的时候,不会等待结果返回,而是直接继续执行后面的语句,直到进入事件循环。当数据库查询结果返回时,会将事件发送到事件队列,等到线程进入事件循环以后,才会调用之前的回调函数继续执行后面的逻辑。
2.Page 31
单线程事件驱动的异步式 I/O 比传统的多线程阻塞式 I/O 究竟好在哪里呢?简而言之,异步式 I/O 就是少了多线程的开销。对操作系统来说,创建一个线程的代价是十分昂贵的,需要给它分配内存、列入调度,同时在线程切换的时候还要执行内存换页,CPU 的缓存被清空,切换回来的时候还要重新从内存中读取信息,破坏了数据的局部性。
当然,异步式编程的缺点在于不符合人们一般的程序设计思维,容易让控制流变得晦涩难懂,给编码和调试都带来不小的困难。习惯传统编程模式的开发者在刚刚接触到大规模的异步式应用时往往会无所适从,但慢慢习惯以后会好很多。尽管如此,异步式编程还是较为困难,不过可喜的是现在已经有了不少专门解决异步式编程问题的库(如 async ),参见6.2.2节。
三、事件 Page 33
Node.js 所有的异步 I/O 操作在完成时都会发送一个事件到事件队列。在开发者看来,事件由 EventEmitter 对象提供。前面提到的 fs.readFile 和 http.createServer 的回调函数都是通过 EventEmitter 来实现的。下面我们用一个简单的例子说明 EventEmitter的用法:
//event.js
var EventEmitter = require('events').EventEmitter;
var event = new EventEmitter();
event.on('some_event', function() {
console.log('some_event occured.');
});
setTimeout(function() {
event.emit('some_event');
}, 1000);
运行这段代码,1秒后控制台输出了 some_event occured. 。其原理是 event 对象注册了事件 some_event 的一个监听器,然后我们通过 setTimeout 在1000毫秒以后向event 对象发送事件 some_event ,此时会调用 some_event 的监听器。我们将在 4.3.1节中详细讨论 EventEmitter 对象的用法。
Node.js 在什么时候会进入事件循环呢?答案是 Node.js 程序由事件循环开始,到事件循环结束,所有的逻辑都是事件的回调函数,所以 Node.js 始终在事件循环中,程序入口就是事件循环第一个事件的回调函数。事件的回调函数在执行的过程中,可能会发出 I/O 请求或直接发射(emit)事件,执行完毕后再返回事件循环,事件循环会检查事件队列中有没有未处理的事件,直到程序结束。
四、循环的陷阱 Page135
Node.js 的异步机制由事件和回调函数实现,一开始接触可能会感觉违反常规,但习惯以后就会发现还是很简单的。然而这之中其实暗藏了不少陷阱,一个很容易遇到的问题就是循环中的回调函数,初学者经常容易陷入这个圈套。让我们从一个例子开始说明这个问题。
//forloop.js
var fs = require('fs');
var files = ['a.txt', 'b.txt', 'c.txt'];
for (var i = 0; i < files.length; i++) {
fs.readFile(files[i], 'utf-8', function(err, contents) {
console.log(files[i] + ': ' + contents);
});
}
这段代码的功能很直观,就是依次读取文件 a.txt、b.txt、c.txt,并输出文件名和内容。假设这三个文件的内容分别是 AAA、BBB 和 CCC,那么我们期望的输出结果就是:
a.txt: AAA
b.txt: BBB
c.txt: CCC
可是我们运行这段代码的结果是怎样的呢?竟然是这样的结果:
undefined: AAA
undefined: BBB
undefined: CCC
这个结果说明文件内容正确输出了,而文件名却不对,也就意味着, contents 的结果是正确的,但 files[i] 的值是 undefined 。这怎么可能呢,文件名不正确却能读取文件内容?既然难以直观地理解,我们就把 files[i]分解并打印出来看看,在读取文件的回调函数中分别输出 files 、 i 和 files[i] 。
//forloopi.js
var fs = require('fs');
var files = ['a.txt', 'b.txt', 'c.txt'];
for (var i = 0; i < files.length; i++) {
fs.readFile(files[i], 'utf-8', function(err, contents) {
console.log(files);
console.log(i);
console.log(files[i]);
});
}
运行修改后的代码,结果如下:
[ 'a.txt', 'b.txt', 'c.txt' ]
3
undefined
[ 'a.txt', 'b.txt', 'c.txt' ]
3
undefined
[ 'a.txt', 'b.txt', 'c.txt' ]
3
undefined
看到这里是不是有点启发了呢?三次输出的 i 的值都是 3,超出了 files 数组的下标范围,因此 files[i] 的值就是 undefined 了。这种情况通常会在 for 循环结束时发生,例如 for (var i = 0; i < files.length; i++) ,退出循环时 i 的值就是files.length 的值。既然 i 的值是 3,那么说明了事实上 fs.readFile 的回调函数中访问到的 i 值都是循环退出以后的,因此不能分辨。而 files[i] 作为 fs.readFile 的第一个参数在循环中就传递了,所以文件可以被定位到,而且可以显示出文件的内容。现在问题就明朗了:原因是3次读取文件的回调函数事实上是同一个实例,其中引用到的 i值是上面循环执行结束后的值,因此不能分辨。如何解决这个问题呢?我们可以利用JavaScript 函数式编程的特性,手动建立一个闭包:
//forloopclosure.js
var fs = require('fs');
var files = ['a.txt', 'b.txt', 'c.txt'];
for (var i = 0; i < files.length; i++) {
(function(i) {
fs.readFile(files[i], 'utf-8', function(err, contents) {
console.log(files[i] + ': ' + contents);
});
})(i);
}
上面代码在 for 循环体中建立了一个匿名函数,将循环迭代变量 i 作为函数的参数传递并调用。由于运行时闭包的存在,该匿名函数中定义的变量(包括参数表)在它内部的函数( fs.readFile 的回调函数)执行完毕之前都不会释放,因此我们在其中访问到的 i 就分别是不同的闭包实例,这个实例是在循环体执行的过程中创建的,保留了不同的值。事实上以上这种写法并不常见,因为它降低了程序的可读性,故不推荐使用。大多数情况下我们可以用数组的 forEach 方法解决这个问题:
//callbackforeach.js
var fs = require('fs');
var files = ['a.txt', 'b.txt', 'c.txt'];
files.forEach(function(filename) {
fs.readFile(filename, 'utf-8', function(err, contents) {
console.log(filename + ': ' + contents);
});
});
6.2.2 解决控制流难题
除了循环的陷阱,Node.js 异步式编程还有一个显著的问题,即深层的回调函数嵌套。在这种情况下,我们很难像看基本控制流结构一样一眼看清回调函数之间的关系,因此当程序规模扩大时必须采取手段降低耦合度,以实现更加优美、可读的代码。这个问题本身没有立竿见影的解决方法,只能通过改变设计模式,时刻注意降低逻辑之间的耦合关系来解决。除此之外,还有许多项目试图解决这一难题。async 是一个控制流解耦模块,它提供了async.series 、 async.parallel 、 async.waterfall 等函数,在实现复杂的逻辑时使用这些函数代替回调函数嵌套可以让程序变得更清晰可读且易于维护,但你必须遵循它的编程风格。
streamlinejs和jscex则采用了更高级的手段,它的思想是“变同步为异步”,实现了一个JavaScript 到JavaScript 的编译器,使用户可以用同步编程的模式写代码,编译后执行时却是异步的。eventproxy 的思路与前面两者区别更大,它实现了对事件发射器的深度封装,采用一种完全基于事件松散耦合的方式来实现控制流的梳理。无论是以上哪种解决手段,都不是“非侵入性的”,也就是说它对你编程模式的影响是非常大的,你几乎不可能无代价地在使用了一种模式很久以后从容地换成另一种模式,或者直接糅合使用两种模式。而且它们都是在解决了深层嵌套的回调函数可读性问题的同时,引入了其他复杂的语法,带来了另一种可读性的降低。所以,是否使用,使用哪种方案,在决定之前是需要仔细斟酌研究的。