正在加载
请稍等

菜单

红楼飞雪 梦

15526773247

文章

Home nodejs nodejs 事件循环
Home nodejs nodejs 事件循环

nodejs 事件循环

nodejs by

要想利用好服务端的JavaScript 环境,关键是要理解Node.js 和JavaScript 设计决策背后的一些核心理念。理解设计决策及其利弊得失能帮助你更轻松地写出好代码并做好系统架构,还能帮助你向别人解释清楚,为什么Node.js 和他们平时用的系统不一样,其性能是如何得到提高的。工程师都不喜欢自己的系统中有不清楚的地方,他们不接受用“魔力”来笼统地解释一切,而需要清楚地知 道一个特定的架构为何能带来益处,以及在什么样的情景下能带来益处。

Node 的一个核心功能就是事件循环,这一概念也多用于JavaScript 底层行为及许多交互系统中。在许多语言中,事件模型是在外层的,但JavaScript 事件一直是其语言的核心模块,这是因为JavaScript 在很多情景下都需要处理与用户交互的事件。用过现代网页浏览器的人都习惯在网页上通过onclick、onmouseover 等事件来进行操作。
这 些事件是那么常见,我们在开发网页交互的时候甚至会忘记它们的存在,但在语言内部支持事件模型是何等强大的功能!在服务器端,没有了网页DOM 对应的那些有限的用户驱动型交互事件,而是在服务器程序上对应发生的各种不同的事件,比如HTTP 服务器模块在用户发送请求给Web 服务器时会触发request 事件。JavaScript 利用事件循环来合理地处理系统各部分的请求。在计算领域,人们可以用若干不同的方法来处理实时或并行运算,但大多数方法都太过复杂,甚至让人头疼。 JavaScript 采用了很简易的方法,使得处理过程更容易理解,但是它需要有一些限制条件。当你把握了事件循环的工作原理后,就能充分地扬长避短了。Node 采用的方式是,所有的I/O 事件都应该是非阻塞的(稍后会解释原因)。这意味着需要让程序暂停操作的HTTP 请求、数据库查询、文件读写,以及其他事情在
数据返回之前并不暂停执行。这些事件都将独立运行,然后在数据准备好以后触发一个事件。也就是说,用 Node.js 编程会用到很多回调函数,来处理各种I/O。回调函数往往以级联的方式嵌在其他回调函数中,这与浏览器编程有所不同。除了用顺序的方式设置好启动项外,大 部分代码都是在处理回调函数。针对这种少见的编程风格,我们需要寻找适合服务器编程的处理模式。先从事件循环开始吧。我们认为大部分人直觉上是理解事件驱 动编程的,因为这和日常生活很像。假设你在烧饭,正在切青椒的时候锅里的东西开始沸溢了,你会暂停切菜,把炉火关小。你不会在切青椒的同时把炉火关小,而是会采用更加安全的方式,通过快速切换工作对象来达到同样的目的。事件驱动编程也是同样的道理。通过让程序员一次只能为一个回调函数编写处理代码,可以让代码可读性更强,而且
能够快速地处理多个任务。

sss

在 日常生活中,我们习惯于用各种内部回调的方式来处理遇到的事件。和JavaScript类似,我们一次只能处理一件事情。好吧,我知道你可以同时揉肚子和 拍脑袋,并且能两样都干得不错,但当你想同时做一些重要的事情时,很快就会出差错的。这点也和JavaScript 很像,能让事件来驱动操作很棒,但它只能以“单线程”的方式运行,即同一时间只能处理一件事情。单线程的概念非常重要。常有人批评Node.js 缺少并发,也就是它没有利用机器上的所有CPU 来运行JavaScript。但是,同时在多个CPU 上运行程序也有它的问题,是需要协调多个执行线程的。要让多个CPU 有效地拆分任务,它们之间需要不停地交换信息,比如当前执行状态,以及各自完成了哪些工作。虽然这不是不可能,但
这么复杂的模型给程序员和系统带 来了很大的工作量。JavaScript 的方式很简单:同一时刻只有一件事情在操作。Node 做的每一件事情都是非阻塞的,所以事件触发与Node 对其操作的时间间隔是很短的,因为Node 不需要等待如磁盘I/O 这样的操作。再举个邮递员投递的例子,帮助你理解事件循环。邮递员的每封信都是一个事件,他有一堆事件等着要按顺序处理,每封信(事件)都要走到相应的路 径进行投递。路径就是对此事件的回调函数(通常不止一条路径)。可怜的是,我们的邮递员只有一双腿,每次只能走其中一条路径。

b

偶 尔,当邮递员在路上行走时,有人会给他另外一封信件,这就像是投递途中的回调函数。这种情况下,邮递员会马上去派送新的信件(因为路人不去邮局而是直接交 给他的信件,一定是十万火急的)。此时邮递员会立刻切换到新的路径去投递新邮件,完成后,再回到之前的路径上继续工作。让我们从简单情形入手,对比一下邮 递员的行为和一般程序的做法。假设我们的Web 服务器(HTTP)被请求要从数据库中读取一些数据,然后返回给用户。在这种情况下,我们只要处理很少的事件。首先,用户的请求多是要Web 服务器返回一个网页。处理这个初始请求的回调函数(我们称之为回调函数A)会先从请求的对象中确定它要从数据库读取什么内容,然后向数据库发起具体的请 求,并传入一个函数(回调函数B)供请求完成时使用。处理完请求后,回调函数A 结束并返回。当数据库找到需要的内容后,再触发相应事件。事件循环队列则调用回调函数B,让它把数据发送给用户。

这似乎非常直观。这里需要 特别注意的是代码“隔断”的地方,这也是过程式的程序不会遇到的情况。因为Node.js 是一个非阻塞的系统,所以当调用需要阻塞等待的数据库函数时,我们会采用回调函数替代闲置等待。这就是说,由另外一些函数来接管这个请求,并在数据准备好 返回时把它处理掉。所以我们需要确认回调函数所要用到的数据能够有办法取得。JavaScript 编程通常是利用闭包来实现这个功能的。稍后,我们会进一步介绍闭包。为什么Node 更加高效呢?想象一下在一家快餐店点餐。你在柜台排队时,服务员有两种方法来处理你的点单,一种是事件驱动的,另一种则不是。我们先采用PHP等许多 Web 平台所使用的方法。你点餐时,服务员先招待你,待你点完后才服务下一个客人。他输入完你的单子后,可以做以下几件事情:收款、为你倒饮料等。但是,服务员 还不知道要等多久厨房才能够把你要的汉堡做好(如果你们中有一人是素食主义者,可能还要等更长时间)。在传统的Web 服务框架下,每个服务程序(线程)每次只能服务一个请求。唯一增加处理能力的方法就是加入更多的线程。很显然这样的做法并不是那么地高效,服务员在等待厨 房做菜时浪费了很多时间。显然,现实生活中的餐馆使用的是更加高效的模式。你点完菜后,服务员会给你一个号码,在菜做好时通知你,你可以称这个为回调号 码。Node 也是这样工作的。当I/O 一类的费时操作开始时,Node 会给它们一个回调引用,然后继续处理其他已经就绪的工作。比如说服务员可以服务下一个客人(对Node 来说,则是下一个事件)。需要重点关注的是,与邮递员的例子一样,餐厅服务员也绝不会在同一时间服务两个客人。当呼叫某位客人来取食物的时候,他们不会处 理新客人的需求,反之也是一样。通过事件驱动的运作方式,服务员能够最大程度地提高产出。下面这个例子展示了在什么样的情况下使用Node 最合适,以及什么情况下它不合适。在一些小餐馆,厨师和服务员是同一个人,这种情况下采用事件驱动并不能提高效率,因为所有的工作都由同一个人完成,事件 驱动的架构并不能增加价值。如果服务器的全部(或大部分)工作是进行运算,Node 并非最理想的模型。同时,我们也能发现这个架构在什么时候合适。假设在餐馆中有两名服务员和四位客人。如果服务员一次只服务一位客人,那么头两位客人可以 最快地拿到食物,而第三和第四位客人的体验会很糟糕。前两位客人之所以能够快速地获得食品,是因为服务员在全力满足他们的要求,这占用了另外两位客人的时 间。在事件驱动模型下,头两位客人可能需要稍微等待一下才能拿到食物,因为服务员需要先处理一下后面两位客人的点单,但系统的平均等待时间(延迟)将大大 降低。v

现 在我们看看另外一个例子。我们给事件循环模式的邮递员一封信去投递,但投递这封信需要经过一扇门。他到达了目的地,而门却关闭着,所以他只能等待并不停地 尝试进入。他等待门打开就像进入了死循环模式(图3-4)。如果在信件队列里有另外一封信能够通知某人来打开门,让邮递员进去,这不就解决问题了吗?不幸 的是,邮递员正在无休止地等待打开门,无法抽身去投递那封信,这是因为打开门的事件是在当前回调事件的外部。如果在回调函数内发起事件,我们知道邮递员会 优先把这封信给投递掉,但是当事件是在当前执行代码的外部发生时,它必须等待正在执行的代码完成之后才会被调用。

c

就像下面的例子一样,Node.js(或浏览器)创建的事件永远不会跳出。

堵塞事件循环的代码

EE = require('events').EventEmitter;
ee = new EE();
die = false;
ee.on('die', function () {
  die = true;
});
setTimeout(function () {
  ee.emit('die');
}, 100);
while (!die) {
}
console.log('done');

在 上面的例子中,console.log 永远不会被调用,因为while 循环不会让Node 有机会触发timeout 回调函数并且发起die 事件。虽然我们不太会写这样一个依赖外部条件作为跳出判断的循环体,但它展示了Node.js 同时只处理一件事的本质,任何一点缺陷都可能导致整个系统混乱。这也是事件驱动编程的核心模块是非阻塞I/O 的原因。我们再做一下算术。当CPU 进行一次运算的时候(不是一行JavaScript 代码,而是单一的机器码运算)大概需要1/3 纳秒(ns)。一个3Ghz 的处理器每秒运行3×109个指令, 所以每个指令花费10-9/3 秒。主流的CPU 内部有两种内存,L1 和L2
cache,访问速度大概 为2~5 纳秒。如果我们从内存(RAM)读取数,需要花费大概80 纳秒,比运行指令要慢两个数量级。但这些操作都是在同一个场景下的。从更慢的I/O 途径中读取内容则太糟糕了。如果把从RAM 中读取数据比作一只猫的重量,那么从硬盘上读数据就比得上一头鲸了,而从网络上等数据就像是100 头鲸的重量。假设拿运行var foo = “bar” 与一个数据库查询对比,简直就是一只猫和100 头鲸比重量。阻塞式I/O 并非真的在事件循环的邮递员前面放了一扇真实的门,它只是把邮递员送到遥远的非洲大陆后再回来投递信件。有了事件循环的基本认识后,我们看看常用的 Node.js 代码是如何创建HTTP 服务器的

基本的HTTP 服务器

var http = require('http');
http.createServer(function (req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/plain'
  });
  res.end('Hello World\n');
}).listen(8124, '127.0.0.1');
console.log('Server running at http://127.0.0.1:8124/');

这 是Node.js 网站上展示的最简单例子(但稍后我们会说明这并非编码的理想方式)。在例子里,通过调用http 库的一个工厂方法来创建HTTP 服务器。工厂方法在创建新的HTTP 服务器的同时,为request 事件绑定了一个回调函数,后者作为createServer 的一个参数传递进去。当代码运行的时候会发生什么有趣的事情呢? Node.js 运行的第一件事情是把例子中的代码从头到尾运行一遍,这可以认为是Node 编程的“设置”阶段。因为我们绑定了一些事件监听器,所以Node.js 不会退出,而是等待这些事件被触发。如果我们没有绑定任何事件,Node.js 在运行完代码后就会立刻退出。那么当服务器接收到一个HTTP 请求时会进行什么处理呢? Node.js 会发起request 事件,因为该事件有对应的回调函数绑定在上面,回调函数会被依次调用。在本例中,只有一个回调函数,那就是在调用createServer 时作为参数传入的匿名函数。我们假设在服务器启动以后来了第一个请求,因为这个时候没有任何其他代码在运行,所以这个request 事件被马上处理并调用回调函数。这是个极为简单的回调过程,所以运行飞快。假设我们的网站变得非常受欢迎,同时有很多的请求进来了。为了方便讨论,假设回 调函数需要执行1 秒钟。在第一个请求后紧跟着又来了第二个请求,那么第二个请求将不会在这1 秒内被处理。显然1 秒钟其实是很长的时间了。让我们看看真实应用情景,事件循环堵塞的问题会严重地破坏用户体验。HTTP 服务器实际上是由操作系统内核处理与客户端的TCP 连接的,所以尽管不会恶化到拒绝新连接的境地,但仍然会有这些链接不被处理的危险。为了处理这些问题,我们希望尽量保持Node.js 的事件驱动和非阻塞的特性。同样的方式,让费时的I/O 事件用回调的方法来通知Node.js,只有数据已经准备好了,才可以进行下一步操作。Node.js 程序本身需要把每一个回调函数都写得运行迅速,防止把事件循环给堵塞住。这意味着你在编写Node.js 服务器程序的时候需要遵循以下两个策略。在

• 设置完成以后,所有的操作都是事件驱动的。
• 如果 Node.js 需要长时间处理数据,就需要考虑把它分配给 web worker 去处理。
事 件驱动方法配合事件循环工作起来非常高效(正如它的名字所暗示的),但编写容易阅读和理解的事件驱动代码也同样重要。在前面的例子里,我们用匿名函数作为 事件回调,这会导致几点不便。首先,我们无法控制代码在哪里使用。匿名函数只有在被使用的地方才存活,而不是在绑定事件回调时存活,这会影响调试。如果所 有东西都是匿名事件,当异常发生时,就很难分辨出是哪个回调函数导致了问题。

 

21 2015-06

 

我要 分享

 

 

本文 作者

 

相关 文章