深入研究javaScript中的Event Loop(事件循环)机制

说明

尝试回答几个问题:

  1. js为什么是一门单线程的语言?
  2. js是怎么处理同步任务和异步任务的?
  3. 宏任务和微任务的区别
  4. 浏览器能用js引擎线程去做定时器的计时功能吗?如果不能,又是怎么实现的计时功能?

前言

javaScript是一门单线程的语言,主要指js引擎是单线程的,至于为什么js引擎是单线程的?这个问题应该没有标准的答案,可能仅仅是因为由于多线程的复杂性,或者是多线程操作一般要加锁,也可能是与浏览器交互的原因。因此最初设计时选择了单线程。

单线程意味着,javascript代码在执行的任何时候,都只有一个主线程来处理所有的任务。如果遇到了异步代码应该怎么去处理呢?会不会阻塞当前的线程?如果遇到复杂的计算,会不会造成阻塞?本文将带着这些疑问去探讨javaScript的执行机制。

浏览器环境下js引擎的事件循环机制

事件循环会涉及到几个概念:

  • JS引擎线程
  • 事件触发线程
  • 定时触发器线程

执行栈与事件队列

堆和栈

当javascript代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。 但是我们这里说的执行栈和上面这个栈的意义却有些不同。

执行栈

当我们调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this对象。 而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。

执行同步代码

当一个脚本第一次执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么js会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码,在这个执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以是无限进行下去的,除非发生了栈溢出,即超过了所能使用内存的最大值。当这个执行环境中的代码执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。这个过程反复进行,直到执行栈中的代码全部执行完毕。

执行异步代码

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

image.png

macro task和micro task

以上的事件循环过程只是一个宏观的表述,实际上因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

微任务:

  • Promise.then、Promise.catch、Promise.finally
  • process.nextTick
  • MutationObserver

宏任务:

  • setInterval
  • setTimeout
  • setImmediate
  • I/O操作
  • requestAnimationFrame
  • ajax

在一个事件循环中,异步事件返回结果后会被放到一个事件队列中。然而,根据这个异步事件的类型,这个事件实际上会被放到对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会优先查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行微任务队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。

简单的说就是,当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行

举个例子:

console.log(1);
setTimeout(function () {
    console.log(4);
});

new Promise(function(resolve,reject){
    console.log(2)
    resolve(3)
}).then(function(val){
    console.log(val);
})

执行结果为:
1
2
3
4
复制代码

根据线程来理解下:

  • macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护。
  • microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护

单独说一下定时器

事件循环机制的核心是:JS引擎线程和事件触发线程。这里面还有一些隐藏细节,比如调用setTimeout后,是如何等待特定时间后才添加到事件队列中的?

是JS引擎检测的么?当然不是了。它是由定时器线程控制(因为JS引擎自己都忙不过来,根本无暇分身)。

为什么要单独的定时器线程?因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确,因此很有必要单独开一个线程用来计时。

什么时候会用到定时器线程?当使用setTimeoutsetInterval,它需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。

举个例子:

// 这段代码的作用是当1000毫秒计时完毕后(由定时器线程计时),将回调函数推入事件队列中,等待主线程执行
setTimeout(function(){
    console.log('hello!');
}, 1000);
复制代码
// 这段代码的效果是最快的时间内将回调函数推入事件队列中,等待主线程执行
setTimeout(function(){
    console.log('hello!');
}, 0);
console.log('begin');
​
// 执行结果是:先begin后hello!
// 虽然代码的本意是0毫秒后就推入事件队列,但是W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。
// 就算不等待4ms,就算假设0毫秒就推入事件队列,也会先执行begin(因为只有可执行栈内空了后才会主动读取事件队列)
复制代码

setTimeout和setInterval

每次setTimeout计时到后就会去执行,然后执行一段时间后才会继续setTimeout,中间就多了误差 (误差多少与代码执行时间有关)。setInterval则是每次都精确的隔一段时间推入一个事件 (但是,事件的实际执行时间不一定就准确,还有可能是这个事件还没执行完毕,下一个事件就来了)。

setInterval比较致命的问题是:

  • 累计效应(上面提到的),如果setInterval代码在(setInterval)再次添加到队列之前还没有完成执行,就会导致定时器代码连续运行好几次,而之间没有间隔。 就算正常间隔执行,多个setInterval的代码执行时间可能会比预期小(因为代码执行需要一定时间)。(补充:不过JS高程中有提到,JS引擎会对setInterval进行优化,如果当前事件队列中有setInterval的回调,不会重复添加)
  • 而且把浏览器最小化显示等操作时,setInterval并不是不执行程序,它会把setInterval的回调函数放在队列中,等浏览器窗口再次打开时,一瞬间全部执行回调函数。

鉴于setInterval的问题,目前一般认为的最佳方案是:用setTimeout模拟setInterval,或者特殊场合直接用requestAnimationFrame。

总结

javascrit的事件循环是这门语言中非常重要且基础的概念。清楚的了解了事件循环的执行顺序和每一个阶段的特点,可以使我们对一段异步代码的执行顺序有一个清晰的认识,从而减少代码运行的不确定性。合理的使用各种延迟事件的方法,有助于代码更好的按照其优先级去执行。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享