深入理解 Event Loop

2019/5/21

众所周知,JavaScript(以下简称 JS) 是单线程语言,在 html5 中增加了 web workers,web workers 是新开了线程执行的,那么 JS 还是单线程的吗?当然是,为什么要设计成单线程?

网上有很多说法,大部分都说是多个线程同时对一个dom操作(同时修改dom内容,一个线程增加属性,一个线程删除属性),会非常混乱,当然如果支持多线程就会相应的就要加入多线程的锁机制,那么 JS 就变得非常复杂了,想想 JS 最开始设计的初衷就是用于用户交互,而且当时的原始需求是:功能不需要太强,语法较为简单,容易学习和部署,Brendan Eich 只用了10天,就设计完成了这种语言的第一版,因此也不可能加入多线程这么复杂的技术。

即使现在支持 web workers,由于没有多线程的机制,web workers 和执行线程只能通过 postMessage 来通信,而且由于没有锁,web workers 无法访问 window 和 document 对象。

JS 的单线程是指一个浏览器进程中只有一个 JS 的执行线程,即同一时刻内只会有一段代码在执行。

Micro-Task 与 Macro-Task

单线程如何实现异步?JS 设计了一个事件循环的方式。所有的代码执行均按照事件循环的方式进行。

事件循环中分两种任务:一个是宏任务(Macro-Task),另一个是微任务(Micro-Task)。常见的宏任务和微任务如下。

宏任务:script(整体代码)、setTimeout、setInterval、requestAnimationFrame、I/O、事件、MessageChannel、setImmediate (Node.js)
微任务:Promise.then、 MutaionObserver、process.nextTick (Node.js)

事件循环按下图的方式进行。

注意:
宏任务执行完后,需要清空当前微任务队列后才回去执行下一个宏任务,如果微任务里面产生了新的微任务,仍然会在当前事件循环里面被执行完,后面会举例说明。

来个示例验证下上面的流程。

<script>
    console.log(1);

    setTimeout(function timeout1() {
        console.log(2);
    }, 0);

    Promise.resolve().then(function promise1() {
        console.log(3);
        setTimeout(function timeout2() {
            console.log(4);
            Promise.resolve().then(function promise2() {
                console.log(5);
            });
        }, 0);
        return Promise.resolve()
            .then(function promise3() {
                console.log(6);
                return Promise.resolve().then(function promise4() {
                    console.log(7);
                });
            })
            .then(function promise5() {
                console.log(8);
            });
    })
    console.log(9);
</script>

<script>
    console.log(10);
    setTimeout(function timeout3() {
        console.log(11);
    }, 0);
    Promise.resolve().then(function promise6() {
        console.log(12);
    });
</script>

按照上面流程梳理下执行流程:

  1. 将两个宏任务(两个script代码)初始化进宏任务队列,宏任务队列为:[script1, script2]
  2. script1 出队压入执行栈执行,宏任务队列为:[script2]
  3. 同步代码执行输出:1,
  4. 0ms 后把 timeout1 放入宏任务队列,宏任务队列为:[script2, timeout1]
  5. promise1 入队,微任务队列为:[promise1]
  6. 同步代码执行输出:9
  7. script1 执行完毕,进入微任务执行阶段,promise1 出队压入执行栈执行,微任务队列为空
  8. 同步代码执行输出:3
  9. 0ms 后把 timeout2 放入宏任务队列,宏任务队列为:[script2, timeout1, timeout2]
  10. promise3 入队,微任务队列为:[promise3]
  11. promise1 执行完毕,继续判断微任务队列是否为空,promise3 出队压入执行栈执行,微任务队列为空
  12. 同步代码执行输出:6
  13. promise4 入队,微任务队列为:[promise4]
  14. promise3 执行完毕,promise5 入队,微任务队列为:[promise4,promise5]
  15. 判断微任务队列是否为空,promise4 出队压入执行栈执行,微任务队列为:[promise5]
  16. 同步代码执行输出:7
  17. promise4 执行完毕,继续判断微任务队列是否为空,promise5 出队压入执行栈执行,微任务队列为空
  18. 同步代码执行输出:8
  19. 微任务队列清空,宏任务 script2 出队压入执行栈执行,宏任务队列为空
  20. 同步代码执行输出:10
  21. 0ms 后把 timeout3 放入宏任务队列,宏任务队列为:[timeout1, timeout2, timeout3]
  22. promise6 入队,微任务队列为:[promise6]
  23. script2 执行完毕,进入微任务执行阶段,promise6 出队压入执行栈执行,微任务队列为空
  24. 同步代码执行输出:12
  25. 微任务队列为空,宏任务 timeout1 压入执行栈执行,宏任务队列为[timeout2, timeout3]
  26. 同步代码执行输出:2
  27. timeout1执行完毕,微任务队列为空,宏任务 timeout2 压入执行栈执行,宏任务队列为[timeout3]
  28. 同步代码执行输出:4,promise2 入队,微任务队列为:[promise2]
  29. timeout2 执行完毕,判断微任务队列是否为空,promise2 出队压入执行栈执行,微任务队列为空
  30. 同步代码执行输出:5
  31. promise2执行完,微任务队列为空,宏任务 timeout2 压入执行栈执行,宏任务队列为空
  32. 同步代码执行输出:11
  33. timeout3执行完毕,微任务队列为空,宏任务队列为空

setTimeout

setTimeout 的 delay 最小值在不同浏览器的有差异,在 Chrome 74 上测试的结果是 2ms,Firefox 67 上则是 1ms。

最小值是什么意思?就是小于这个值后,浏览器按照0处理。比如在 Chrome 上,测试下面的代码:

setTimeout(function(){console.log(1)},1.99);
setTimeout(function(){console.log(2)},0);

输出的结果为 1、2,而

setTimeout(function(){console.log(1)},2);
setTimeout(function(){console.log(2)},0);

输出的结果为 2、1,说明 2ms 是有效的。当然这个前提是两个 setTimeout 直接没有别的代码,因为代码执行还需要时间。

另外 setTimeout 是从调用开始计时,到了时间就放入宏任务队列,我们来看下面的例子。

var s = Date.now()
setTimeout(function timeout1() {
    console.log(1)
}, 200)

while (Date.now() - s <= 200) {
}

setTimeout(function timeout2() {
    console.log(2)
}, 0)
  1. timeout1 200ms 后会放入到宏任务队列中
  2. while 执行了 200ms,此时 timeout1 已经先添加到宏任务队列中,因此最终打印结果为:1、2
  3. 如果将 while 的时间设置小于 200ms,考虑到代码执行需要花费时间,将 while 的条件改为Date.now() - s <= 198
  4. 测试 while 执行花费了 198ms,timeout2 会被先添加到宏任务队列中,因此最终打印结果会是:2、1

setInterval

和 setTimeout 相同,调用开始计时,按 delay 时间将回调添加到宏任务队列中。那么 setInterval 是按 delay 不断的向宏任务队列添加任务,还是需要等待已添加的任务执行完后再添加,还是其他机制?

思考下面代码:

var start = Date.now()

var id = setInterval(function interval() {

    var whileStart = Date.now()   
    console.log(whileStart - start)                 // 输出 interval1 调用的时间和最开始调用计时的时间差,即过了多久才调用
    while (Date.now() - whileStart < 250) {   // 相当于 sleep 250ms
    }
}, 100)

setTimeout(function timeout() {
    clearInterval(id)
    console.log(Date.now() - start)       
}, 400)

打印的时间间隔是?

100
351
605
855

为了更好的理解,用图示来解释上面的流程。

参考

JavaScript语言的历史

原创文章,持续完善中,转载请注明出处。本文地址: https://www.qinshenxue.com/article/event-loop.html