EventLoop
JavaScript是 单线程
、非阻塞
的,它通过事件队列 (Event Loop)
的方式来实现异步回调。
关键点
单线程
:JavaScript是单线程的,即同一个时间只能做一件事,事件循环使其能够高效地处理异步操作。非阻塞式I/O
:事件循环使得JavaScript可以执行长时间运行的任务(如I/O操作),而不会阻塞线程。微任务优先
:在每个宏任务(Macrotask)执行完毕后,立即执行当前微任务队列
中的所有微任务(依次执行),在进行下一个宏任务之前。
为什么 javascript 是单线程的
我们知道多线程可以提高效率啊,为什么JavaScript被设计为单线程,主要原因是它创建的初衷:与用户的交互以及操作DOM。
在Web页面中,脚本需要响应用户的操作:如点击按钮、提交表单等,这些操作涉及到对DOM的读写。假如JavaScript是多线程的,那么就会引入复杂的同步问题。
例如:两个线程同时尝试修改同一个DOM节点,那么会产生竞态条件,导致不可预测的结果。所以js一诞生就是单线程并且未来也不会改变
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
简化开发
单线程模型简化了JavaScript的设计和使用。开发者可以更专注于实现功能,而不用担心如线程同步、死锁等多线程编程中常见的问题。
避免DOM操作冲突
javascript选择只用一个主线程来执行代码,任何时刻只有一个操作可以被执行,从而保证了DOM操作的一致性和可预测性。
事件循环和异步编程
尽管JavaScript是单线程的,但它通过事件循环(Event Loop)机制支持异步编程。事件循环允许JavaScript在执行I/O密集型或耗时任务(如Ajax请求、文件操作等)时,不会阻塞主线程。
这是通过将这些任务设置为异步操作并配合回调函数、Promise或async/await来实现的。事件循环和异步编程模型使JavaScript能够高效地处理多种操作,而无需引入多线程的复杂性。
什么是非阻塞
JavaScript的非阻塞特性是指在执行耗时操作(如I/O操作、请求数据等)时,不会阻塞程序的其他部分继续执行。
这种特性是通过异步编程模式实现的,它允许JavaScript在等待某个操作完成的同时,继续执行代码的其他部分,从而提高程序的整体性能和响应能力。
为什么需要非阻塞
在浏览器环境中,JavaScript运行在单线程中,这意味着在同一时间内只能执行一个任务。如果JavaScript执行一个长时间运行的任务,如从服务器下载大量数据,它将阻塞后续代码的执行,导致整个页面无法响应用户操作,甚至出现卡顿。
通过非阻塞异步编程,JavaScript可以在等待某个长时间运行的任务完成的同时,继续执行其他任务,提高应用的响应性和用户体验。
实现非阻塞的方式有哪些?
1) 回调函数(Callbacks)
最初,JavaScript通过回调函数实现非阻塞行为。当一个异步操作开始时,会传入一个函数(回调函数),这个函数会在异步操作完成时被调用。
这种方式虽然解决了非阻塞的问题,但当有多个异步操作需要协同工作时,会导致所谓的"回调地狱"(Callback Hell),使得代码难以阅读和维护。
2) Promise
为了解决回调函数带来的问题,ES6引入了Promise对象。Promise提供了一种更优雅的方式来处理异步操作。
它代表了一个异步操作的最终完成(或失败)及其结果值。通过.then()和.catch()方法,可以更容易地组织和管理异步操作及其结果。
3)async/await
ES7进一步引入了async
和await
关键字,使得使用Promise的代码可以像写同步代码那样简洁明了。
async
函数声明一个函数是异步的,而await
关键字用于等待一个Promise解决(resolve)。使用async
和await
可以以同步的方式写出清晰、易读的代码,同时保持异步操作的非阻塞特性。
同步任务、异步任务、宏任务、微任务之间的概念和关系
JS 分为同步任务和异步任务;同步任务都在JS引擎线程上执行,形成一个执行栈
,它们与事件循环无关;异步任务被分为宏任务和微任务,它们都依赖事件循环进行调度,但执行时机不同;
事件触发线程管理一个任务队列
,异步任务触发条件达成,将回调事件放到任务队列
中。
-
同步任务:这些任务在主线程上按顺序执行,执行过程会阻塞后续任务的执行,直到当前任务完成。同步任务的执行不依赖事件循环,它们直接在调用栈(执行栈)中按顺序执行。
-
异步任务:异步任务的执行不会立即完成,它们依赖事件循环进行调度。异步任务包括宏任务和微任务,它们在特定的时间点被推入各自的队列中等待执行。
-
宏任务(Macrotasks)
- 宏任务是一类异步任务,它们代表了一些较大的、独立的工作单元。每个宏任务的执行会在一个新的事件循环中进行,包括:
setTimeout
、setInterval
、I/O 操作
、UI 渲染
(在浏览器环境中)、postMessage
等
- 宏任务是一类异步任务,它们代表了一些较大的、独立的工作单元。每个宏任务的执行会在一个新的事件循环中进行,包括:
-
微任务(Microtasks)
- 微任务也是异步任务,但它们用于处理一些需要尽快执行的较小的工作单元。**微任务在当前宏任务执行完毕后、下一个宏任务开始之前执行。包括:Promise(⚠️promise本身是同步的,回调函数才是异步的)的回调(
.then
、.catch
、.finally
)、MutationObserver
的回调、queueMicrotask
。
- 微任务也是异步任务,但它们用于处理一些需要尽快执行的较小的工作单元。**微任务在当前宏任务执行完毕后、下一个宏任务开始之前执行。包括:Promise(⚠️promise本身是同步的,回调函数才是异步的)的回调(
一次事件循环的执行
-
执行当前宏任务 :事件循环首先从宏任务队列(也称为任务队列)中取出第一个任务执行。这包括了诸如
setTimeout
、setInterval
、I/O 操作、用户交互事件(如点击或键盘事件)等任务。 -
执行所有微任务 :当前宏任务执行完毕后,事件循环会检查微任务队列。如果队列中有微任务(例如,由
Promise.then()
或MutationObserver
等产生的任务),事件循环会依次执行队列中的所有微任务,直到微任务队列清空。微任务的执行是连续的,不会中断。 -
渲染UI(如果需要):在浏览器环境中,一旦微任务队列清空,浏览器会检查是否需要执行UI渲染。通常,浏览器的UI渲染会在执行完所有微任务之后,下一个宏任务开始之前进行。
-
继续下一个宏任务:完成当前宏任务、所有微任务以及可能的UI渲染之后,事件循环会回到第一步,从宏任务队列中取出下一个任务,开始新一轮的执行。
在同一次事件循环中,微任务(Microtasks)总是在当前宏任务(Macrotasks)之后执行。
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。
只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
实践
js
setTimeout(() => {
console.log(1)
}, 0)
console.log(2)
const promise2 = new Promise((resolve) => {
console.log(3)
resolve(3)
})
promise2.then((res) => {
console.log(4)
})
执行步骤和输出结果的解释:
- setTimeout(() => { console.log(1) }, 0) 注册了一个宏任务,但它的回调函数(打印
1
)不会立即执行。放入宏任务队列中,等待当前执行栈清空和所有微任务完成后再执行。 - console.log(2) 是同步代码,会立即执行,输出
2
。 - 创建了一个新的Promise对象promise2,*console.log(3)*也是同步代码,因此会立即执行,输出
3
。 - 在Promise的executor函数中,调用resolve(3) 。这会将 promise2.then((res) => { console.log(4) }) 中的回调函数加入到微任务队列,等待当前执行栈清空后执行。
- 此时,同步代码执行完毕。
- 在进入下一个宏任务之前,事件循环会处理所有微任务。因此,promise2.then()的回调函数会被执行,输出
4
。 - 最后,事件循环处理下一个宏任务,即 setTimeout() 的回调函数,执行并输出
1
。
所以,输出结果为:2
、3
、4
、1
。
js整体执行顺序是:同步任务 -> 微任务 -> 宏任务 -> 微任务 -> 宏任务 -> ...
总结
-
执行一个
宏任务
(栈中没有就从事件队列
中获取) -
执行过程中如果遇到
微任务
,就将它添加到微任务
的任务队列中 -
宏任务
执行完毕后,立即执行当前微任务队列
中的所有微任务
(依次执行) -
当前
宏任务
执行完毕,开始检查渲染,然后GUI线程
接管渲染 -
渲染完毕后,
JS线程
继续接管,开始下一个宏任务
(从事件队列中获取)
分享一个实用工具:Event Loop可视化面板
参考文献: