JavaScript中的并发模型与事件循环

86 阅读8分钟

1. JavaScript的单线程模型

JavaScript是一种单线程的语言,意味着它一次只能执行一个任务。想象一下,JavaScript就像一个工人,他只能同时做一件事情。如果他在做一个任务时,还想处理别的事情,就只能等前一个任务完成后再做。

2. 异步编程的需求

但在现代Web开发中,我们通常需要处理多个任务,比如:

  • 从服务器获取数据
  • 等待用户点击按钮
  • 定时执行某些操作

为了避免让JavaScript在等待这些操作时阻塞其他任务,JavaScript引入了“异步编程”模型,这样它可以在等待某些操作(如网络请求)时继续执行其他任务。

3. 事件循环 (Event Loop)

事件循环是JavaScript处理并发任务的机制。简单来说,事件循环帮助JavaScript管理如何执行异步任务。

JavaScript的运行机制可以分为以下几个部分:

  • 调用栈(Call Stack):调用栈是一个存放任务的地方,JavaScript会把当前正在执行的代码放在栈上。代码执行完成后,就会从栈中弹出。
  • 消息队列(Message Queue):当异步任务(如setTimeoutPromise)完成后,它的回调函数会被放入消息队列中。事件循环会不断地查看调用栈是否为空,如果为空,就从消息队列中取出回调函数执行。

4. 同步任务与异步任务

  • 同步任务:这些任务是按照顺序执行的,一个任务完成后才会执行下一个任务。例如,普通的函数调用就是同步任务。
  • 异步任务:异步任务是一些不需要立即完成的任务,例如setTimeout、网络请求、文件读取等。JavaScript不会等待这些任务完成,而是继续执行后面的代码。完成后,它们的回调函数会被放入消息队列等待执行。

5. 事件循环的工作流程

事件循环的核心思想是:JavaScript在处理完所有的同步任务后,会去执行异步任务。事件循环大致是按以下步骤工作的:

  1. 执行同步代码:首先,JavaScript执行调用栈中的所有同步任务,直到栈为空。
  2. 执行微任务:微任务包括Promise的回调函数,它们的优先级高于宏任务。当调用栈空闲时,事件循环会首先检查微任务队列,并执行所有的微任务。
  3. 执行宏任务:宏任务包括setTimeout、用户点击事件等。当微任务队列为空时,事件循环才会从宏任务队列中取出任务执行。
  4. 重复循环:事件循环会不断重复这个过程。

6. 举个例子

假设我们有以下代码:

console.log("Start");

setTimeout(() => {
  console.log("Timeout");
}, 0);

Promise.resolve().then(() => {
  console.log("Promise");
});

console.log("End");

代码执行顺序:

  1. console.log("Start")会立即执行,输出:Start
  2. setTimeout是一个异步操作,尽管设置了0毫秒的延时,但它是一个宏任务,因此回调函数会被加入到宏任务队列中。
  3. Promise.resolve().then()是微任务,它的回调函数会被加入到微任务队列中。
  4. console.log("End")会立即执行,输出:End
  5. 事件循环检查微任务队列,发现有Promise的回调任务,执行该回调,输出:Promise
  6. 最后,事件循环会去宏任务队列中取出setTimeout的回调任务,执行,输出:Timeout

最终输出:

Start
End
Promise
Timeout

7. 宏任务与微任务的区别

  • 宏任务setTimeoutsetInterval、I/O操作(如网络请求),这些任务会被放入宏任务队列。
  • 微任务PromiseMutationObserver等,这些任务的优先级高于宏任务,会先于宏任务执行。

8. 为什么要了解事件循环

理解事件循环非常重要,尤其是在处理异步操作时。它帮助我们控制异步任务的执行顺序,避免任务阻塞,提升程序的性能和响应速度。

关于 JavaScript并发模型事件循环 的面试题。


1. JavaScript中的并发模型是什么?

 JavaScript是**单线程**的编程语言,这意味着它一次只能执行一个任务。然而,为了处理异步任务(如网络请求、定时器等),JavaScript引入了**事件循环**机制,通过这种机制来模拟并发执行。

事件循环通过将异步任务分为两类(宏任务和微任务),并利用 消息队列调用栈 来逐步执行这些任务。事件循环会首先处理同步任务,然后依次处理微任务,最后处理宏任务。


2. 什么是事件循环(Event Loop)?

​ 事件循环是JavaScript处理异步操作的机制。由于JavaScript是单线程的,它无法同时执行多个操作,但通过事件循环,它可以模拟并发操作。

事件循环的工作方式如下:

  1. 同步代码会先执行。同步任务会被依次加入调用栈(Call Stack)并执行,直到调用栈为空。
  2. 微任务队列会在调用栈清空后立即执行所有微任务(如Promise的回调函数)。
  3. 宏任务队列(如setTimeout、I/O操作)会在微任务执行完毕后才会执行。

3. JavaScript中的宏任务和微任务有什么区别?

  • **宏任务(Macrotasks)**是较为重的任务,它们包括:

    • setTimeout / setInterval
    • DOM事件处理程序
    • I/O操作等

    宏任务会被放入宏任务队列(Macrotask Queue)中,等待事件循环处理。

  • **微任务(Microtasks)**是较轻的任务,它们包括:

    • Promisethen/catch/finally回调
    • MutationObserver

    微任务的优先级高于宏任务,事件循环在处理完所有同步代码后,首先会执行微任务队列中的任务,然后才会执行宏任务队列中的任务。


4. 解释一下下面这段代码的执行顺序:

console.log('Start');

setTimeout(() => {
  console.log('Timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise');
});

console.log('End');

执行顺序

  1. console.log('Start') 立即执行,输出:Start
  2. setTimeout 是一个宏任务,虽然设置了0毫秒的延时,它的回调函数会被加入到宏任务队列中。
  3. Promise.resolve().then() 是一个微任务,它的回调函数会被加入到微任务队列中。
  4. console.log('End') 立即执行,输出:End
  5. 事件循环检查微任务队列,发现有一个微任务(Promise),执行该回调,输出:Promise
  6. 最后,事件循环去宏任务队列中取出setTimeout的回调任务,执行它,输出:Timeout

最终输出顺序

Start
End
Promise
Timeout

5. 为什么JavaScript的事件循环是必要的?

​ JavaScript的事件循环机制使得它能够在单线程环境下处理异步任务。如果没有事件循环,JavaScript就无法高效地处理像用户交互、网络请求、文件读取等这些需要等待的任务。事件循环通过消息队列和微任务队列管理异步任务,让JavaScript能够在同步代码执行完后,优先执行微任务,再执行宏任务,从而避免了长时间的阻塞。


6. 在JavaScript中,setTimeoutsetImmediate 有什么区别?

  • setTimeout(callback, 0):虽然设置的时间是0毫秒,但它的回调函数会被加入到宏任务队列中。在事件循环的下一次迭代时执行。

  • setImmediate(callback):这是Node.js中的API,它的回调函数会立即被放入轮询阶段的回调队列中,优先级高于宏任务,但低于微任务。

    总结:在浏览器中没有setImmediate,只有setTimeout。在Node.js中,setImmediatesetTimeout(时间为0)都属于宏任务,但setImmediate会在当前事件循环结束后立即执行,而setTimeout会等待下一轮事件循环。


7. Promise的微任务队列与setTimeout的宏任务队列的执行顺序是什么?

Promise的回调会被放到微任务队列中,而setTimeout的回调会被放到宏任务队列中。

执行顺序

  1. 执行同步代码。
  2. 执行所有微任务(如Promise回调)。
  3. 执行宏任务(如setTimeout回调)。

即使setTimeout设置为0毫秒,Promise的回调依然会优先执行。


8. 事件循环中“调用栈”和“消息队列”的作用是什么?

  • 调用栈(Call Stack):它存储着正在执行的函数。JavaScript代码会从调用栈中逐个执行,当一个函数执行完毕时,会从栈中弹出,继续执行下一个任务。
  • 消息队列(Message Queue):存放着异步任务的回调(如setTimeoutPromisethen回调等)。当调用栈空闲时,事件循环会从消息队列中取出任务并执行。

9. 在什么情况下,JavaScript的事件循环会“阻塞”?

JavaScript的事件循环会被“阻塞”当有长时间运行的同步代码时。例如:

  • 一个大的计算循环(如大量的for循环),它会一直占用调用栈,直到执行完毕,导致消息队列中的异步任务无法得到执行。
  • 执行一个阻塞I/O操作时,JavaScript会等待I/O操作完成后才能继续执行后续的任务。

为了避免阻塞,可以通过将长时间运行的代码拆分为多个小任务,或者使用setTimeout/setImmediate等异步方法来分批执行。


10. 如何解决事件循环中的"任务阻塞"问题?

​ 可以通过以下方式避免或减轻任务阻塞:

  1. 将长时间运行的任务拆分成小任务:使用setTimeoutrequestAnimationFrame将一个大任务分解成多个小任务,这样它们会被逐个加入到消息队列中,防止阻塞主线程。
  2. 使用Web Workers:对于计算密集型的任务,可以使用Web Workers将任务放到后台线程,避免阻塞主线程。