前言
JavaScript 是一种单线程的编程语言,其执行机制依赖于事件循环。事件循环(Event Loop)是 JavaScript 引擎的一部分,负责监控调用栈(Call Stack)和任务队列(Task Queue),检查是否有任务需要执行。当调用栈为空时,事件循环会从任务队列中取出一个任务并放入调用栈执行,这个过程不断重复,形成了循环。
进程与线程
在深入了解 JavaScript 的事件循环之前,我们需要先理解进程(Process)和线程(Thread)的基本概念。
进程 是一个程序在操作系统中执行的实例。每个进程都有自己独立的内存空间、系统资源和一个主线程。操作系统通过进程管理系统资源的分配。
线程 是进程内的一个执行单元,也是 CPU 调度的基本单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源,但每个线程有自己的栈、寄存器和程序计数器。
进程和线程的区别
-
独立性:进程之间是独立的,而线程共享进程的资源。
-
内存空间:进程有独立的内存空间,线程共享进程的内存空间。
-
资源开销:创建和销毁进程的开销较大,而创建和销毁线程的开销较小。
-
通信方式:进程间通信需要借助操作系统提供的机制(如管道、消息队列等),线程间通信可以通过共享内存直接进行。
JavaScript 是单线程
工作流程
JavaScript 是单线程语言,这意味着同一时间只能执行一个任务。单线程模型简化了编程模型,因为开发者不需要处理多线程带来的复杂问题,如资源竞争和死锁。
JavaScript 的单线程特性通过调用栈和事件循环来管理任务的执行顺序。调用栈是一个 LIFO(后进先出)的数据结构,用于存储函数调用。每当一个函数被调用时,它就会被推入调用栈顶,当函数执行完毕时,它会从调用栈顶弹出。
优点
-
节约性能:
- 单线程意味着不需要处理线程同步、锁机制等复杂的多线程操作,避免了多线程编程中的潜在竞态条件和死锁问题。
- 在一些情况下,单线程的管理能力更高效,因为不需要额外的线程切换和上下文切换开销。
-
简化编程模型:
- JavaScript 的单线程模型简化了并发编程的复杂性,使得开发者可以更专注于业务逻辑的实现,而不用过多关注线程安全、同步等问题。
-
避免资源竞争:
- 单线程执行避免了多线程中的资源竞争问题,如共享内存的并发读写问题,从而减少了一些潜在的 Bug 和调试难度。
同步与异步代码
同步代码 是按顺序逐行执行的代码。当一行代码在执行时,后续的代码必须等待其完成。这种方式简单但可能导致阻塞,尤其是在执行 I/O 操作或复杂计算时。
异步代码 允许代码在等待某些操作完成时,继续执行其他任务。异步操作通常通过回调函数、Promise 或 async/await 实现,避免了阻塞主线程。
同步代码示例
js
function syncOperation() {
console.log('Start of operation');
// 模拟一个耗时1秒的操作
const start = Date.now();
while (Date.now() - start < 1000) {}
console.log('End of operation');
}
console.log('Before sync operation');
syncOperation();
console.log('After sync operation');
// Before sync operation
// Start of operation
// End of operation
// After sync operation
异步代码示例
js
function asyncOperation(callback) {
console.log('Start of async operation');
setTimeout(() => {
console.log('End of async operation');
callback();
}, 1000);
}
console.log('Before async operation');
asyncOperation(() => {
console.log('Async operation completed');
});
console.log('After async operation');
// Before async operation
// Start of async operation
// After async operation
// End of async operation
// Async operation completed
宏任务与微任务
在 JavaScript 的事件循环中,任务可以分为两类:宏任务(Macro Task)和微任务(Micro Task)。
宏任务
-
setTimeout
-
setInterval
-
setImmediate
-
I/O 操作
-
UI 渲染
微任务
-
Promise.then
-
Promise.catch
-
Promise.finally
-
process.nextTick
-
MutationObserver
事件循环执行顺序
事件循环的执行顺序是理解 JavaScript 异步行为的关键。以下是事件循环的详细执行步骤:
-
执行同步代码:所有同步代码都会在主线程上依次执行,直至调用栈为空。
-
同步代码执行完毕,检测是否有异步需要执行:当调用栈为空时,事件循环会检查是否有异步任务需要执行。
-
检测到异步任务,先执行微任务:微任务队列中的所有任务会被依次执行,直到微任务队列为空。
-
微任务执行完毕,如果有需要就会渲染页面:在浏览器环境中,渲染引擎会在微任务执行完毕后判断是否需要更新 UI,并进行页面渲染。
-
执行异步宏任务,开启下一次事件循环:最后,事件循环会从宏任务队列中取出一个任务执行,然后开始下一次事件循环。
示例代码
js
console.log("script start");
async function async1() {
await async2();
//await 会将后续的代码阻塞进微任务队列
console.log("async1 end");
}
async function async2() {
console.log("async2 end");
}
async1();
setTimeout(function () {
console.log("setTimeout");
}, 0);
new Promise(function (resolve, reject) {
console.log("promise");
resolve();
})
.then(() => {
console.log("then1");
})
.then(() => {
console.log("then2");
});
console.log("script end");
详解
-
同步代码部分:
console.log("script start");
输出 "script start"。
-
定义异步函数 async1 和 async2:
- 这两行只是定义函数,不会立即执行它们的内容。
-
调用 async1:
- 进入
async1
,执行await async2()
。 - 进入
async2
,执行console.log("async2 end");
输出 "async2 end"。 async2
执行完毕,返回一个已解决的 Promise,await
使得async1
的后续代码被推入微任务队列。
- 进入
-
设置 setTimeout:
setTimeout
被调用,注册一个回调函数,并将其放入宏任务队列。该函数将在所有同步和微任务执行完毕后执行。
-
Promise:
new Promise
立即执行,输出 "promise"。resolve()
被调用,then
回调被推入微任务队列。
-
输出 "script end" :
console.log("script end");
输出 "script end"。