macrotask与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');

log输出顺序如下:

script start
script end
promise1
promise2
setTimeout

为什么?

macrotask

姑且称为宏任务,在很多上下文也被简称为task。例如:

  • setTimeout

  • setInterval

  • setImmediate

  • requestAnimationFrame

  • I/O

  • UI rendering

最常见的延迟调用与间歇调用,Node环境的立即调用,高频的RAF,以及I/O操作和改UI。这些都是macrotask,事件循环的主要工作就是一轮一轮地检查macrotask queue,并处理这些任务

例如:

setImmediate(() => {
  console.log('#1');
});
setImmediate(() => {
  console.log('#2');
});
setImmediate(() => {
  console.log('#3');
  setImmediate(() => {
    console.log('#4');
  });
});

下一次检查immediate macrotask queue时,会依次执行外层的3个回调函数,下下一次才执行内层的那个,所以macrotask的规则是等下一班车(下一轮事件循环,或者当前事件循环尚未发生的特定阶段)

microtask

微任务,也称job。例如:

  • process.nextTick

  • Promise callback

  • Object.observe

  • MutationObserver

nextTick和Promise经常见到,Object.observe应该是个废弃API,原生观察者实现,MutationObserver来历比较久远了,用来监听DOM change

一般情况下,这些回调函数都会在某些条件下被添加到microtask queue,在当前macrotask队列flush结束后检查该队列并flush掉(处理完队列中的所有microtask)

P.S.二般情况指的是某些浏览器版本下的Promise callback不一定走microtask queue,因为Promises/A+规范没有明确要求这一点(说是都行)

例如:

setImmediate(() => {
  console.log('immediate');
});
Promise.resolve(1).then(x => {
  console.log(x);
  return x + 1;
}).then(x => {
  console.log(x);
  return x + 1;
}).then(x => console.log(x));

下一次检查microtask queue的时候,发现只有一个Promise callback,立即执行,再检查发现又冒出来一个,继续执行,诶检查又刷出来一个,接着执行,再检查,没了,继续事件循环,检查immediate macrotask queue,这时才执行setImmediate回调。所以microtask的规则是挂在当前车尾,而且允许现做现卖(当前macrotask队列flush结束时就执行,不用等下一班车,而且microtask queue flush过程中产生的同类型microtask也会被立即处理掉,即允许阻塞)

Event Loop

我们知道JS天生的异步特性是靠Event Loop来完成的,例如:

const afterOneSecond = console.log.bind(console, '1s later');
setTimeout(afterOneSecond, 1000);

具体执行过程大致如下:

  1. JS线程启动,创建事件循环

  2. script加入调用栈

  3. 执行第一行创建了一个Function

  4. 执行第二行,(由Event Table)记下1000ms后,再处理afterOneSecond回调

  5. script出栈,调用栈空了

  6. 事件循环空跑一会儿(macrotask queue为空,无事可做)

  7. 1s多后,timer过期了,afterOneSecond回调被插入macrotask queue

  8. 接下来的一轮事件循环检查macrotask queue发现非空,先进先出,取出afterOneSecond回调加入调用栈

  9. 执行afterOneSecond,log输出1s later

  10. afterOneSecond出栈,调用栈又空了

  11. 不会再有事情发生了,事件循环结束

到这里开始有点意思了,比如事件循结束的时间点,一个常见的误解是:

JS代码执行都处于事件循环里

这当然是含糊的,实际上直到调用栈为空的时候,事件循环才有存在感(检查任务队列),确认不会再有事情发生的时候,就结束事件循环,例如:

// 把上例写入./setTimeout.js文件
$ node ./setTimeout.js
1s later

用来执行./setTimeout.js的Node进程大约存活了1s,伴随着事件循环的结束而正常exit了。而Server程序则不同,比如一直监听着特定端口的请求,事件循环无法结束,所以Node进程也一直存在

P.S.每个JS线程都有自己的事件循环,所以Web Worker也有独立的事件循环

P.S.Event Table是一个数据结构,配合Event Loop使用,用来记录回调触发条件与回调函数的映射关系:

Every time you call a setTimeout function or you do some async operation — it is added to the Event Table. This is a data structure which knows that a certain function should be triggered after a certain event.

作用

那么,事件循环的存在意义是什么?没这个东西不行吗?

就是为了支持异步特性。试想,JS用于浏览器环境这么多年,无论UI交互还是网络请求都是比较慢的,而JS运行在主线程,会阻塞渲染,如果这些慢动作都是同步阻塞的,那么体验会相当差,例如:

document.body.addEventListener('click', () => alert(+new Date));
const xhr = new XMLHttpRequest();
// Sync xhr
xhr.open('GET', 'http://www.ayqy.net', false);
xhr.send(null);
console.log(xhr.responseText);

执行send()的大约3秒内,页面完全无响应,在此期间点出来的alert框会被插入macrotask队列,直到请求响应回来,这些框才会一个接一个地弹出来

如果没有事件循环,这3秒将彻底无法交互,alert框也不会再在将来某一刻弹出来。所以,事件循环带来了异步特性,以应对慢动作阻塞渲染的问题

P.S.实际上,DOM事件回调都是macrotask,同样依赖着事件循环

Call Stack

JS的单线程环境意味着某一时刻只能做一件事,所以(一个JS线程下)调用栈只有一个。例如:

function mult(a, b) { return a * b; }
function double(a) { return mult(a, 2); }
+ function main() {
  return double(12);
}();

执行过程中调用栈的变化情况如下:

// push script
// push main
// push double
// push mult
// pop mult
// pop double
// pop main
// pop script

注意,只有在调用栈为空的时候,事件循环才有机会工作,例如:

function onClick() {
  console.log('click');
  setTimeout(console.log.bind(console, 'timeout'), 0);
  // Wait 10ms
  let now = Date.now();
  while (Date.now() - now < 10) {}
}
document.body.addEventListener('click', onClick);
document.body.firstElementChild.addEventListener('click', onClick);
document.body.firstElementChild.click();

上例的输出结果是:

click
click
timeout
timeout

第一个click输出后没有立即输出timeout因为此时调用栈不空(栈里只有个onClick,是孩子身上的),事件循环就不检查macrotask队列,虽然里面确实有个过期timer的回调。具体来讲,是因为事件冒泡触发了body身上的onClick,所以孩子身上的onClick还不能出栈,直到一串同步冒泡结束

P.S.所以,这个场景有意思的地方在于事件冒泡带来的“隐式函数调用”

6个任务队列

NodeJS中有4个macrotask队列(有明确的处理顺序)

  1. Expired timers/intervals queue:setTimeoutsetInterval

  2. IO events queue:如文件读写、网络请求等回调

  3. Immediates queue:setImmediate

  4. Close handlers queue:如socket的close事件回调

事件循环从过期的timer开始检查,按顺序依次处理各个队列中等待着的所有回调

此外,还有2个microtask队列(也有明确的处理顺序)

  1. Next tick queue:process.nextTick

  2. Micro task queue:如Promise callback

nextTick微任务队列优先级高于其它微任务队列,所以只有在nextTick空了才处理其它的比如Promise

Next tick queue has even higher priority over the Other Micro tasks queue.

nextTick与setImmediate

前者是microtask,后者是macrotask,这意味着过多连续的nextTick调用会阻塞事件循环,进而阻塞I/O,所以除非必要,不要滥用nextTick

It is suggested you use setImmediate() over process.nextTick(). setImmediate() likely does what you are hoping for (a more efficient setTimeout(…, 0)), and runs after this tick’s I/O. process.nextTick() does not actually run in the “next” tick anymore and will block I/O as if it were a synchronous operation.

另外,二者的主要区别是,nextTick挂在车尾执行,而setImmediate要等下一班车

  • process.nextTick() fires immediately on the same phase

  • setImmediate() fires on the following iteration or ‘tick’ of the event loop

P.S.setImmediate描述不是十分严谨,等到下一个immediate阶段就可以执行了,不一定是下一轮事件循环(取决于当前处于哪个阶段)

P.S.单从名字上来看,似乎immediate更近,实际上nextTick才是最近的将来,历史原因,没得换了

注意,之所以存在nextTick,是为了提供更细粒度的task,让它能够在事件循环各阶段的夹缝中执行,比如做一些着急的清理工作,错误处理/重试,也就是说有实际需求场景,具体见Why use process.nextTick()?,这里不展开

setTimeout与setImmediate

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

根据timer-IO-immediate-close的macrotask处理顺序,猜测log先后顺序是:

setTimeout
setImmediate

实际情况是:

// 1st
setImmediate
setTimeout
// 2nd
setImmediate
setTimeout
// 3rd
setImmediate
setTimeout
// 4th
setTimeout
setImmediate
// 5th
setImmediate
setTimeout
// 6th
setTimeout
setImmediate

输出是无序的,不是因为存在竞争关系,而是因为setTimeout 00并不是严格意义上的“立即”,也就是说一个0ms的timer不一定会立即把回调函数插入任务队列,所以setTimeout 0可能赶不上接下来最近的一轮事件循环,此时就会出现不合常理的输出

那么什么情况下能确定二者的顺序呢?

const fs = require('fs');

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

IO队列处理中产生新的timer task和immediate task,按照顺序,接下来开始处理immediate队列,所以总是先输出'immediate',顺序不会乱

那么,有办法能让它们保持相反的顺序吗?

有的。这样做:

setTimeout(function() {
    console.log('setTimeout')
}, 0);
//! wait timer to be expired
var now = Date.now();
while (Date.now() - now < 2) {
    //...
}
setImmediate(function() {
    console.log('setImmediate')
});

上例会稳定先输出setTimeout,中间的阻塞2ms是在等待timer过期,这样就能保证在启动事件循环之前,timer因为过期,其回调就已经被插进待处理队列中了

P.S.至于为什么这里用2ms,因为据说setTimeout 0被转换成了setTimeout 1ms,所以我们恰好多等一点点,具体见Understanding Non-deterministic order of execution of setTimeout vs setImmediate in node.js event-loop的uvlib源码分析

P.S.如果2ms不够,就多等一会儿,反正关键点就是等timer过期,只有这样才能让事件循环第一眼就看见setTimeout 0的回调,而不用等到下一轮

IO starvation

microtask机制带来了IO starvation问题,无限长的microtask队列会阻塞事件循环,为了避免这个问题,NodeJS早期版本(v0.12)设置了1000的深度限制(process.maxTickDepth),后来去掉了

process.maxTickDepth has been removed, allowing process.nextTick to starve I/O indefinitely. This is due to adding setImmediate in 0.10.

P.S.具体见https://github.com/nodejs/node/wiki/API-changes-between-v0.10-and-v0.12#process

例如:

const fs = require('fs');

function addNextTickRecurs(count) {
    let self = this;
    if (self.id === undefined) {
        self.id = 0;
    }

    if (self.id === count) return;

    process.nextTick(() => {
        console.log(`process.nextTick call ${++self.id}`);
        addNextTickRecurs.call(self, count);
    });
}

addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
    console.log('omg! file read complete callback was called!');
});

console.log('started');

永远不会输出omg! xxx,因为同步代码执行完后,调用栈空了,事件循环检查任务队列发现nextTick微任务队列非空,取出该微任务,把回调扔进调用栈执行一下,又插进去一个,没完没了,停不下来了

注意,是立即检查nextTick队列,而不用管此刻处于事件循环的哪个阶段:

the nextTickQueue will be processed after the current operation completes, regardless of the current phase of the event loop.

(引自The Node.js Event Loop, Timers, and process.nextTick())

Event Loop Counter

怎么对事件循环计数?

不妨这样做:

const LoopCounter = {
  counter: 0,
  active: true,
  start() {
    setImmediate(this.countLoop.bind(this));
  },
  stop() {
    this.active = false;
  },
  get() {
    return this.counter;
  },
  countLoop() {
    this.counter++;
    if (this.active) setImmediate(this.countLoop.bind(this));
  }
};

// test
LoopCounter.start();
let now = Date.now();
let intervals = 0;
let MAX_COUNT = 10;
let handle = setInterval(() => {
  console.log(LoopCounter.get());
  if (++intervals >= MAX_COUNT) {
    clearInterval(handle);
    LoopCounter.stop();
  }
}, 10);

setImmediate做时钟,是因为4种macrotask里,只有setImmediate能够确保在下一轮事件循环立即得到处理

这个计数器有什么用?

可以用来跟踪事件循环,比如确认是否处于同一个事件循环,比如之前讨论的setTimeout 0setImmediate的顺序问题,可以通过计数器做进一步验证,结果如下:

// 1st
setImmediate 1
setTimeout 1
// 2nd
setTimeout 0
setImmediate 1

1 1表示timer没赶上接下来的第一轮事件循环,到第二轮的时候才执行,0 1表示在接下来的第一轮事件循环之前,timer已经过期了(成功赶上了)

参考资料

macrotask与microtask》上有3条评论

  1. 张诗恒

    你好 请问你的微信号是多少?需要咨询微信公众号文章批量生成 这个事情,我的qq微信 1311771248

    回复
  2. pyuyu

    大佬好,请问

    NodeJS中有4个macrotask队列(有明确的处理顺序):Expired timers/intervals queue:setTimeout、setInterval IO events queue:如文件读写、网络请求等回调 Immediates queue:setImmediate Close handlers queue:如socket的close事件回调 事件循环从过期的timer开始检查,按顺序依次处理各个队列中等待着的所有回调 此外,还有2个microtask队列(也有明确的处理顺序): Next tick queue:process.nextTick Micro task queue:如Promise callback nextTick微任务队列优先级高于其它微任务队列,所以只有在nextTick空了才处理其它的比如Promise

    中描述的4个宏任务队列和2个微任务队列就是对应着node中timers、pending callbacks、idle, prepare、poll、check、close callbacks这六个阶段么?如果是的话,这六个阶段不是依次执行么;如果不是的话,那么宏任务、微任务队列与这6个阶段是怎样一个关系呢 中描述的4个宏任务队列和2个微任务队列就是对应着node中timers、pending callbacks、idle, prepare、poll、check、close callbacks这六个阶段么?如果是的话,这六个阶段不是依次执行么;如果不是的话,那么宏任务、微任务队列与这6个阶段是怎样一个关系呢

    回复
  3. pyuyu

    大佬好,请问

    NodeJS中有4个macrotask队列(有明确的处理顺序):Expired timers/intervals queue:setTimeout、setInterval IO events queue:如文件读写、网络请求等回调 Immediates queue:setImmediate Close handlers queue:如socket的close事件回调 事件循环从过期的timer开始检查,按顺序依次处理各个队列中等待着的所有回调 此外,还有2个microtask队列(也有明确的处理顺序): Next tick queue:process.nextTick Micro task queue:如Promise callback nextTick微任务队列优先级高于其它微任务队列,所以只有在nextTick空了才处理其它的比如Promise

    中描述的4个宏任务队列和2个微任务队列, 4个宏任务队列应该是6个宏任务队列?即timers、pending callbacks、idle, prepare、poll、check、close callbacks这六个阶段么?

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code