JavaScript 是单线程

在浏览器中打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

JavaScript 引擎一次只能执行一个任务(比如执行一个函数),它不能在同一时刻处理多个任务或进程。这就是为什么它不需要像多线程编程那样涉及到线程同步的问题。尽管 JavaScript 是单线程的,但它通过事件循环和异步编程来处理多个任务。这使得它看起来能够“同时”做多个事情。

JavaScript 相关概念

JavaScript 调用一个函数时,它会在调用栈中创建一个栈帧。栈帧用于存储函数执行时的上下文信息(如局部变量、函数参数等)。当函数执行完成后,它的栈帧会从调用栈中弹出,控制权返回给调用函数的地方。

队列

JavaScript 在运行时会维护一个消息队列,这个队列包含着所有待处理的任务(通常是回调函数)。每当执行一些异步操作(如 setTimeout()fetch()、事件监听等),这些操作完成后,它们的回调函数会被放入这个消息队列中,等待执行。

每当调用栈为空时,事件循环会检查消息队列中是否有待处理的消息。如果有,它就会从队列中取出最先进入的消息,并将其关联的回调函数推入执行栈(调用栈)。执行栈会按照函数的调用顺序依次执行函数。

对象被分配在堆中。

事件循环(Event Loop)

浏览器的事件循环

JavaScript 是单线程的,这意味着它一次只能执行一个任务,但浏览器能够在一定程度上“同时”执行多个任务的原因,实际上是利用了浏览器的多线程特性以及 JavaScript 引擎的事件循环机制来实现并发。

具体来说,浏览器将某些耗时的操作(如 I/O、网络请求、定时器等)交给浏览器的 Web API(比如 setTimeoutfetch 等)来处理,一旦这些任务完成,它们会通过回调函数放入任务队列中。事件循环会从任务队列中取出回调并执行它们。

这样,JavaScript 线程就不需要等待这些操作完成,而是继续执行后续的代码,等到任务完成后再通过回调函数将结果传递给 JavaScript 执行。

微任务和宏任务

任务队列又可以分为微任务队列和宏任务队列。

这两者的分离主要是为了优化 JavaScript 的执行效率和响应性。

微任务(如 Promise)是一些非常快速的操作,优先执行它们可以保证快速响应用户的操作和系统的变化。而宏任务(如定时器、事件等)通常代表较为耗时的任务,按顺序执行它们则可以避免阻塞短小的任务或过多的 UI 更新。

微任务(microtask)和宏任务(macrotask)是 JavaScript 中任务队列的两种不同类型,它们分别对应着不同优先级的任务。

  • 宏任务:宏任务通常是比较“大的”任务,涉及到浏览器的核心操作和一些时间间隔较长的任务。每当执行栈为空时,事件循环会从宏任务队列中取出一个任务并执行。

    例子:定时器(setTimeout、setInterval),网络请求,DOM 事件,I/O 操作

  • 微任务:微任务是比宏任务优先级更高的任务。微任务通常在当前执行栈中的任务完成后立即执行,但会优先于宏任务执行。微任务的优先级比宏任务高,它们会在事件循环的每一轮结束前,先执行完所有微任务,这意味着如果在宏任务执行完之前,有新的微任务加入队列,它们会在当前宏任务之后立即执行。

    例子:Promise的 .then().catch() 回调、MutationObserver

方面微任务(microtask)宏任务(macrotask)
执行顺序在当前执行栈中的任务执行完之后,优先于宏任务执行在所有微任务执行完之后,才会执行
例子Promise.then(), catch(), MutationObserversetTimeout(), setInterval(), DOM 事件
执行时机在每一轮事件循环中,微任务队列先被清空在每一轮事件循环中,宏任务队列中的任务执行
是否有延迟没有延迟,微任务会紧跟着同步代码执行宏任务通常会有延迟,因为它们需要等待上一轮的所有微任务执行完

案例

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');

执行顺序:

  1. console.log('script start');

    这是一条同步任务,直接输出 script start

  2. async1();

    调用 async1() 函数,async1 函数会执行。

    async1 中,await async2() 会触发 async2() 函数。async2 里的 console.log('async2 end')同步 执行,打印 async2 end。然后,await 会使得 async1 函数在 async2 执行完后继续,但此时 async1 的剩余部分会被放入微任务队列 中,等待当前执行栈为空时执行。

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

    setTimeout 是宏任务,它的回调会被放入宏任务队列,并且会等到当前的所有同步代码和微任务都执行完后才会被执行。

  4. new Promise(resolve => {…}).then(…).then(…);

    在执行 new Promise() 的时候,console.log('Promise')同步代码,会立即执行。resolve() 会触发 then() 中的回调函数,但这些回调会被放入微任务队列中,等待当前执行栈中的所有同步代码执行完之后再执行。

  5. console.log('script end');

    这条语句是同步代码,直接执行,打印 script end

输出顺序:

  1. script start
  2. async2 end
  3. Promise
  4. script end
  5. async1 end
  6. promise1
  7. promise2
  8. setTimeout

Node事件循环

Node.js 和浏览器中的事件循环的区别在于它们的执行环境、任务队列和优先级等方面。

Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

Node.js 中的事件循环分为6个阶段:

  1. Timers:执行 setTimeout()setInterval() 的回调。
  2. I/O callbacks:执行几乎所有的 I/O 回调。
  3. Idle, prepare:内部用于准备和处理。
  4. Poll:执行 I/O 事件的回调,判断是否需要阻塞,或者执行一个回调。
  5. Check:执行 setImmediate() 的回调。
  6. Close callbacks:处理如 socket.on('close', ...) 之类的回调。

相关资料