同步任务和异步任务
我们知道JavaScript语言的一大特点就是单线程,同一时间内只能做一件事。
那么,有多个任务时就会出现排队等待的情况。若是当前任务执行时间过长(比如IO操作),那么下一个任务就会等待很长的时间才能执行。
于是,针对任务就分为了两种,同步任务和异步任务。
- 同步任务都在主线程上执行,形成一个执行栈。只有前一个任务执行完毕,才能执行后一个任务。
- 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
- 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。
下面是示意图:
Event Loop(事件循环)
只要主线程空了,主线程从“任务队列”中读取事件,这个过程是不断循环的,所以JavaScript的这个运行机制又称为Event Loop(事件循环)。
这里就直接引用一张图片来协助理解:(参考自Philip Roberts的演讲《Help, I'm stuck in an event-loop》)
上图中,
- 主线程运行时会产生执行栈
- 执行栈中的代码调用某些api时,产生异步任务。异步任务执行完毕后,会在任务队列中添加回调
- 只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
- 如此循环
这里需要注意的是,执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。
定时器
上面的Event Loop我们主要说的是JS引擎线程和事件触发线程。在事件触发线程中,除了放置异步任务的事件,也可以放定时事件,即多少时间后再执行代码。这种定时执行的代码,叫做“定时器”功能。
那么,是由JS引擎来控制时间的吗?不是,JS引擎是单线程的,若存在阻塞就会影响计时准确,所以单独开了一个定时器线程来控制。
定时器功能主要由setTimeout()和setInterval()这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行。
setTimeout(function(){
console.log('hello world!');
}, 4000);
这段代码的逻辑是在计时4000毫秒后,将回调事件放置到任务队列中,等待执行。
这里需要注意的是,W3C在HTML标准中规定,setTimeout中低于4ms的时间间隔算为4ms。
setTimeout而不是setInterval
用setTimeout模拟定期计时和直接用setInterval是有区别的:
- 因为每次setTimeout计时到后就会去执行,然后执行一段时间后才会继续setTimeout,中间就多了误差
- 误差可能是等待执行栈执行导致的
- setInterval则是每次都精确的隔一段时间推入一个事件
- 由于需要等待执行栈事件执行完毕,所以事件的实际执行时间不一定就准确,还有可能是这个事件还没执行完毕,下一个事件就来了
所以,使用setInterval有一些问题:
- 累计效应,可能会导致回调代码连续执行好几次,且没有停顿。可以看看这个.
- 把浏览器最小化显示等操作时,setInterval并不是不执行程序,它会把setInterval的回调函数放在队列中,等浏览器窗口再次打开时,一瞬间全部执行时
所以,目前一般认为的最佳方案是:用setTimeout模拟setInterval,或者特殊场合直接用requestAnimationFrame(每16毫秒执行一次)。
宏任务(macrotask)和微任务(microtask)
在es6中,我们添加了Promise这一异步实现方案。这可能会导致上面的循环机制出现一些问题:
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');
它的最后输出结果如下:
script start
script end
promise1
promise2
undefined // 输出Promise的返回值
setTimeout
这里我们需要理解一个新的概念:宏任务(macrotask)和微任务(microtask)。
- 宏任务:每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
- 微任务:当前宏任务执行完毕之后立即执行的任务。
那么什么场景会形成宏任务和微任务呢?
- 宏任务:主代码块,setTimeout,setInterval等(可以看到,事件队列中的每一个事件都是一个macrotask)
- 微任务:Promise.then,MutationObserver,process.nextTick等
注:在node环境下,process.nextTick的优先级高于Promise__,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。点击这里查看详情解释。
所以,运行机制如下:
- 执行一个宏任务
- 宏任务产生微任务,则添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当微任务都执行完毕后,执行下一个宏任务
注意,在执行微任务的时候有几个地方需要注意下:
- 微任务中若产生微任务,也会添加到微任务队列中,也会在下一个宏任务前执行
setTimeout(()=>console.log("d"), 0)
var r = new Promise(function(resolve, reject){
resolve()
});
r.then(() => {
var begin = Date.now();
while(Date.now() - begin < 1000);
console.log("c1")
new Promise(function(resolve, reject){
resolve()
}).then(() => console.log("c2"))
});
最后的结果如下:
c1
c2
d
先执行的c1,在这时产生了微任务c2,所以执行c2后再执行d。
- 若微任务中调用了setTimeout等定时器,则会添加该定时器到宏任务中,具体执行顺序按照定时器线程控制顺序。
setTimeout(()=>console.log("d"), 0)
var r = new Promise(function(resolve, reject){
resolve()
});
r.then(() => {
var begin = Date.now();
while(Date.now() - begin < 1000);
console.log("c1")
new Promise(function(resolve, reject){
resolve()
}).then(() => console.log("c2"));
setTimeout(()=>console.log("d1"), 0)
});
输出的结果为:
c1
c2
d
d1
如果我们去掉其中1s的阻塞,并修改下setTimeout的定时:
setTimeout(()=>console.log("d"), 1000)
var r = new Promise(function(resolve, reject){
resolve()
});
r.then(() => {
console.log("c1")
new Promise(function(resolve, reject){
resolve()
}).then(() => console.log("c2"));
setTimeout(()=>console.log("d1"), 500)
});
输出的结果为:
c1
c2
d1
d