浏览器/nodeJS 中的事件环工作原理

575 阅读10分钟

众所周知,JS的最大特点之一便是单线程.这意味着JS中如果从上到下执行命令,如果前面的命令花时间太长,则会出现"假死"状态,影响用户体验. 因此在浏览器/nodeJS中,通过webAPI等方式, 将这些长时间的js命令通过异步"分流"到其他的线程(JS本身是单线程,但是浏览器和nodeJS是多线程), 等这些命令执行完成后通过回调函数"返回"JS中. 而这一套机制的实现 就是事件环(eventloop). 下面我们就来仔细研究一下它的工作原理.

浏览器中的事件环工作原理

首先 用一张图来展示浏览器中的事件环:

Alt text
浏览器中的事件环

从这张图中我们可以看到其中有宏任务(MacroTask)和微任务(MicroTask)之分,我们来说下这个宏任务与微任务。

宏任务包括:

  • setTimeout
  • setInterval 微任务包括:
  • Promise
  • MutaionObserver
  • Object.observe(已废弃:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe)

在单次的迭代中,event loop首先检查Macrotask队列,如果有一个Macrotask等待执行,那么执行该任务。当该任务执行完毕后(或者Macrotask队列为空),event loop继续执行Microtask队列。(V8 中 Microtask 默认是自动运行的)。

讲了这么多理论, 先来一点代码看看:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

这段代码的顺序如何呢? 将代码放入chrome执行, 我们可以得到顺序如下:

Alt text
注意 该图是chrome的结果 不同浏览器可能呈现不同结果

那么我们来分析一下为什么是按照这个顺序出现的

我们先看一看wiki中对宏任务和微任务的定义:

"Tasks(宏任务) are scheduled so the browser can get from its internals into JavaScript/DOM land and ensures these actions happen sequentially. Between tasks, the browser may render updates. Getting from a mouse click to an event callback requires scheduling a task, as does parsing HTML, and in the above example, setTimeout.

setTimeout waits for a given delay then schedules a new task for its callback. This is why setTimeout is logged after script end, as logging script end is part of the first task, and setTimeout is logged in a separate task. Right, we're almost through this, but I need you to stay strong for this next bit…

Microtasks(微任务) are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task. The microtask queue is processed after callbacks as long as no other JavaScript is mid-execution, and at the end of each task. Any additional microtasks queued during microtasks are added to the end of the queue and also processed. Microtasks include mutation observer callbacks, and as in the above example, promise callbacks.

Once a promise settles, or if it has already settled, it queues a microtask for its reactionary callbacks. This ensures promise callbacks are async even if the promise has already settled. So calling .then(yey, nay) against a settled promise immediately queues a microtask. This is why promise1 and promise2 are logged after script end, as the currently running script must finish before microtasks are handled. promise1 and promise2 are logged before setTimeout, as microtasks always happen before the next task."

根据以上的描述, 我们一步一步的分析之前的代码:

step1

程序执行到第一行 直接输出 script start

step2

接下来 setTimeout进入宏任务列表中 如下图所示:

step3

接下来 Promise进入微任务列表中 如下图所示:

step4

然后程序直行至最后一行,输出script End:

step5

然后 先执行微任务中的命令:

step6

then中的部分是直接执行 因此console中显示promise1

step7

由于promise的回调函数中返回'undefined'于是将下一个promise 进入到微任务中.

step8

下图中的 promise then 和promise callback对应的都是第二个then的. 而promise2也在console中显示.

step9

最终结果如step10所示:

step10

看完了这一题 是不是觉得事件环也没有想象中那么难呢? 那么在这里大家可以再看看下一题作为思考题. 限于文章篇幅所限,仅提供正确答案供大家参考 ^_^

首先, 我们来一个html页面:

<div class="outer">
  <div class="inner"></div>
</div>

得到如下的一个大方块套小方块的html页面:

如果该html页面的JS如下所示,那么我点击内部的小方块,会得到怎样的结果呢?

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

(使用chrome)正确答案:

click

promise

mutate

click

promise

mutate

timeout

timeout

node中的事件环工作原理

事件驱动

Node采用事件驱动的运行方式。在事件驱动的模型当中,每一个IO工作被添加到事件队列中,线程循环地处理队列上的工作任务,当执行过程中遇到来堵塞(读取文件、查询数据库)时,线程不会停下来等待结果,而是留下一个处理结果的回调函数,转而继续执行队列中的下一个任务。这个传递到队列中的回调函数在堵塞任务运行结束后才被线程调用。

Node Async IO 这一套实现开始于Node开始启动的进程,在这个进程中Node会创建一个循环,每次循环运行就是一个Tick周期,每个Tick周期中会从事件队列查看是否有事件需要处理,如果有就取出事件并执行相关的回调函数。事件队列事件全部执行完毕,node应用就会终止。Node对于堵塞IO的处理在幕后使用线程池来确保工作的执行。Node从池中取得一个线程来执行复杂任务,而不占用主循环线程。这样就防止堵塞IO占用空闲资源。当堵塞任务执行完毕通过添加到事件队列中的回调函数来处理接下来的工作。

当然这么华丽的运行机制就能解决前面说的两个弊端。node基于事件的工作调度能很自然地将主要的调度工作限制到了一个线程,应用能很高效地处理多任务。程序每一时刻也只需管理一个工作中的任务。当必须处理堵塞IO时,通过将这个部分的IO控制权交给池中的线程,能最小地影响到应用处理事件,快速地反应web请求。 当然对机器方便的事情对于写代码的人来说就需要更小心地划分业务逻辑,我们需要将工作划分为合理大小的任务来适配事件模型这一套机制。

事件队列调度

Node可以通过传递回调函数将任务添加到事件队列中,这种异步的调度可以通过5种方式来实现这个目标:异步堵塞IO库(db处理、fs处理),Node内置的事件和事件监听器(http、server的一些预定义事件),开发者自定义的事件和监听器、定时器以及Node全局对象process的.nextTick()API。

异步堵塞IO库

其IO库提供的API有Node自带的Module(比如fs)和数据库驱动API,比如mongoose的.save(doc, callback)就是将繁重的数据库Insert操作以及回调函数交给子线程来操作,主线程只负责任务的调度。当MongoDB返回给Node操作结果后,回调函数才开始执行。

Dtree.create(frontData, function (err, dtree) {
      if (err) {
            console.log('Error: createDTree: DB failed to create due to ', err);
            res.send({'success': false, 'err': err});
      } else {
            console.log('Info: createDTree: DB created successfully dtree = ', dtree);
            res.send({'success': true, 'created_id': dtree._id.toHexString()});
      }
});

比如这段处理Dtree存储的回调函数只有当事件队列中的接收到来自堵塞IO处理线程的执行完毕才会被执行。

Node内置的事件和事件监听器

Node原生的模块都预定义来一些事件,比如NET模块的一套服务状态事件。当Net中的Socket检测到close就会调用放置在事件循环中的回调函数,下例中就是将sockets数组中删除相应的socket连接对象。

socket.on('close', function(){
  console.log('connection closed');
  var index = sockets.indexOf(socket);
  //服务器端断开相应连接
  sockets.splice(index, 1);
});

开发者自定义的事件

Node自身和很多模块都支持开发者自定义事件和处理持戟处理函数,当然既然是自定义,那么触发事件也是显性地需要开发者。在Socket.io编程中就有很好的例子,开发者可以自定义消息事件来处理端对端的交互。

//socket监听自定义的事件消息
socket.on('chatMessage', function(message){
  message.type = 'message';
  message.created = Date.now();
  message.username = socket.request.user.username;
  console.log(message);
  //同时也可以像对方发出事件消息
  io.emit('chatMessage', message);
});

计时器(Timers)

Node使用前端一致的Timeout和Interval计时器,他们的区别在Timeout是延时执行,Interval是间隔一段事件执行。值得注意的是这组函数其实不属于JS语言标准,他们只是扩展。在浏览器中,他们属于BOM,即它的确切定义为:window.setTimeout和window.setInterval;与window.alert, window.open等函数处于同一层次。Node把这组函数放置于全局范围中。

除了这两个函数,Node还添加Immediate计时器,setImmediate()函数是没有事件参数的,在事件队列中的当前任务执行结束后执行,并且优先级比Timeout、Interbal高。

计时器的问题在于它在事件循环中并非精确的执行回调函数。《深入浅出Node.js》举了一个例子:当通过setTimeout()设定一个任务在10毫秒后执行,但是如果在9毫秒后,有一个任务占用了5毫秒的CPU,再次炖老定时器执行时,事件就已经过期了。

Node全局对象process的.nextTick()API

这个延时执行函数函数是在添加任务到队列的开头,下一次Tick周期开始时就执行,也就是在其他任务前调度。

nextTick的优先级是高于immediate的。并且每轮循环,nextTick中的回调函数全部都会执行完,而Immediate只会执行一个回调函数。这里有得说明每个Tick过程中,判断事件循环中是否有事件要处理的观察者。在Node的底层libuv,事件循环是一个典型的生产者/消费者模型。异步IO、网络请求是事件的生产者,回调函数是事件的消费者,而观察者则是在中间将传递过来的事件暂存起来。回调函数的idle观察者在每轮事件循环开始被检查,而check观察者后于idle观察者检查,两者之间被检查的就是IO操作的观察者。

事件驱动与高性能服务器

前面大致介绍了Node的事件驱动模型,事件驱动的实质就是主循环线程+事件触发的方式来运行程序。Node的异步IO成功地使得IO操作与CPU操作分离成为一套高性能平台,既可以像Nginx一样构建服务器平台,也可以处理具体的业务。虽然Node没有Nginx在Web服务器方面那么专业,但不错的性能和更多的使用场景使得在实际开发中能够达到优异的性能。这一切也都归功与异步IO实现的核心——事件循环。在实际的项目中,我们可以结合不同工具的优点达到应用的最优性能。