事件循环机制的那些事
校招笔试常考:setTimeout(fn, 0) 和同步 console.log 谁先打印。很多人会写成 1,2,4,3,真实顺序是 1, 4, 2, 3。相关知识点就是 JavaScript 事件循环。下面从进程线程讲到宏任务微任务,均为个人理解。
先看这道题:
错误思路往往是:先打印 1,定时器等待 0 所以立刻打印 2,再打印 4,两秒后 3。定时器回调不会插进当前同步代码中间,而是进任务队列,等栈空了再跑。
一、进程、线程与 JS 单线程
进程和线程的关系可以用工厂和工人来记:
-
进程好比工厂,有专属资源 -
线程好比工人,一个工厂多个工人,关系是 1:n -
多个工厂彼此独立
官方说法:进程是 CPU 资源分配的最小单位;线程是 CPU 调度的最小单位。任务管理器第一个 tab 就是进程列表,CPU 和内存占比一目了然:
C、C++、Java 都支持多线程,JS 设计成单线程,是因为它主要跑在浏览器里、最终改 DOM。两个脚本同时改同一个节点,浏览器该听谁?所以要求当前修改完成才能做下一步。
二、浏览器多进程与内核线程
打开浏览器任务管理器可以看到:每个 tab 往往对应一个进程,有内存、CPU 和 PID。新开 tab 会新建进程,但浏览器会优化:多个空白 tab 可能合并成一个进程。
和事件循环相关的,主要是 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 读文件
同步与异步的执行流程:
-
栈:任务在栈里执行 -
主线程:操作员,执行栈中任务 -
任务队列:等待被加工的回调 -
异步注册完成后把回调放入队列;同步执行完再取队列
入栈与出栈
运行结果: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。
-
宏任务:新增一个宏任务队列,来源可以有多个 -
微任务:直接压入唯一的微任务队列
执行机制可以顺一遍:
-
从全局 script 进栈,执行完出栈 -
遇到异步交给对应模块(计时、监听状态) -
可执行时事件线程把回调放入队列,等栈空再进栈
栈空之后宏任务和微任务怎么穿插:
-
入口 script 本身是宏任务,同步跑完后先清空微任务队列 -
微任务清空后再取最前面的宏任务 -
执行宏任务时遇到的微任务再进微任务队列 -
该宏任务栈空后再清微任务,如此循环
五、实例解析
回到开头那段代码:
-
全局任务先打印 1 -
遇到 setTimeout 记为 setTimeout1,交给异步模块 -
再遇到 setTimeout2 -
同步打印 4 -
再读宏任务队列:setTimeout(0) 会输出 2;HTML 标准把低于 4ms 的间隔当成 4ms -
setTimeout2 的 2 秒多半还没到,稍后打印 3(实际等待通常不到满 2 秒,因为前面已经花了一点时间)
所以正确输出是 1, 4, 2, 然后约 2 秒后的 3。
再看宏任务里套微任务:
第一轮:两个 setTimeout 进宏任务队列,Promise.then 进微任务,同步打印 5。
第二轮:栈空先执行微任务,输出 4。
再取 setTimeout1,打印 1,内部 then 进微任务。
第三轮:先跑微任务输出 2。
再取 setTimeout2,打印 3,结束。
最终顺序:5, 4, 1, 2, 3。
最后是综合题,欢迎对照事件循环自己推一遍。浏览器里的结果是:
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 后面。
转载请注明来源:事件循环机制的那些事


















