Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

由setTimeout和setImmediate执行顺序的随机性窥探Node的事件循环机制 #6

Open
xunan007 opened this issue Feb 23, 2018 · 0 comments

Comments

@xunan007
Copy link
Owner

xunan007 commented Feb 23, 2018

问题引入

接触过事件循环的同学大都会纠结一个点,就是在Node中setTimeoutsetImmediate执行顺序的随机性。

比如说下面这段代码:

setTimeout(() => {
    console.log('setTimeout');
}, 0);
setImmediate(() => {
    console.log('setImmediate');
})

执行的结果是这样子的:

为什么会出现这种情况呢?别急,我们先往下看。

浏览器中事件循环模型

我们都知道,JavaScript是单线程的语言,对I/O的控制是通过异步来实现的,具体是通过“事件循环”机制来实现。

对于JavaScript中的单线程,指的是JavaScript执行在单线程中,而内部I/O任务其实是另有线程池来完成的。

在浏览器中,我们讨论事件循环,是以“从宏任务队列中取一个任务执行,再取出微任务队列中的所有任务”来分析执行代码的。但是在Node环境中并不适用。具体的浏览器事件循环解析:传送门

在Node中,事件循环的模型和浏览器相比大致相同,而最大的不同点在于Node中事件循环分不同的阶段。具体我们下面会讨论到。本文的核心也在这里。

Node中事件循环阶段解析

下面是事件循环不同阶段的示意图:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

每个阶段都有一个先进先出的回调队列要执行。而每个阶段都有自己的特殊之处。简单来说,就是当事件循环进入某个阶段后,会执行该阶段特定的任意操作,然后才会执行这个阶段里的回调。当队列被执行完,或者执行的回调数量达到上限后,事件循环才会进入下一个阶段。

以下是各个阶段详情。

timers

一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定的时间过后,timers会尽早的执行回调,但是系统调度或者其他回调的执行可能会延迟它们。

从技术上来说,poll阶段控制timers什么时候执行,而执行的具体位置在timers

下限的时间有一个范围:[1, 2147483647],如果设定的时间不在这个范围,将被设置为1。

I/O callbacks

这个阶段执行一些系统操作的回调,比如说TCP连接发生错误。

idle, prepare

系统内部的一些调用。

poll

这是最复杂的一个阶段。

poll阶段有两个主要的功能:一是执行下限时间已经达到的timers的回调,一是处理poll队列里的事件

注:Node很多API都是基于事件订阅完成的,这些API的回调应该都在poll阶段完成。

以下是Node官网的介绍:

笔者把官网陈述的情况以不同的条件分解,更加的清楚。(如果有误,师请改正。)

当事件循环进入poll阶段:

  • poll队列不为空的时候,事件循环肯定是先遍历队列并同步执行回调,直到队列清空或执行回调数达到系统上限。
  • poll队列为空的时候,这里有两种情况。
    • 如果代码已经被setImmediate()设定了回调,那么事件循环直接结束poll阶段进入check阶段来执行check队列里的回调。
    • 如果代码没有被设定setImmediate()设定回调:
      • 如果有被设定的timers,那么此时事件循环会检查timers,如果有一个或多个timers下限时间已经到达,那么事件循环将绕回timers阶段,并执行timers的有效回调队列。
      • 如果没有被设定timers,这个时候事件循环是阻塞在poll阶段等待回调被加入poll队列。

check

这个阶段允许在poll阶段结束后立即执行回调。如果poll阶段空闲,并且有被setImmediate()设定的回调,那么事件循环直接跳到check执行而不是阻塞在poll阶段等待回调被加入。

setImmediate()实际上是一个特殊的timer,跑在事件循环中的一个独立的阶段。它使用libuvAPI来设定在poll阶段结束后立即执行回调。

注:setImmediate()具有最高优先级,只要poll队列为空,代码被setImmediate(),无论是否有timers达到下限时间,setImmediate()的代码都先执行。

close callbacks

如果一个sockethandle被突然关掉(比如socket.destroy()),close事件将在这个阶段被触发,否则将通过process.nextTick()触发。

关于setTimeout和setImmediate

代码重现,我们会发现setTimeoutsetImmediate在Node环境下执行是靠“随缘法则”的。

比如说下面这段代码:

setTimeout(() => {
    console.log('setTimeout');
}, 0);
setImmediate(() => {
    console.log('setImmediate');
})

执行的结果是这样子的:

为什么会这样子呢?

这里我们要根据前面的那个事件循环不同阶段的图解来说明一下:

首先进入的是timers阶段,如果我们的机器性能一般,那么进入timers阶段,一毫秒已经过去了(setTimeout(fn, 0)等价于setTimeout(fn, 1)),那么setTimeout的回调会首先执行。

如果没有到一毫秒,那么在timers阶段的时候,下限时间没到,setTimeout回调不执行,事件循环来到了poll阶段,这个时候队列为空,此时有代码被setImmediate(),于是先执行了setImmediate()的回调函数,之后在下一个事件循环再执行setTimemout的回调函数。

而我们在执行代码的时候,进入timers的时间延迟其实是随机的,并不是确定的,所以会出现两个函数执行顺序随机的情况。

那我们再来看一段代码:

var fs = require('fs')

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

这里我们就会发现,setImmediate永远先于setTimeout执行。

原因如下:

fs.readFile的回调是在poll阶段执行的,当其回调执行完毕之后,poll队列为空,而setTimeout入了timers的队列,此时有代码被setImmediate(),于是事件循环先进入check阶段执行回调,之后在下一个事件循环再在timers阶段中执行有效回调。

同样的,这段代码也是一样的道理:

setTimeout(() => {
    setImmediate(() => {
        console.log('setImmediate');
    });
    setTimeout(() => {
        console.log('setTimeout');
    }, 0);
}, 0);

以上的代码在timers阶段执行外部的setTimeout回调后,内层的setTimeoutsetImmediate入队,之后事件循环继续往后面的阶段走,走到poll阶段的时候发现队列为空,此时有代码被setImmedate(),所以直接进入check阶段执行响应回调(注意这里没有去检测timers队列中是否有成员到达下限事件,因为setImmediate()优先)。之后在第二个事件循环的timers阶段中再去执行相应的回调。

综上,我们可以总结:

  • 如果两者都在主模块中调用,那么执行先后取决于进程性能,也就是随机。
  • 如果两者都不在主模块调用(被一个异步操作包裹),那么setImmediate的回调永远先执行。

process.nextTick() and Promise

对于这两个,我们可以把它们理解成一个微任务。也就是说,它其实不属于事件循环的一部分。

那么他们是在什么时候执行呢?

不管在什么地方调用,他们都会在其所处的事件循环最后,事件循环进入下一个循环的阶段前执行。

举个🌰:

setTimeout(() => {
    console.log('timeout0');
    process.nextTick(() => {
        console.log('nextTick1');
        process.nextTick(() => {
            console.log('nextTick2');
        });
    });
    process.nextTick(() => {
        console.log('nextTick3');
    });
    console.log('sync');
    setTimeout(() => {
        console.log('timeout2');
    }, 0);
}, 0);

结果是:

再解释一下:

timers阶段执行外层setTimeout的回调,遇到同步代码先执行,也就有timeout0sync的输出。遇到process.nextTick后入微任务队列,依次nextTick1nextTick3nextTick2入队后出队输出。之后,在下一个事件循环的timers阶段,执行setTimeout回调输出timeout2

最后

下面给出两段代码,如果能够理解其执行顺序说明你已经理解透彻。

代码1:

setImmediate(function(){
  console.log("setImmediate");
  setImmediate(function(){
    console.log("嵌套setImmediate");
  });
  process.nextTick(function(){
    console.log("nextTick");
  })
});

// setImmediate
// nextTick
// 嵌套setImmediate

解析:事件循环check阶段执行回调函数输出setImmediate,之后输出nextTick。嵌套的setImmediate在下一个事件循环的check阶段执行回调输出嵌套的setImmediate

代码2:

var fs = require('fs');

function someAsyncOperation (callback) {
  // 假设这个任务要消耗 95ms
  fs.readFile('/path/to/file', callback);
}

var timeoutScheduled = Date.now();

setTimeout(function () {

  var delay = Date.now() - timeoutScheduled;

  console.log(delay + "ms have passed since I was scheduled");
}, 100);


// someAsyncOperation要消耗 95 ms 才能完成
someAsyncOperation(function () {

  var startCallback = Date.now();

  // 消耗 10ms...
  while (Date.now() - startCallback < 10) {
    ; // do nothing
  }

});

解析:事件循环进入poll阶段发现队列为空,并且没有代码被setImmediate()。于是在poll阶段等待timers下限时间到达。当等到95ms时,fs.readFile首先执行了,它的回调被添加进poll队列并同步执行,耗时10ms。此时总共时间累积105ms。等到poll队列为空的时候,事件循环会查看最近到达的timer的下限时间,发现已经到达,再回到timers阶段,执行timer的回调。


如果有什么问题,欢迎留言交流探讨。

参考链接:

https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

creeperyang/blog#26

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant