事件循环(Call Stack、Task Queue、Event Loop)

事件循环(Call Stack、Task Queue、Event Loop)

EventLoop(事件循环)认识

JavaScript 事件循环(Event Loop)是JavaScript运行时环境(比如浏览器或Node.js)的核心机制,允许 JavaScript 在执行异步代码时不阻塞主线程。这个机制使得 JavaScript 在执行长时间运行的任务(比如 I/O 操作、计时器等)时仍然能响应用户输入和执行其他任务

🍎作用

Event Loop是我们编写高效、非阻塞的 JavaScript 代码的核心。

Event Loop用于协调同步代码、异步代码以及各种任务的执行。

Event Loop使JavaScript这个单线程语言能够实现非阻塞的异步操作

确保程序不会因为阻塞操作(如长时间运行的计算)而导致用户界面或其他任务的冻结

🍎核心概念

事件循环的核心概念是 执行栈(Call Stack)、任务队列(Task Queue) 和 事件循环(Event Loop) 本身

执行栈 (Call Stack)----后进先出(LIFO模式-- Last In, First Out)

任务队列(Task Queue)---先进先出(FIFO模式 First In, First Out)

🍎解释

我们可以把Event Loop想象成一个永不停歇的循环,它不断地检查两个地方

一个是主线程的执行栈(Call Stack)

另一个是任务队列(Task Queue)

当执行栈清空时,Event Loop就会从任务队列中取出任务,放到执行栈中执行

🍎事件循环的工作原理

通过几个步骤来实现:

  • 任务队列(Task Queue) 事件循环不断检查任务队列,查看是否有任务需要执行。 任务可能是用户输入事件、I/O操作完成的通知、定时器的到期等。
  • 调用栈(Call Stack) 调用栈用于管理当前正在执行的代码。如果调用栈为空且任务队列中有任务, 事件循环将从任务队列中取出任务,并将其推送到调用栈中执行。
  • 非阻塞I/O操作: 在事件循环机制中,长时间运行的I/O操作(例如网络请求、文件读写)不会阻塞主线程, 而是将这些任务交给其他线程处理。 当I/O操作完成时,相关的回调函数会被放入任务队列中,等待事件循环去执行。

🍎JavaScript中的事件循环

在JavaScript中,事件循环在浏览器中或Node.js环境中都得到了广泛应用

JavaScript是单线程的,通过事件循环,可以处理大量的异步任务,而不会阻塞用户界面或其他任务

🍎异步编程与事件循环

异步编程通过回调、Promiseasync/await实现。事件循环确保这些异步操作按照正确的顺序执行,即使它们在代码中是非顺序的

微任务队列(Microtasks)

Promise的回调函数、MutationObserver的回调函数等会被放到微任务队列中。微任务的优先级高于宏任务,事件循环会在执行下一个宏任务前,先执行微任务队列中的所有任务

🍎优化异步代码

事件循环是实现非阻塞I/O操作、提高系统响应速度、优化用户体验的关键技术。在单线程环境中,它能有效地管理大量并发操作,写Event loop的时候我们需要注意到:

避免长时间运行的同步代码

长时间运行的同步代码会阻塞主线程,导致页面卡顿。如果遇到耗时操作,考虑将其拆分为多个小任务,或者使用Web Workers在后台线程中执行。

合理使用Promiseasync/await

Promiseasync/await是处理异步操作的强大工具,能够让代码更清晰、易维护。但滥用await可能会导致性能问题,例如在循环中频繁使用await

理解任务优先级

清楚宏任务和微任务的执行顺序,可以避免不必要的bug

进程(Process)和线程(Thread)

🍎进程

是计算机中运行的一个程序实例,代表着一个程序在内存中的执行。

每个进程都拥有独立的地址空间、代码、数据、文件描述符等资源,进程之间是相互独立的。

进程是操作系统管理的基本单位。

🍎线程

是进程中的一个执行单元,是程序执行的最小单位。

线程是进程内的一个执行流,指的是执行一段指令所需的时间。

一个进程可以包含一个或多个线程,这些线程共享进程的资源(如内存空间、文件描述符等)但每个线程有自己独立的执行栈。

线程之间的切换比进程之间的切换要轻便得多,因此,线程更适合用来进行多任务处理。

举个例子

比如我们打开手机上的一个app,就打开了一个进程。看到页面就开始了一个渲染线程,发消息开始了一个消息线程。这些线程共享微信进程的内存空间,各自执行不同的任务。

浏览器进程(Process)和线程(Thread)

浏览器也是多进程的应用程序,打开一个浏览器Tab页面时,就意味着开启了一个新的浏览器进程。

进程是独立的,所以即使其中一个Tab崩溃了,也不会影响到其他Tab的正常运行。

浏览器进程内部,有许多线程在协同工作,最终将网页内容展示出来。

我们理解Event Loop最密切相关的有以下几种线程

🍎HTTP请求线程(网络线程)

负责处理网络请求,下载前端(HTML、CSS、JavaScript)文件,或发送Ajax请求获取数据。

🍎JS引擎线程

负责解析和执行JavaScript代码。JavaScript是单线程的,这意味着在同一时间,JS引擎线程只能做一件事。

🍎渲染线程(GUI渲染线程)

负责解析HTML和CSS,构建DOM树和渲染树,并最终将页面绘制到屏幕上。当页面需要重绘或回流时,也是它来完成。

需要注意:JS引擎线程和渲染线程是互斥的。

意味着当JS引擎线程在执行JavaScript代码时,渲染线程会被挂起,无法进行页面的渲染。反之亦然。这就是为什么长时间运行的JavaScript代码会阻塞页面渲染,导致页面"卡死"的原因。而其他线程之间,比如HTTP请求线程和JS引擎线程,则可以并行工作,互不影响。

JavaScript的单线程特性与异步

🍎JavaScript单线程的原因

JavaScript被设计成单线程的,主要是为了避免DOM操作的复杂性。

如果JavaScript是多线程的,当多个线程同时操作同一个DOM元素时,就会出现竞态条件(Race Condition),导致不可预测的结果。

例如,一个线程要删除某个DOM元素,另一个线程要修改它,那么到底应该以哪个线程的操作为准呢?为了避免这种复杂性,JavaScript从诞生之初就被设计为单线程。

这意味着,JavaScript引擎在执行代码时,默认只开启一个线程工作。这个线程就是我们前面提到的JS引擎线程。负责从头到尾地执行JavaScript代码,一次只处理一个任务。

🍎单线程异步机制如何处理耗时操作-挂起

既然JavaScript是单线程的,那如何处理那些耗时很长的操作呢?网络请求(Ajax)、定时器(setTimeout、setInterval)或者文件读写?

如果这些操作都同步执行,在它们完成之前,JS引擎线程就会一直被阻塞,导致页面长时间无响应,用户体验极差。

为解决这个问题,JavaScript引入了异步机制。当JS引擎线程遇到异步代码时,它不会等待异步操作完成,而是会将其"挂起",交给其他线程(比如浏览器提供的Web APIs)去处理,然后JS引擎线程继续执行后续的同步代码。当异步操作完成后,它会将一个"任务"放入任务队列(Task Queue)中,等待JS引擎线程空闲时再来处理。

🍎同步代码与异步代码的区别

同步代码

按照代码顺序,一行一行执行。只有前一行代码执行完毕才能执行下一行。如果某一行代码耗时很长,那么后续代码都会被阻塞。

plain 复制代码
console.log('Start');
alert('这是一个同步操作,会阻塞页面'); // 阻塞
console.log('End');

打印 'Start'

弹出警告框

只有点击确定后,才会打印 'End'。

在警告框弹出期间,页面是无法进行任何操作的。

异步代码

不会立即执行,而是会被"挂起",等待某个条件满足(比如定时器时间到了,网络请求回来了)后,再将对应的回调函数放入任务队列,等待JS引擎线程空闲时执行。异步代码不会阻塞主线程的执行。

plain 复制代码
console.log('Start');
setTimeout(() => {
  console.log('Async operation finished');
}, 0);
console.log('End');

代码的执行顺序是

先打印 'Start',然后setTimeout被挂起,JS引擎线程继续执行,打印 'End'。最后,当JS引擎线程空闲时(即使setTimeout设置的时间是0毫秒),setTimeout的回调函数才会被执行,打印 'Async operation finished'。这就是异步的魅力,它让JavaScript在单线程的环境下也能处理复杂的、耗时的任务,而不会阻塞用户界面。

🍎任务队列的概念

任务队列是一个先进先出(FIFO)的队列,用于存放异步操作完成后需要执行的回调函数。当异步操作(比如setTimeout的计时结束,或者Ajax请求成功返回数据)满足了执行条件时,它们对应的回调函数并不会立即执行,而是会被放入这个任务队列中排队。JS引擎线程会在执行完所有同步代码后,才从任务队列中取出任务来执行。这个不断从任务队列中取出任务并执行的过程,就是Event Loop的核心所在。

宏任务(Macro-tasks)与微任务(Micro-tasks)

在JavaScript的异步世界里,任务被分成了两种类型:宏任务(Macro-tasks)和微任务(Micro-tasks)。这两种任务在Event Loop中有着不同的优先级和执行时机。

宏任务(Macro-tasks)包括:

  • setTimeout
  • setInterval
  • I/O操作(例如网络请求 ajax、文件读写)
  • UI渲染
  • setImmediate (Node.js环境特有)

微任务(Micro-tasks)包括:

  • Promise.then()Promise.catch()Promise.finally()
  • process.nextTick (Node.js环境特有)
  • MutationObserver (用于监听DOM变化)

Event Loop的执行顺序详解

宏任务和微任务是理解理解JavaScript异步编程的关键:

🍎执行宏任务,执行完毕=> 清空微任务=> 渲染页面(如果需要)=> 继续下一个宏任务

  1. 执行同步代码: 当JavaScript代码开始执行时,会首先执行所有的同步代码。这些同步代码可以被看作是当前宏任务的一部分。在执行过程中,如果遇到异步任务(无论是宏任务还是微任务),就会将其对应的回调函数放入相应的任务队列中。
  2. 清空微任务队列: 当所有同步代码执行完毕后,Event Loop并不会立即去执行宏任务队列中的任务。它会优先检查并清空微任务队列。意味着,所有在当前宏任务执行期间产生的微任务,都会在下一个宏任务开始之前被执行完毕。
  3. 页面渲染(可选): 在微任务队列清空之后,如果浏览器判断有必要进行页面渲染(比如DOM结构发生了变化,或者需要更新UI),它就会进行一次页面渲染。这一步是可选的,浏览器会根据实际情况决定是否进行渲染。
  4. 执行下一个宏任务: 页面渲染完成后,Event Loop会从宏任务队列中取出一个任务来执行。这个任务执行完毕后,又会重复步骤2,检查并清空微任务队列,然后再次进行页面渲染(如果需要),接着再从宏任务队列中取出下一个任务......如此循环往复,直到所有任务执行完毕。

async/await

🍎await关键字在Event Loop中的表现

async/await是ES2017引入的异步编程语法糖,它让异步代码看起来像同步代码一样,极大地提高了代码的可读性和可维护性。

await的底层实现依然是基于Promise和Event Loop的。

🍎await的本质:将后续代码推入微任务队列

当你使用await关键字时,它会暂停async函数的执行,直到await后面的Promise对象状态变为resolvedrejected。而await的精妙之处在于,它会将await后面的代码(即async函数中await表达式之后的代码)推入微任务队列。

这意味着,当await等待的Promise解决后,async函数中被暂停的代码会作为微任务被添加到微任务队列中,等待当前宏任务执行完毕,并且所有已有的微任务执行完毕后,才会轮到它执行。

🍎浏览器对await执行时间的优化

🌂早期版本async/await

在早期的async/await实现中,await 是基于传统的事件循环机制实现的。

await 后面的异步操作(如 Promise)被"暂停"并放入 微任务队列,而 await 本身会让代码的执行变得"同步化"

即浏览器会等待 await 后的异步任务执行完毕后,再继续执行下面的代码。导致在一些情况下,await 可能会带来比必要的更多的延时。

以前的await执行原理(已过时)

在旧的实现中,await会将await后面的代码(即async函数中await表达式之后的代码)作为一个微任务放入微任务队列。

await表达式本身,如果它等待的是一个Promise,那么这个Promisethen方法也会产生一个微任务。这可能导致一些复杂的执行顺序问题。

🌂导致的问题:

不必要的延时

如果 await 的异步操作已经很快执行完毕,浏览器仍然会等待当前执行周期的其他同步任务完成,导致一些不必要的延迟。

回调队列的拥塞

当多个异步任务并行执行时,await 后的代码会推迟到事件循环的微任务队列中,可能会造成回调的堆积和延迟。

🌂新版本

微任务队列优化

现在 await 在很多情况下会直接在当前事件循环内执行,而不需要推迟到下一个事件循环。这样,当异步操作快速完成时,await 可以更快地继续执行后续代码。

优先级调整

现代的JavaScript引擎会更智能地调度微任务,优先执行即将完成的异步任务,减少不必要的延迟。

现在的await执行顺序

现在,await的执行机制可以这样理解:当await一个Promise时,它会"阻塞"async函数的执行,直到Promise解决。一旦Promise解决,await会立即将async函数中await表达式后面的代码作为微任务添加到微任务队列中。而await表达式本身,可以被看作是立即执行的,它只是等待一个值。

结合代码示例深入分析await的执行流程:

让我们通过一个具体的例子来理解await的执行流程。假设我们有以下代码:

plain 复制代码
// async.js
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

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

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});

console.log("script end");

请你预测一下这段代码的输出顺序

我们来一步步分析:

  1. console.log("script start"): 首先执行同步代码,输出 script start
  2. setTimeout: 遇到setTimeout,将其回调函数放入宏任务队列。
  3. async1(): 调用async1函数。
  • console.log("async1 start"):async1函数内部,首先输出 async1 start
  • await async2(): 遇到awaitasync2函数被调用,输出 async2
  • await会暂停async1的执行,并将async1await后面的代码(即console.log("async1 end"))放入微任务队列。
  1. new Promise(...): 继续执行同步代码,遇到Promise
  • console.log("promise1")Promise构造函数是同步执行的,所以立即输出 promise1
  • resolve()Promise状态变为resolved
  • .then(...)then方法的回调函数被放入微任务队列。
  1. console.log("script end"): 继续执行同步代码,输出 script end

至此,所有的同步代码执行完毕。此时,微任务队列中有两个任务:

  1. async1await后面的代码 (console.log(async1 end"))
  2. Promise.then的回调 (console.log(promise2"))

根据Event Loop的执行顺序,接下来会清空微任务队列:

  1. 执行微任务队列: 先执行async1await后面的代码,输出 async1 end
  2. 执行微任务队列: 再执行Promise.then的回调,输出 promise2

微任务队列清空后,检查宏任务队列:

  1. 执行宏任务队列: 执行setTimeout的回调,输出 setTimeout

最终的输出顺序是:

plain 复制代码
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

这个例子完美地展示了async/await在Event Loop中的执行机制,以及微任务的优先级高于宏任务的特性。

常见误区

理解了Event Loop的原理,我们再来看一些实际场景和常见的误区

🍎经典题

Event Loop是前端常客,尤其是关于输出顺序的题目。除了上面async/await的例子,我们再来看一个经典的题目:

plain 复制代码
// 1.js
console.log("script start");

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

Promise.resolve()
  .then(function () {
    console.log("promise1");
  })
  .then(function () {
    console.log("promise2");
  });

console.log("script end");

做一下这段代码的输出顺序以后我们分析一下

同步代码=> 微任务队列 => 下一个宏任务

  1. console.log("script start"): 首先执行同步代码,输出 script start
  2. setTimeout: 遇到setTimeout,将其回调函数放入宏任务队列。
  3. Promise.resolve().then(...)Promise.resolve()会立即返回一个已解决的Promise。它的第一个.then回调函数被放入微任务队列。
  4. console.log("script end"): 继续执行同步代码,输出 script end

至此,所有同步代码执行完毕。此时微任务队列中有:promise1的回调。

  1. 执行微任务队列: 执行promise1的回调,输出 promise1。注意,promise1的回调执行完毕后,它返回的Promise会立即resolve,因此其后面的.then回调(promise2)会立即被放入微任务队列。
  2. 继续执行微任务队列: 执行promise2的回调,输出 promise2

微任务队列清空。检查宏任务队列:

  1. 执行宏任务队列: 执行setTimeout的回调,输出 setTimeout

最终的输出顺序是:

plain 复制代码
script start
script end
promise1
promise2
setTimeout

注意微任务的优先级。即使setTimeout的延迟时间设置为0,它也必须等待当前宏任务中的所有微任务执行完毕后才能执行。

🍎错误理解与纠正

  1. 误区:setTimeout(fn, 0)会立即执行。纠正:setTimeout(fn, 0)表示将fn放入宏任务队列,等待主线程空闲且所有微任务执行完毕后,在下一个宏任务阶段执行。它并不能保证立即执行,只是表示"尽快"执行。

  2. 误区:async/await是同步的。纠正:async/await只是语法糖,它让异步代码看起来像同步代码,但其本质仍然是基于Promise的异步操作,并且await后面的代码会被推入微任务队列,遵循Event Loop的规则。

  3. 误区:只要是异步代码,就一定会比同步代码后执行。纠正: 这句话不完全正确。异步代码的回调函数确实会在同步代码执行完毕后才执行,但微任务的优先级高于宏任务。所以,在同步代码执行完毕后,会先执行所有微任务,再执行宏任务。

相关推荐
Qrun39 分钟前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp40 分钟前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.2 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl4 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫5 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友5 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理7 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻7 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
mapbar_front8 小时前
在职场生存中如何做个不好惹的人
前端
牧杉-惊蛰8 小时前
纯flex布局来写瀑布流
前端·javascript·css