浏览器的执行(3)-JS运行机制 Event Loop

734 阅读6分钟

同步任务和异步任务

我们知道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

参考