浏览器和node下的事件循环

一道例题引出的血案

提示:(绿色表示node才有的对象)

宏任务(macro task):script(整体代码)、setTimeout、setInterval、setImmediate、 I/O、UI rendering
**微任务(micro task):process.nextTick、 promise、 **Object.observe、MutationObserve
**任务优先级:process.nextTick > promise.then > setTimeout > setImmediate **

例题

console.log("golb1");

setTimeout(function () {
  console.log("timeout1");

  process.nextTick(function () {
    console.log("timeout1_nextTick");
  });

  new Promise(function (resolve) {
    console.log("timeout1_promise");
    resolve();
  }).then(function () {
    console.log("timeout1_then");
  });

});

setImmediate(function () {
  console.log("immediate1");

  process.nextTick(function () {
    console.log("immediate1_nextTick");
  });

  new Promise(function (resolve) {
    console.log("immediate1_promise");
    resolve();
  }).then(function () {
    console.log("immediate1_then");
  });

});

process.nextTick(function () {
  console.log("glob1_nextTick");
});

new Promise(function (resolve) {
  console.log("glob1_promise");
  resolve();
}).then(function () {
  console.log("glob1_then");

});

setTimeout(function () {
  console.log("timeout2");

  process.nextTick(function () {
    console.log("timeout2_nextTick");
  });

  new Promise(function (resolve) {
    console.log("timeout2_promise");
    resolve();
  }).then(function () {
    console.log("timeout2_then");
  });

});

process.nextTick(function () {
  console.log("glob2_nextTick");
});

new Promise(function (resolve) {
  console.log("glob2_promise");
  resolve();
}).then(function () {
  console.log("glob2_then");
});

setImmediate(function () {
  console.log("immediate2");

  process.nextTick(function () {
    console.log("immediate2_nextTick");
  });

  new Promise(function (resolve) {
    console.log("immediate2_promise");
    resolve();
  }).then(function () {
    console.log("immediate2_then");
  });
  
});

我开始按照之前的经验写出这样的顺序:

golb1 -> lob1_promise -> glob2_promise -> 
glob1_nextTick -> glob2_nextTick -> glob1_then -> glob2_then -> 
timeout1 -> timeout1_promise -> timeout1_nextTick -> timeout1_then -> 
timeout2 -> timeout2_promise -> timeout2_nextTick -> timeout2_then -> 
immediate1 -> immediate1_promise -> immediate1_nextTick -> immediate1_then -> 
immediate2 -> immediate2_promise -> immediate2_nextTick -> immediate2_then

在node环境下执行结果却是

golb1 -> glob1_promise -> glob2_promise -> 
glob1_nextTick -> glob2_nextTick -> glob1_then -> glob2_then -> 
timeout1 -> timeout1_promise -> timeout2 -> timeout2_promise -> 
timeout1_nextTick -> timeout2_nextTick -> 
timeout1_then -> timeout2_then -> 
immediate1 -> immediate1_promise -> immediate2 -> immediate2_promise -> 
immediate1_nextTick -> immediate2_nextTick -> immediate1_then -> immediate2_then

嗅到一丝不一样的味道的我把浏览器不支持的一些方法注释,然后在浏览器里输出,结果是:

golb1 -> glob1_promise -> glob2_promise -> 
glob1_then -> glob2_then -> 
timeout1 -> timeout1_promise -> timeout1_then -> 
timeout2 -> timeout2_promise -> timeout2_then

这个和我之前的经验写出来的是一样,因此大概知道浏览器和node不一样了 = =,果然学习最重要的是实践,之前查阅的资料都只说了浏览器的事件循环。
正片开始,这个文章不适合新手,因为都是个人的总结,新手可以看看最下边的参考资料

事件循环

javascript从诞生之日起就是一门单线程非阻塞的脚本语言。

单线程是必要的,也是javascript这门语言的基石,原因之一在其最初也是最主要的执行环境——浏览器中,我们需要进行各种各样的dom操作。试想一下 如果javascript是多线程的,那么当两个线程同时对dom进行一项操作,例如一个向其添加事件,而另一个删除了这个dom,此时该如何处理呢?因此,为了保证不会 发生类似于这个例子中的情景,javascript选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。

而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。

当我们调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文。因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈

js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。

图中的stack表示我们所说的执行栈,web apis则是代表一些异步事件,而callback queue即事件队列。

macro task与micro task

  • 宏任务(macro task):script(整体代码)、setTimeout、setInterval、setImmediate、 I/O、UI rendering
  • **微任务(micro task):process.nextTick、 promise、 **Object.observe、MutationObserve
  • **任务优先级:process.nextTick > promise.then > setTimeout > setImmediate **

注:MutationObserve:监视对DOM树所做更改的能力。

  • setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。
// setTimeout中的回调函数才是进入任务队列的任务
setTimeout(function() {
    console.log('xxxx');
})
// 非常多的同学对于setTimeout的理解存在偏差。所以大概说一下误解:
// setTimeout作为一个任务分发器,这个函数会立即执行,而它所要分发的任务,也就是它的第一个参数,才是延迟执行
  • 来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。
  • 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去。

node事件循环

node事件循环分为6个阶段

外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O 事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段(按照该顺序反复运行)…

  • timers 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
  • I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 阶段:仅 node 内部使用
  • poll 阶段:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
  • ** check 阶段:执行 setImmediate() 的回调**
  • close callbacks 阶段:执行 socket 的 close 事件回调
console.log('start')

setTimeout(() => {
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
      }
)}, 0)

setTimeout(() => {
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    }
)}, 0)

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

console.log('end')

//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2

说明:

  1. 一开始执行栈的同步任务(这属于宏任务)执行完毕后(依次打印出 start end,并将 2 个 timer 依次放入 timer 队列),会先去执行微任务(这点跟浏览器端的一样),所以打印出 promise3
  2. 然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise.then 回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;这点跟浏览器端相差比较大,timers 阶段有几个 setTimeout/setInterval 都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务

注意

  • setTimeout 和 setImmediate
    setTimeout 可能执行在前,也可能执行在后。
const fs = require('fs')
fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0)
    setImmediate(() => {
        console.log('immediate')
    })
})
// immediate
// timeout

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

  • process.nextTick
    这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
setTimeout(() => {
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

process.nextTick(() => {
    console.log('nextTick')

    process.nextTick(() => {
        console.log('nextTick')

        process.nextTick(() => {
            console.log('nextTick')

            process.nextTick(() => {
                console.log('nextTick')
            })
        })
    })
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

总结

  1. 浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行,
    (浏览器)微任务是跟屁虫,一直跟着当前宏任务后面,代码执行到一个微任务就跟上,一个接一个
  2. Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务。

参考资料

详解JavaScript中的Event Loop(事件循环)机制
深入核心,详解事件循环机制
浏览器与Node的事件循环(Event Loop)有何区别?