事件循环机制的那些事

    |     2019年5月4日   |   web前端技术   |     0 条评论   |    1231

校招笔试常考:setTimeout(fn, 0) 和同步 console.log 谁先打印。很多人会写成 1,2,4,3,真实顺序是 1, 4, 2, 3。相关知识点就是 JavaScript 事件循环。下面从进程线程讲到宏任务微任务,均为个人理解。

先看这道题:

//写出下面代码的运行结果:

console.log('1');

setTimeout(function () {

    console.log('2');

}, 0);

setTimeout(function () {

    console.log('3');

}, 2000);

console.log('4');

错误思路往往是:先打印 1,定时器等待 0 所以立刻打印 2,再打印 4,两秒后 3。定时器回调不会插进当前同步代码中间,而是进任务队列,等栈空了再跑。

一、进程、线程与 JS 单线程

进程和线程的关系可以用工厂和工人来记:

╬в╨┼╜╪═╝_20200415212643

  • 进程好比工厂,有专属资源
  • 线程好比工人,一个工厂多个工人,关系是 1:n
  • 多个工厂彼此独立

官方说法:进程是 CPU 资源分配的最小单位;线程是 CPU 调度的最小单位。任务管理器第一个 tab 就是进程列表,CPU 和内存占比一目了然:

╬в╨┼╜╪═╝_20200415212721

C、C++、Java 都支持多线程,JS 设计成单线程,是因为它主要跑在浏览器里、最终改 DOM。两个脚本同时改同一个节点,浏览器该听谁?所以要求当前修改完成才能做下一步。

二、浏览器多进程与内核线程

打开浏览器任务管理器可以看到:每个 tab 往往对应一个进程,有内存、CPU 和 PID。新开 tab 会新建进程,但浏览器会优化:多个空白 tab 可能合并成一个进程。

╬в╨┼╜╪═╝_20200415212740

和事件循环相关的,主要是 Browser 主进程里这些常驻线程,以及负责页面的 Render 进程。

GUI 渲染线程

  • 解析 HTML/CSS,建 DOM,布局绘制
  • 重绘或回流时执行
  • 与 JS 引擎线程互斥:跑 JS 时 GUI 挂起,任务队列空闲才渲染

JS 引擎线程

  • 执行 JavaScript
  • 定时器到点、异步请求返回后,回调进队列等它执行
  • JS 跑太久会卡住页面渲染

事件触发线程

把准备好的事件交给 JS 引擎:setTimeout 计时结束、ajax 回调、点击等,依次加入任务队列队尾。

定时器线程

负责 setTimeout、setInterval。主线程遇到定时器就交给它,计时完毕由事件线程把回调塞进队列。

HTTP 请求线程

负责 Promise、axios、ajax 等异步请求。状态变化且有回调时,事件线程把回调加入队列。(这些异步线程各司其职;Render 进程负责页面渲染、JS 执行和事件循环。)

三、同步、异步与出入栈

  • 同步任务:可立即执行,如 console.log、声明变量、一次加法
  • 异步任务:不会立刻执行,包括后面要说的宏任务和微任务
  • 常见异步:Ajax、DOM 事件、setTimeout、Promise.then、Node 读文件

同步与异步的执行流程:

╬в╨┼╜╪═╝_20200415212805

  • 栈:任务在栈里执行
  • 主线程:操作员,执行栈中任务
  • 任务队列:等待被加工的回调
  • 异步注册完成后把回调放入队列;同步执行完再取队列

入栈与出栈

console.log(1);



function fn1() {

    console.log(2);

}



function fn2() {

    console.log(3);

    fn1();

}

setTimeout(function () {

    console.log(4);

}, 2000);

fn2();

console.log(5);

╬в╨┼╜╪═╝_20200415213057

运行结果:1, 3, 2, 5, 4。fn2 先入栈打印 3,再调 fn1 打印 2,然后同步的 5,最后才是 2 秒后的 4。

四、宏任务、微任务与 Event Loop

异步任务分宏任务和微任务。宏任务队列可以有多个,微任务队列只有一个。浏览器和 Node 的细节有差异。

宏任务 macrotask

script(全局任务)、setTimeout、setInterval、setImmediate、I/O、UI rendering。

微任务 microtask

原文小标题误写成 macrotask,实际是微任务:process.nextTick、Promise.then()、Object.observe、MutationObserver。微任务里 process.nextTick 优先于 Promise。

  • 宏任务:新增一个宏任务队列,来源可以有多个
  • 微任务:直接压入唯一的微任务队列

╬в╨┼╜╪═╝_20200415212828

执行机制可以顺一遍:

  • 从全局 script 进栈,执行完出栈
  • 遇到异步交给对应模块(计时、监听状态)
  • 可执行时事件线程把回调放入队列,等栈空再进栈

栈空之后宏任务和微任务怎么穿插:

  • 入口 script 本身是宏任务,同步跑完后先清空微任务队列
  • 微任务清空后再取最前面的宏任务
  • 执行宏任务时遇到的微任务再进微任务队列
  • 该宏任务栈空后再清微任务,如此循环

五、实例解析

回到开头那段代码:

//写出下面代码的运行结果:

console.log('1');

setTimeout(function () {

    console.log('2');

}, 0);

setTimeout(function () {

    console.log('3');

}, 2000);

console.log('4');
  • 全局任务先打印 1
  • 遇到 setTimeout 记为 setTimeout1,交给异步模块
  • 再遇到 setTimeout2
  • 同步打印 4
  • 再读宏任务队列:setTimeout(0) 会输出 2;HTML 标准把低于 4ms 的间隔当成 4ms
  • setTimeout2 的 2 秒多半还没到,稍后打印 3(实际等待通常不到满 2 秒,因为前面已经花了一点时间)

所以正确输出是 1, 4, 2, 然后约 2 秒后的 3。

再看宏任务里套微任务:

setTimeout(function () {

    console.log(1);

    Promise.resolve().then(function () {

        console.log(2);

    });

}, 0);

setTimeout(function () {

    console.log(3);

}, 0);

Promise.resolve().then(function () {

    console.log(4);

});

console.log(5);

第一轮:两个 setTimeout 进宏任务队列,Promise.then 进微任务,同步打印 5。

╬в╨┼╜╪═╝_20200415212844

第二轮:栈空先执行微任务,输出 4。

╬в╨┼╜╪═╝_20200415212857

再取 setTimeout1,打印 1,内部 then 进微任务。

╬в╨┼╜╪═╝_20200415212910

第三轮:先跑微任务输出 2。

╬в╨┼╜╪═╝_20200415212923

再取 setTimeout2,打印 3,结束。

╬в╨┼╜╪═╝_20200415212937

最终顺序:5, 4, 1, 2, 3。

最后是综合题,欢迎对照事件循环自己推一遍。浏览器里的结果是:

console.log('1');

setTimeout(function () {

    console.log('2');

    new Promise(function (resolve) {

        console.log('3');

        resolve();

    }).then(function () {

        console.log('4')

    })

})

new Promise(function (resolve) {

    console.log('5');

    resolve();

}).then(function () {

    console.log('6')

})

setTimeout(function () {

    console.log('7');

})

setTimeout(function () {

    console.log('8');

    new Promise(function (resolve) {

        console.log('9');

        resolve();

    }).then(function () {

        console.log('10')

    })

})

new Promise(function (resolve) {

    console.log('11');

    resolve();

}).then(function () {

    console.log('12')

})

console.log('13');

1, 5, 11, 13, 6, 12, 2, 3, 4, 7, 8, 9, 10。原因:script 里 new Promise 的执行器是同步的(5、11),两个 then 是微任务(6、12),三个 setTimeout 各是一轮宏任务,宏任务内部的 then 要等当前宏任务结束才执行。

类型 典型来源 队列 何时跑
同步 log、运算、Promise 执行器 调用栈 立刻
微任务 then / nextTick / MutationObserver 一条微队列 每个宏任务结束后清空
宏任务 script、setTimeout、I/O、UI 可有多条 一次取一个,中间穿插微任务

一句话总结:JS 单线程靠事件循环干活:同步先跑完,再清空微任务,再取一个宏任务;setTimeout(0) 也进宏任务,所以排在当前同步和已排队的 then 后面。

转载请注明来源:事件循环机制的那些事
本文链接地址:https://ai.zhousir.top/?p=2347
回复 取消