前言
在JavaScript的执行环境中,事件循环(Event Loop)是实现非阻塞异步执行的关键机制。它由主线程(也称为调用栈)和任务队列(Event Queue)组成,这两个组件协同工作以确保同步和异步任务的有序执行。文章结尾附上真题解析
- 主线程(Call Stack):这是JavaScript引擎执行同步代码的场所。当代码被执行时,同步任务会被压入调用栈中,按照后进先出(LIFO)的原则依次执行。
- 任务队列(EventQueue):这是一个先进先出(FIFO)的数据结构,用于存储异步任务的回调函数。当异步操作(如网络请求、定时器等)完成时,它们的回调函数会被放入任务队列中等待执行。
- 事件循环检查(Event LoopCheck):主线程在执行完当前的同步任务后,会进入事件循环检查阶段。在这个阶段,主线程会不断检查任务队列,查看是否有待处理的事件。
- 任务队列处理:如果任务队列中有待处理的事件,主线程会从队列中取出第一个事件,将其回调函数推入调用栈中执行。这个过程是连续的,主线程会不断地从任务队列中取出事件并执行,直到队列为空。
- 事件循环的持续性:事件循环是一个持续的过程,它会不断地检查任务队列,确保异步任务能够在适当的时机被执行。这种机制保证了JavaScript的执行环境能够在单线程中高效地处理同步和异步任务,避免了多线程环境下可能出现的竞态条件和锁的问题。
事件循环的步骤
- 执行同步代码:当执行JavaScript代码时,同步任务会被推入调用栈中执行。
- 执行异步回调:当异步操作(如setTimeout, Promise, XMLHttpRequest等)完成时,它们的回调函数会被推入事件队列。
- 事件循环检查:事件循环会检查调用栈是否为空。如果为空,它会从事件队列中取出第一个任务,推入调用栈执行。
- 重复循环:事件循环会不断重复上述过程。
同步任务
同步任务指的是任务按照程序中的顺序,一个接一个地执行,并且当前任务必须等待前一个任务完成后才能开始。在同步执行中,程序的执行流程是线性的,每个操作都必须等待前一个操作完成后才能进行。
console.log('Step 1');
let result = add(2, 3);
console.log(result);
console.log('Step 2');
function add(a, b) {
return a + b;
}
在上面的例子中,console.log(‘Step 1’) 执行完毕后才会执行函数调用 add(2, 3),并等待 add 函数返回结果后才会继续执行后续代码。
异步任务
异步任务则允许程序在等待某些操作完成时继续执行其他任务。这意味着可以启动一个任务,然后程序继续执行,而不需要等待该任务完成。当异步任务完成时,通常会通过回调函数、事件、或者Promise等方式通知程序。包括:
- 回调函数 callback
- Promise/async await
- Generator
- 事件监听
- 发布/订阅
- 计时器(setTimeout,setInterval)
- requestAnimationFrame
- MutationObserver
- process.nextTick
- I/O操作
console.log('Start');
setTimeout(() => {
console.log('Timeout callback');
}, 1000);
console.log('End');
在上述例子中,setTimeout 是一个异步任务,它会在1秒后将回调函数推入任务队列,而主线程不会等待这个1秒,而是继续执行后面的 console.log(‘End’)。当主线程的同步任务执行完成后,它会检查任务队列,将异步任务的回调函数推入执行栈,最终输出 ‘Timeout callback’。
任务队列
任务队列分为宏任务队列(macrotask queue)和微任务队列(microtask queue)两种。JavaScript 引擎遵循事件循环的机制,在执行完当前宏任务后,会检查微任务队列,执行其中的微任务,然后再取下一个宏任务执行。这个过程不断循环,形成事件循环。
1、宏任务包括:
- 所有同步任务
- I/O操作,如文件读写、数据库数据读写等
- setTimeout、setInterval
- setImmediate(Node.js环境)
- requestAnimationFrame
- 事件监听回调函数等
2、微任务包括:
- Promise的then、catch、finally
- async/await中的代码
- Generator函数
- MutationObserver
- process.nextTick(Node.js 环境)
Node.js中的事件循环
Node.js的事件循环比浏览器中的复杂,因为它需要处理I/O操作、网络请求等。Node.js的事件循环由以下几个阶段组成:
- timers:执行setTimeout和setInterval的回调。
- I/O callbacks:执行除了close事件外的所有I/O操作的回调。
- idle, prepare:Node.js内部使用,与libuv的事件循环有关。
- poll:等待新的I/O事件,如果timers阶段执行时间太长,poll阶段会被推迟。
- check:执行setImmediate的回调。
- close callbacks:执行关闭事件的回调,如socket.on(‘close’, …).
多版本Node.js的事件循环
Node.js的事件循环机制在不同版本中有所变化。例如:
- Node.js v0.10:使用了较为简单的事件循环模型。
- Node.js v0.12:引入了setImmediate,允许在当前事件循环的末尾执行回调。
- Node.js 4.x:引入了kPromiseResolve阶段,用于处理Promise的resolve回调。
- Node.js 7.x:引入了async_id和async_hooks模块,用于跟踪异步操作。
结尾
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// 旧版输出如下,但是请继续看完本文下面的注意那里,新版有改动
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
分析这段代码:
- 执行代码,输出script start。
- 执行async1(),会调用async2(),然后输出async2 end,此时将会保留async1函数的上下文,然后跳出async1函数。
- 遇到setTimeout,产生一个宏任务
- 执行Promise,输出Promise。遇到then,产生第一个微任务
- 继续执行代码,输出script end
- 代码逻辑执行完毕(当前宏任务执行完毕),开始执行当前宏任务产生的微任务队列,输出promise1,该微任务遇到then,产生一个新的微任务
- 执行产生的微任务,输出promise2,当前微任务队列执行完毕。执行权回到async1
- 执行await,实际上会产生一个promise返回,即
let promise_ = new Promise((resolve,reject){
resolve(undefined)})
-
执行完成,执行await后面的语句,输出async1 end
-
最后,执行下一个宏任务,即执行setTimeout,输出setTimeout
最近搞了一个前端知识分享的群,大家有什么问题都可以在里面交流,里面会不定期更新,欢迎大家的加入。