Node核心功能是事件循环,此概念也多用于JS底层行为以及许多交互系统中。在许多语言中,事件模型是在外层的,但JS事件一直是其语言的核心模块,这是因为JS在很多场景下都需要处理与用户交互的事件。在服务端没有网页DOM对应的那些有限的用户驱动型交互事件,而是在服务器程序上对应发生的各种不同的事件,例如HTTP服务器模块在用户发送请求给web服务器时会触发request事件。
JS利用事件循环来合理地处理系统各部分的请求。Node采用的方式是,所有I/O事件都应该是非阻塞的。这意味着需要让程序暂停操作的HTTP请求、数据库查询、文件读写,以及其他事情,在数据返回之前并不暂停执行。这些事件都将独立运行,然后在数据准备好后触发一个事件。也就是说Node编程会用到很多回调函数,来处理各种I/O。回调函数往往以级联的方式嵌入在其他回调函数中,这与浏览器编程有所不同。除了用顺序的方式设置好启动项外,大部分代码都在处理回调函数。
针对这种少见的编程风格,我们需要寻找合适服务器编程的处理模式。先从事件模型开始,大部分人直觉上是理解事件驱动编程的,因为这和日常生活息息相关。
事件驱动的人们
假设你在烧饭,正在切青椒的时候锅里面的东西开始沸溢出来了,你会暂停切菜把炉火关小。你不会在切青椒的同时把炉火关小,而是会采用更加安全的方式,通过快速切换工作对象来达到同样的目的。事件驱动编程也是同样的道理。通过让程序员一次只能为一个回调函数编写处理代码,可以让代码可读性更强,而且能够快速地处理多个任务。
日常生活中,人们习惯于用各种内部回调的方式来处理遇到的事件。和JS类似,我们一次只能处理一件事情。这也和JS很像,能让事件来驱动操作很棒,但它只能以单线程的方式运行,即同一时间只能处理一件事情。
单线程的概念非常重要,常有人批评Node缺乏并发,也就是没有利用机器上所有CPU来运行JS。但是同时在多个CPU上运行程序也有它的问题,即需要协调多个执行线程。要让多个CPU有效地拆分任务,它们之间需要不停地交换信息,比如当前执行状态以及各自完成了那些工作。虽然这不是不可能,但这么复杂的模型给程序员和系统带来了很大的工作量。JS的方式很简单,同一时刻只有一件事情在操作。Node做的每一件事情都是非阻塞的,所以事件触发与Node对其操作的时间间隔是很短的,因为Node不需要等待诸如磁盘I/O这样的操作。
事件驱动的快递员
以快递员投递为例帮助你理解事件循环。快递员的每个快件都是一个事件,他有一堆快件等着要按顺序处理,每个快件都要走相应的路径进行投递。路径就是对此事件的回调函数。可怜的是,快递员只有一双腿,每次只能走其中一条路径。
偶尔,当快递员在路上行走时,有人会给它派发另一快件,这就像是投递途中的回调函数。这种情况下,快递员会马上去派送新的快件。此时快递员会立即切换到新的路径去投递。完成后在回到之前的路径上继续工作。
对比快递员的行为和一般程序的做法。假设web服务器被请求要从数据库读取数据,然后返回给用户。这种情况下,我们只要处理很少的事件。首先,用户的请求是要web服务器返回一个网页。处理此个初始请求的回调函数A会先从请求的对象中确定它要从数据库读取什么内容,然后向数据库发起具体的请求,并传入一个回调函数B供请求完成时使用。处理完请求后,回调函数A结束并返回。当数据库找到需要的内容,再触发相应事件。事件循环队列则调用回调函数B,让它把数据发送给用户。
这似乎非常直观,这里需要特别注意的是代码隔断的地方,这也是过程式编程不会遇到的情况。因为Node是一个非阻塞的系统,所以当调用需要阻塞等待的数据库函数时,我们会采用回调函数替代闲置等待。这就是说,由另外一些函数来接管这个请求,并在数据准备好返回时把它处理掉。所以我们需要确定回调函数所要用到的数据能够有办法取得。JS编程通常是利用闭包来实现这个功能的。
事件驱动的快餐店
为什么Node更加高效呢?想象下在快餐店点餐,你站在柜台前排队时,服务员有两种方式来处理你的点单,一种是事件驱动的,另一种则不是。先采用PHP等许多web平台所使用的方式。你点餐时服务员先招待你,待你点完后才服务下一个客人。他输入完你的单子后可以做以下几件事情:收款、为你倒饮料等。但是,服务员还不知道要等多久厨房才能把你的快餐做好。在传统web服务框架下,每个服务程序(线程 )每次只能服务一个请求。唯一增加处理能力的方法是加入更多的线程。很显然这样的做法并不是那么的高效,服务员在等待厨房做菜时浪费了很多时间。
显然现实生活的餐馆使用的是更加高效的模式。你点完菜后,服务员会给你一个号码,在菜做好时通知你,你可以称之为回调号码。Node也是这样工作的。当I/O一类费时操作开始时,Node会给它们一个回调引用,然后继续处理其他已经就绪的工作。比如服务员可服务下一个客人,对Node来说则是下一个事件。需要重点关注的是。当呼叫某位客人来取食物的时候,他们不会处理新客人的需求,反之亦然。通过事件驱动的运作方式,服务员能够最大程度地提高产出。
在一些小餐馆,厨师和服务员是同一个人,这种情况下采用事件驱动并不能提高效率,因为所有的工作都由同一个人完成,事件驱动的架构并不能增加价值。如果服务器的全部或大部分工作是进行运算,Node并非最理想的模型。
假设餐馆中有两名服务员和四位客人。如果服务员一次只能服务一位客人,那么头两个客人可最快地拿到食物,而剩余两位的体验会很糟糕。前两位客人之所以能够快速地获得食物是因为服务员在全力满足他们的需求,这占用了后两位客人的时间。在事件驱动模型下,头两位客人可能需稍微等待一下才能拿到食物,因为服务员需先处理一下后两位客人的点单,但系统的平均等待时间(延迟)将大大降低。
被阻塞的邮递员
我们给事件循环模式的邮递员一封信去投递,但投递这封信需要经过一扇门。邮递员达到目的地,而门却关闭着,所以他只能等待并不停地尝试进入。他等待门打开就像进入了死循环模式。如果在信封队列里有另外一封信能够通知某人来打开门,让邮递员进去,这不就解决问题了吗?不幸的是,邮递员正在无休止地等待打开门,无法抽身去投递那封信,这是因为打开门的事件在当前回调事件的外部。如果在回调函数内部发起事件,我们知道邮递员会优先把这封信给投递掉哦,但是当事件是在当前执行代码的外部发生时,它必须等待正在执行的代码完成之后才会被调用。
虽然我们不太会编写依赖外部条件作为跳出判断的循环体,但这展示了Node同时只能处理一件事情的本质,任何一点缺陷都可能导致整个系统混乱。这也是事件驱动编程的核心模块是非阻塞I/O的原因。
var evt = require('events');
var EE = evt.EventEmitter;
var ee = new EE();
var die = false;
ee.on('die', function(){
die = true;
});
setTimeout(function(){
ee.emit('die');
}, 100);
while(!die){}
// console.log()永远不会被调用,因为while循环不会让Node有机会触发timeout回调函数并发起die事件。
console.log('done');
创建HTTP服务器
var http = require('http');
http.createServer(function(req, res){
res.writeHead(200, {Content-Type:'text/plain'});
res.end('Hi\n');
}).listen(8124, '127.0.0.1');
console.log('server running');
通过调用http库的工厂方法来创建HTTP服务器,工厂方法在创建新的HTTP服务器的同时,为request事件绑定了一个回调函数,后者作为createServer()的第一个参数传递进去。当代码运行的时候会发生什么有趣的事情呢?
Node.js运行的第一件事情是把代码从头到尾运行一遍,这可以认为是Node编程的设置阶段。因为我们绑定了一些事件监听器,所以Node.js不会退出,而是等待这些事件被触发。若没有绑定任何事件,Node.js在运行完代码后就会立即退出。
当服务器接收到一个HTTP请求时会进行怎样的处理呢?Node.js会发起request事件,因为该事件有对应的回调函数绑定在上面,回调函数会被依次调用。
假设网站变得非常受欢迎,同时又很多请求进来。假设回调函数需执行1秒。在第一个请求后紧跟着又来了第二个请求,那么第二个请求将不会在这1秒内被处理。显然1秒其实是很长的时间了。
让我们来看看真实应用情景,事件循环阻塞的问题会严重的破坏用户体验。HTTP服务器实际上是由操作系统内核处理与客户端的TCP连接的。所以尽管不会恶化到拒绝新连接的境地,但仍然会有这些链接不被处理的危险。为了处理这些问题,希望尽量保持Node.js的事件驱动和非阻塞的特性。同样的方式,让费时的I/O事件回调的方法来通知Node.js,只有数据已经准备好了,才可以进行下一步操作。Node.js程序本身需要把每个回调函数都写得运行迅速,防止把事件循环给阻塞住。
这意味着编写Node.js服务器程序的时候需遵循以下两个策略:
- 在设置完成以后所有的操作都是事件驱动的
- 如果Node.js需长时间处理数据就要考虑把它分配给web worker去处理