从一个最简单的示例说起:
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
为什么?
姑且称为宏任务,在很多上下文也被简称为task。例如:
setTimeout
setInterval
setImmediate
requestAnimationFrame
最常见的延迟调用与间歇调用,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的规则是等下一班车(下一轮事件循环,或者当前事件循环尚未发生的特定阶段)
微任务,也称job。例如:
process.nextTick
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也会被立即处理掉,即允许阻塞)
我们知道JS天生的异步特性是靠Event Loop来完成的,例如:
const afterOneSecond = console.log.bind(console, '1s later');
setTimeout(afterOneSecond, 1000);
具体执行过程大致如下:
1000
ms后,再处理afterOneSecond
回调afterOneSecond
回调被插入macrotask queueafterOneSecond
回调加入调用栈afterOneSecond
,log输出1s later
afterOneSecond
出栈,调用栈又空了到这里开始有点意思了,比如事件循结束的时间点,一个常见的误解是:
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,同样依赖着事件循环
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.所以,这个场景有意思的地方在于事件冒泡带来的“隐式函数调用”
NodeJS中有4个macrotask队列(有明确的处理顺序):
setTimeout
、setInterval
setImmediate
事件循环从过期的timer开始检查,按顺序依次处理各个队列中等待着的所有回调
此外,还有2个microtask队列(也有明确的处理顺序):
process.nextTick
nextTick微任务队列优先级高于其它微任务队列,所以只有在nextTick空了才处理其它的比如Promise
Next tick queue has even higher priority over the Other Micro tasks queue.
前者是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要等下一班车:
P.S.setImmediate
描述不是十分严谨,等到下一个immediate
阶段就可以执行了,不一定是下一轮事件循环(取决于当前处于哪个阶段)
P.S.单从名字上来看,似乎immediate
更近,实际上nextTick
才是最近的将来,历史原因,没得换了
注意,之所以存在nextTick
,是为了提供更细粒度的task,让它能够在事件循环各阶段的夹缝中执行,比如做一些着急的清理工作,错误处理/重试,也就是说有实际需求场景,具体见Why use process.nextTick()?,这里不展开
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 0
的0
并不是严格意义上的“立即”,也就是说一个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
的回调,而不用等到下一轮
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())
怎么对事件循环计数?
不妨这样做:
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 0
与setImmediate
的顺序问题,可以通过计数器做进一步验证,结果如下:
// 1st
setImmediate 1
setTimeout 1
// 2nd
setTimeout 0
setImmediate 1
1 1
表示timer没赶上接下来的第一轮事件循环,到第二轮的时候才执行,0 1
表示在接下来的第一轮事件循环之前,timer已经过期了(成功赶上了)