浏览器基础(2)Node 中的事件循环

什么是事件轮询

事件循环是 Node.js 处理非阻塞 I/O 操作的机制——尽管 JavaScript 是单线程处理的——当有可能的时候,它们会把操作转移到系统内核中去。

既然目前大多数内核都是多线程的,它们可在后台处理多种操作。当其中的一个操作完成的时候,内核通知 Node.js 将适合的回调函数添加到轮询队列中等待时机执行。我们在本文后面会进行详细介绍。

事件轮询机制解析

当 Node.js 启动后,它会初始化事件轮询;处理已提供的输入脚本,它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick(),然后开始处理事件循环。

下面的图表展示了事件循环操作顺序的简化概览。
在这里插入图片描述
注意:每个框被称为事件循环机制的一个阶段。(一共6个阶段)
每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。

由于这些操作中的任何一个都可能调度 更多的 操作和由内核排列在轮询阶段被处理的新事件, 且在处理轮询中的事件时,轮询事件可以排队。因此,长时间运行的回调可以允许轮询阶段运行长于计时器的阈值时间。

阶段概述
  • 定时器(timers):本阶段执行setTimeout() 和 setInterval() 的回调。
  • 待定回调( pending callbacks又称I/O callbacks ):大多数的回调方法在这个阶段执行,除了timers、close和setImmediate事件的回调函数。
  • idle, prepare:仅系统内部使用。
  • 轮询(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • 检测(check):处理setImmediate()事件的回调。
  • 关闭的回调函数(close):处理一些close相关的事件,例如socket.on(‘close’,…)等。

假设事件循环现在进入了某个阶段,即使在这期间有其它队列中的事件就绪,也会先将当前阶段队列里的全部回调方法执行完毕后,再进入到下个阶段。

阶段的详细概述
定时器

这个阶段主要用来处理定时器相关的回调,当一个定时器超时后,一个事件就会加入到队列中,事件循环跳转至这个阶段执行对应的回调函数。定时器的回调会在触发后尽可能早地被调用,这表示实际的延时可能会比定时器规定的时间要长。如果事件循环,此时正在执行一个比较耗时的callback,例如处理一个比较耗时的循环,那么定时器的回调只能等到当前回调执行结束了才能被执行,即被阻塞。事实上,timers阶段的执行受到poll阶段的控制,后面会讲到。

待定回调

Nodejs官网文档对这个阶段的解释为:除了timers、setImmediate,以及close操作之外的大多数的回调方法都位于这个阶段执行。但是,一些常见的回调,例如fs.readFile的回调是放在poll阶段来执行的。根据libuv的文档,一些应该在上轮事件循环poll阶段执行的callback,因为某些原因不能执行,就会被延迟到这一轮的事件循环的I/O callbacks阶段执行。换句话说这个阶段执行的callbacks是上轮残留的。

轮询

轮询阶段的主要任务是等待新的事件的出现(该阶段使用epoll来获取新的事件),如果没有,事件循环可能会在此阻塞。这些事件对应的回调方法可能位于timers阶段(如果定义了定时器),也可能是check阶段(如果设置了setImmediate方法)。
轮询 阶段有两个重要的功能:

  1. 如果有到期的定时器,那么就执行定时器的回调方法。。
  2. 然后,处理 轮询 队列里的事件。

当事件循环进入 轮询 阶段且 没有被调度的计时器时 ,将发生以下两种情况之一:

  • 如果 轮询 队列 不是空的 ,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
  • 如果 轮询 队列 是空的 ,还有两件事发生:
  1. 如果脚本被 setImmediate() 调度,则事件循环将结束 轮询 阶段,并继续 检查 阶段以执行那些被调度的脚本。
  2. 如果脚本 未被 setImmediate()调度,那么事件循环将会进入等待状态,并等待新的事件出现,这也是该阶段为什么会被命名为poll(轮询)的原因。此外,还会不断检查是否有相关的定时器超时,如果有,就会跳转到timers阶段,然后执行对应的回调。
检查阶段

此阶段允许人员在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲状态,并且脚本使用 setImmediate() 后被排列在队列中,则事件循环可能继续到 检查 阶段而不是等待。

setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调在 轮询 阶段完成后执行。

通常,在执行代码时,事件循环最终会命中轮询阶段,在那等待传入连接、请求等。但是,如果回调已使用 setImmediate()调度过,并且轮询阶段变为空闲状态,则它将结束此阶段,并继续到检查阶段而不是继续等待轮询事件。

关闭的回调函数

如果一个socket或者一个句柄被关闭,那么就会产生一个close事件,该事件会被加入到对应的队列中。close阶段执行完毕后,本轮事件循环结束,循环进入到下一轮。

看完了上面的描述,我们明白了Node中的事件循环是分阶段处理的,对于每一个阶段来说,处理事件队列中的事件就是执行对应的回调方法,每一个阶段的事件循环都对应着不同的队列。在Node中,事件队列不止一个,定时器相关的事件和磁盘IO产生的事件需要不同的处理方式,如果把所有的事件都放到一个队列里,势必要增加许多类似switch/case的代码。那样的话,倒不如将不同类型的事件归类到不同的事件队列里,然后一个个的遍历下来,如果当中出现了新的事件,就进行相应的处理。event loop的每一次循环都需要依次经过上述的阶段。

每个阶段都有自己的callback队列,每当进入某个阶段,都会从所属的队列中取出callback来执行,当队列为空或者被执行callback的数量达到系统的最大数量时,进入下一阶段。这六个阶段都执行完毕称为一轮循环。

setImmediate() 对比 setTimeout()

setImmediate() 和 setTimeout() 很类似,但是基于被调用的时机,他们也有不同表现。

  • setImmediate() 设计为一旦在当前 轮询 阶段完成, 就执行脚本。
  • setTimeout() 在最小阈值(ms 单位)过后运行脚本。

执行计时器的顺序将根据调用它们的上下文而异。如果二者都从主模块内调用,则计时器将受进程性能的约束(这可能会受到计算机上其他正在运行应用程序的影响)。

例如,如果运行以下不在 I/O 周期(即主模块)内的脚本,则执行两个计时器的顺序是非确定性的,因为它受进程性能的约束:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是,如果你把这两个函数放入一个 I/O 循环内调用,setImmediate 总是被优先调用:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

使用 setImmediate() 相对于setTimeout() 的主要优势是,如果setImmediate()是在 I/O 周期内被调度的,那它将会在其中任何的定时器之前执行,跟这里存在多少个定时器无关。

process.nextTick()

process.nextTick的意思就是定义出一个异步动作,并且让这个动作在事件循环当前阶段结束后执行。

process.nextTick其实并不是事件循环的一部分,但它的回调方法也是由事件循环调用的,该方法定义的回调方法会被加入到一个名为nextTickQueue的队列中。在事件循环的任何阶段,如果nextTickQueue不为空,都会在当前阶段操作结束后优先执行nextTickQueue中的回调函数,当nextTickQueue中的回调方法被执行完毕后,事件循环才会继续向下执行。

Node限制了nextTickQueue的大小,如果递归调用了process.nextTick,那么当nextTickQueue达到最大限制后会抛出一个错误,我们验证一下:

function recurse(i){
    while(i<9999){
        process.nextTick(recurse(i++))
    }
}

recurse(0);

在这里插入图片描述
既然nextTickQueue也是一个队列,那么先被加入队列的回调会优先执行,我们验证一下:

process.nextTick(function(){
    console.log('one')
})
process.nextTick(function(){
    console.log('tow')
})
console.log('three');

//运行结果如下:
//three 
//one 
//two

在这里插入图片描述
和其它回调函数一样,nextTick定义的回调也是由事件循环执行的,如果nextTick的回调方法中出现了阻塞操作,后面的要执行的回调函数同样会被阻塞,我们验证一下:

process.nextTick(function(){
    console.log('one');
    //由于死循环的存在,之后的事件被阻塞
    while(true){}
});
process.nextTick(function(){
    console.log('tow') //不会被打印
});
console.log('three');
//运行结果如下:
//three 
//one

在这里插入图片描述

process.nextTick() 对比 setImmediate()

就用户而言,我们有两个类似的调用,但它们的名称令人费解。

  • process.nextTick() 在同一个阶段立即执行。
  • setImmediate() 在事件循环的接下来的迭代或 ‘tick’ 上触发。

实质上,这两个名称应该交换,因为 process.nextTick() 比 setImmediate() 触发得更快,但这是过去遗留问题,因此不太可能改变。如果贸然进行名称交换,将破坏 npm 上的大部分软件包。每天都有更多新的模块在增加,这意味着我们要多等待每一天,则更多潜在破坏会发生。尽管这些名称使人感到困惑,但它们本身名字不会改变。

seImmediate方法不属于ECMAScript标准,而是Node提出的新方法,它同样将一个回调函数加入到事件队列中,不同于setTimeout和setInterval,setImmediate并不接受一个时间作为参数,setImmediate的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环的末尾(check)执行。
setImmediate方法和process.nextTick方法很相似,二者经常被拿来放在一起比较,由于process.nextTick会在当前操作完成后立刻执行,因此总会在setImmediate之前执行,我们验证一下:

setImmediate(function(param){
    console.log("执行"+param);
},"setImmediate");

process.nextTick(function(){
    console.log("执行next Tick");
});
//运行结果如下:
//执行next Tick
//执行setImmediate

在这里插入图片描述
此外,当有递归的异步操作时只能使用setImmediate,不能使用process.nextTick,前面讲process.nextTick时已经验证过这个问题了,就是递归调用process.nextTck会出现call stack溢出的情况。关于递归调用setImmediate,我们验证一下:

function recurse(i,end){
    if(i>end){
        console.log('done!')
    } else {
        console.log(i);
        setImmediate(recurse,i+1,end)
    }
}
recurse(0,999999999999);

运行上面的代码完全没有问题,因为setImmediate不会生成call stack。

建议开发人员在所有情况下都使用 setImmediate(),因为它更容易理解。

为什么要使用 process.nextTick()

有两个主要原因:

  1. 允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前重试请求。
  2. 有时有让回调在栈展开后,但在事件循环继续之前运行的必要。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值