JavaScript 异步编程和事件循环详解
⏲️建议阅读时间: 15min
1. 异步编程的意义
什么是异步编程,解决了什么问题
异步编程是什么呢?
简单说,异步编程就是不需要等待一个任务执行完毕,就可以继续执行其他任务的编程方式。
🌰举个生活中的例子,平时我们做饭需要按顺序一步一步来,必须等一样做完才能做下一样。但是如果让好几个人一起做饭,他们就可以异步进行,同时做多件事,效率更高。(但是也不是所有的任务都可以异步,比如生孩子)
编程也是一样,默认是同步执行,只有前一个任务做完,才能执行后一个任务。异步编程允许我们在等待一个任务完成的同时,继续执行其他任务。
异步编程解决了什么问题呢?
主要解决了程序中一些不必要的等待时间问题。比如程序中有某些任务需要等待网络请求,文件读取,用户输入等情况。这种需要等待的时间对程序来说是"空转"时间。
📖浏览器和 Node.js 中的异步应用场景
在浏览器和 Node.js 中,异步编程很常见,主要应用在以下场景:
- 网络请求。比如 Ajax 请求,需要等待服务器响应,可以异步执行。
- 文件操作。读取文件、访问数据库等 IO 操作,都可能需要等待,可以异步处理。
- 用户输入。比如点击、输入等用户交互,不能假设用户的响应时间,需要异步处理。
- 定时任务。设置定时器执行某任务,也是典型的异步操作。
通过异步编程,这些需要等待的任务不会造成整个程序的阻塞,可以优化程序执行流程,提高用户体验。
所以异步编程对于今天的网络应用和交互程序来说,是非常重要的编程方式。正确使用异步编程可以让你的程序更高效、更流畅。
2. 事件循环机制简介
💡事件循环的概念和原理
什么是事件循环呢?
事件循环是一种编程模式。它的核心思想是维护一个事件队列,循环检查队列,处理队列中的事件。
我们比较一下同步模型和事件循环模型:
- 同步模型:程序按顺序依次执行任务,必须等待前一个任务结束才能执行下一个任务。
- 事件循环模型:程序维护一个待处理事件队列,循环检查队列,找出需要处理的事件并触发回调函数,然后再循环检查队列......这样一个无结束的循环。
事件循环允许我们注册事件和对应的回调函数,然后程序会自动按照发生顺序调用这些回调函数。
举个例子,假设我们注册了点击和滚动事件的监听器函数,然后点击并滚动页面,事件循环内部会自动调用注册的回调函数处理点击和滚动事件。
通过这样的机制,我们就可以采用异步编程方式来响应事件,不需要像同步模型那样等待事件处理完再做其他事情。
🚀事件循环解决的核心问题
- 可以异步处理并发事件,不需要阻塞程序等待。
- 可以 registers 多个事件和回调函数,事件循环会自动调用它们。
- 不同事件可以共享同一个线程,不需要为每个事件创建新线程。
- 可以将事件队列作为线程间通信的消息通道。
3. JavaScript 中的事件循环
💻浏览器环境下的事件循环(Event Loop)
在浏览器环境下,JavaScript 语言实现了一个 Event Loop 来处理事件循环。
它的工作流程是这样的:
- 所有同步任务直接在主线程上执行,形成一个执行栈。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
所以浏览器的事件循环是通过任务队列来实现的。异步任务被抛到队列中,等待同步任务结束,然后从队列中读取事件并执行。
🖥️Node.js 环境下的事件循环
而在 Node.js 中,它实现了一个 Event Loop 来处理事件,与浏览器略有不同:
- Node 的 Event Loop 只包含 6 个阶段,它们会控制事件的执行。
- 每个阶段都有一个先进先出的回调队列,只有回调队列清空后才会进入下一阶段。
- 通过这种阶段控制机制,Node 实现了非阻塞 I/O 模型。
🔍两者的区别和联系
区别:
- 浏览器的事件循环是单一的,而 Node.js 有多个阶段的事件循环。
- 浏览器将异步任务统一放入任务队列,Node.js 每个阶段有独立的回调队列。
- 浏览器没有阶段的概念,所有任务都在一个队列里,Node.js 通过不同阶段来控制任务执行。
- 浏览器的事件循环是由 HTML5 标准定义,Node.js 的事件循环规范是独立的。
联系:
- 它们底层都实现了事件循环机制,可以处理异步任务而不阻塞主线程。
- 都维护一个队列来存储待处理事件,然后循环查找新的事件。
- 都会在主线程空闲时,从队列中取出事件处理回调函数执行。
- 都允许非阻塞 IO 和事件驱动的编程方式。
- 都需要避免长时间运行的任务 blocking 主线程。
- 它们帮助 JavaScript 实现了异步非阻塞的并发模型。
4. 🧰事件循环的作用
🔑启用异步编程
事件循环的第一个作用是启用异步编程。
通过事件循环,我们可以把一些需要等待结果的任务注册为异步任务,不会阻塞主线程,非常适合处理诸如网络请求、文件 I/O 等异步操作。
举个例子,在浏览器中发一个 Ajax 请求。我们注册一个回调函数来接收响应,然后该请求被加入事件循环的"任务队列"。我们的主线程可以接着做其他工作,当这个 Ajax 响应返回的时候,对应的回调函数会在事件循环中被调用。这样就实现了异步编程方式。
🔗调度任务执行
事件循环的第二个作用是调度任务执行。
事件循环会按照正确的顺序执行回调函数,比如先点击按钮,后滚动页面,事件循环会保证先调用点击的回调函数,然后再调用滚动的回调。这样就实现了调度作用。
🔧优化性能
事件循环的第三个作用是优化性能。
通过异步非阻塞的方式执行任务,可以大大减少程序中的等待时间,充分利用 CPU,也节约了线程资源,非常高效。
🧯避免长时间阻塞
最后,事件循环避免了长时间运行的任务 blocking 主线程。
因为事件循环会按照队列顺序执行回调函数,所以每个回调函数不能运行太长时间,否则会堵塞后面的任务。通过这种方式,事件循环很好的避免了单个任务的长时间阻塞。
5. 👓常见的异步编程方法
回调函数
第一种是回调函数。一个函数可以接受另一个函数作为参数,这个作为参数传递进去的函数就叫回调函数。回调函数通常在当前任务完成后被执行。
例如,我们可以注册一个回调函数来处理 Ajax 返回的结果:
js
function ajaxCallback(result) {
console.log(result);
}
ajax("http://example.com", ajaxCallback);
Promise
Promise 是一个对象,代表了一个异步任务的最终完成或失败。我们可以在 Promise 对象上注册回调函数。
使用 Promise 的话,代码如下:
js
let p = new Promise();
p.then(function (result) {
console.log(result);
});
p.catch(function (err) {
console.log(err);
});
async/await
async/await,这是基于 Promise 实现的语法糖。async 函数会返回一个 Promise 对象,await 等待 Promise 结果:
js
async function myFunc() {
let result = await doSomethingAsync();
console.log(result);
}
事件和监听器
最后是事件和监听器,这是浏览器异步编程的主要方式。例如可以为按钮添加点击事件:
js
let btn = document.getElementById("myBtn");
btn.addEventListener("click", function () {
// 收到点击事件后的处理
});
6. 实现一个简单的事件循环
往往事件循环代表了应用的整个生命周期,事件循环结束意味着应用也就退出了。
我们先来看一个简陋版的事件循环系统。
js
class EventSystem {
constructor() {
// 任务队列
this.queue = [];
}
// 追加任务
enQueue(func) {
this.queue.push(func);
}
// 事件循环
run() {
while (1) {
while (this.queue.length) {
const func = this.queue.shift();
func();
}
}
}
}
// 新建一个事件循环系统
const eventSystem = new EventSystem();
// 生产任务
eventSystem.enQueue(() => {
console.log("hi");
});
// 启动事件循环
eventSystem.run();
// 生产任务
eventSystem.enQueue(() => {
console.log("hi");
});
根据上面我们了解到的知识,我们可以知道这个简陋版的事件循环逻辑如下
- 新建一个事件循环系统;
- 生产任务;
- 启动事件循环系统处理任务。
但是!如果你运行就会发现,当没有任务时,它就会陷入死循环,这不仅浪费了 CPU,新的任务也无法继续添加了。
因此,在一个事件循环系统中,如何处理没有任务时的情况非常关键。这应该怎么解决呢?
js
class EventSystem {
constructor() {
// 需要处理的任务队列
this.queue = [];
// 标记是否需要退出事件循环
this.stop = 0;
// 有任务时调用该函数"唤醒" await
this.wakeup = null;
}
// 没有任务时,事件循环的进入"阻塞"状态
wait() {
return new Promise((resolve) => {
// 记录 resolve,可能在睡眠期间有任务到来,则需要提前唤醒
this.wakeup = () => {
this.wakeup = null;
resolve();
};
});
}
// 停止事件循环,如果正在"阻塞",则"唤醒它"
setStop() {
this.stop = 1;
this.wakeup && this.wakeup();
}
// 生产任务
enQueue(func) {
this.queue.push(func);
this.wakeup && this.wakeup();
}
// 处理任务队列
handleTask() {
if (this.queue.length === 0) {
return;
}
// 本轮事件循环的回调中加入的任务,下一轮事件循环再处理,防止其他任务没有机会处理
const queue = this.queue;
this.queue = [];
while (queue.length) {
const func = queue.shift();
func();
}
}
// 事件循环的实现
async run() {
// 如果 stop 等于 1 则退出事件循环
while (this.stop === 0) {
// 处理任务,可能没有任务需要处理
this.handleTask();
// 处理任务过程中如果设置了 stop 标记则退出事件循环
if (this.stop === 1) {
break;
}
// 没有任务了,进入睡眠
if (this.queue.length === 0) {
await this.wait();
}
}
// 退出前可能还有任务没处理,处理完再退出
this.handleTask();
}
}
// 新建一个事件循环系统
const eventSystem = new EventSystem();
// 启动前生成的任务
eventSystem.enQueue(() => {
console.log("1");
});
// 模拟定时生成一个任务
setTimeout(() => {
eventSystem.enQueue(() => {
console.log("3");
eventSystem.setStop();
});
}, 1000);
// 启动事件循环
eventSystem.run();
// 启动后生成的任务
eventSystem.enQueue(() => {
console.log("2");
});
现在我们就有了一个简单但是核心功能都有的事件循环系统。
- 事件循环的整体架构是一个 while 循环。
- 定义任务类型和队列,这里只有一种任务类型和一个队列,比如 Node.js 里有好几种,每种类型的任务有不同的作用。
- 没有任务的时候怎么处理:进入阻塞状态,而不是靠忙轮询。
第 3 点是事件循环系统中非常重要的逻辑,任务队列中不可能一直都有任务需要处理,这就意味着生产任务可以是一个异步的过程,所以事件循环系统就需要有一种等待 / 唤醒机制。
7. 总结
🔔异步编程模式的意义
异步编程模式非常重要,它允许我们在等待某个操作时继续执行其他代码,大大提高了效率。
其核心是事件循环机制。它维护一个事件队列,然后循环检查队列并执行回调函数,这样就实现了异步执行流程。
JavaScript 语言利用事件循环实现了异步编程。比如,在浏览器中我们可以注册事件回调函数,Node.js 也使用事件循环处理非阻塞 IO。
💾事件循环的工作机制
- 事件循环的整体架构是一个 while 循环。
- 定义任务类型和队列,这里只有一种任务类型和一个队列,比如 Node.js 里有好几种,每种类型的任务有不同的作用。
- 没有任务的时候怎么处理:进入阻塞状态,而不是靠忙轮询。
💻在 JavaScript 中高效利用事件循环
要在 JavaScript 中高效利用事件循环,我们可以:
- 多用异步 API,比如定时器、Promise 等,避免阻塞。
- 合理使用异步语法 async/await 来组织代码。
- 避免长时间运行的任务阻塞线程。
- 使用 Promise 链或 async/await 处理回调地狱。
- 合理使用定时器和请求拆分来控制并发。
- 使用事件循环可视化工具观察事件循环状态。