JavaScript 是单线程
在浏览器中打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
JavaScript 引擎一次只能执行一个任务(比如执行一个函数),它不能在同一时刻处理多个任务或进程。这就是为什么它不需要像多线程编程那样涉及到线程同步的问题。尽管 JavaScript 是单线程的,但它通过事件循环和异步编程来处理多个任务。这使得它看起来能够“同时”做多个事情。
JavaScript 相关概念
栈
JavaScript 调用一个函数时,它会在调用栈中创建一个栈帧。栈帧用于存储函数执行时的上下文信息(如局部变量、函数参数等)。当函数执行完成后,它的栈帧会从调用栈中弹出,控制权返回给调用函数的地方。
队列
JavaScript 在运行时会维护一个消息队列,这个队列包含着所有待处理的任务(通常是回调函数)。每当执行一些异步操作(如 setTimeout()
、fetch()
、事件监听等),这些操作完成后,它们的回调函数会被放入这个消息队列中,等待执行。
每当调用栈为空时,事件循环会检查消息队列中是否有待处理的消息。如果有,它就会从队列中取出最先进入的消息,并将其关联的回调函数推入执行栈(调用栈)。执行栈会按照函数的调用顺序依次执行函数。
堆
对象被分配在堆中。
事件循环(Event Loop)
浏览器的事件循环
JavaScript 是单线程的,这意味着它一次只能执行一个任务,但浏览器能够在一定程度上“同时”执行多个任务的原因,实际上是利用了浏览器的多线程特性以及 JavaScript 引擎的事件循环机制来实现并发。
具体来说,浏览器将某些耗时的操作(如 I/O、网络请求、定时器等)交给浏览器的 Web API(比如 setTimeout
、fetch
等)来处理,一旦这些任务完成,它们会通过回调函数放入任务队列中。事件循环会从任务队列中取出回调并执行它们。
这样,JavaScript 线程就不需要等待这些操作完成,而是继续执行后续的代码,等到任务完成后再通过回调函数将结果传递给 JavaScript 执行。
微任务和宏任务
任务队列又可以分为微任务队列和宏任务队列。
这两者的分离主要是为了优化 JavaScript 的执行效率和响应性。
微任务(如
Promise
)是一些非常快速的操作,优先执行它们可以保证快速响应用户的操作和系统的变化。而宏任务(如定时器、事件等)通常代表较为耗时的任务,按顺序执行它们则可以避免阻塞短小的任务或过多的 UI 更新。
微任务(microtask)和宏任务(macrotask)是 JavaScript 中任务队列的两种不同类型,它们分别对应着不同优先级的任务。
-
宏任务:宏任务通常是比较“大的”任务,涉及到浏览器的核心操作和一些时间间隔较长的任务。每当执行栈为空时,事件循环会从宏任务队列中取出一个任务并执行。
例子:定时器(setTimeout、setInterval),网络请求,DOM 事件,I/O 操作
-
微任务:微任务是比宏任务优先级更高的任务。微任务通常在当前执行栈中的任务完成后立即执行,但会优先于宏任务执行。微任务的优先级比宏任务高,它们会在事件循环的每一轮结束前,先执行完所有微任务,这意味着如果在宏任务执行完之前,有新的微任务加入队列,它们会在当前宏任务之后立即执行。
例子:Promise的
.then()
或.catch()
回调、MutationObserver
方面 | 微任务(microtask) | 宏任务(macrotask) |
---|---|---|
执行顺序 | 在当前执行栈中的任务执行完之后,优先于宏任务执行 | 在所有微任务执行完之后,才会执行 |
例子 | Promise.then() , catch() , MutationObserver | setTimeout() , setInterval() , DOM 事件 |
执行时机 | 在每一轮事件循环中,微任务队列先被清空 | 在每一轮事件循环中,宏任务队列中的任务执行 |
是否有延迟 | 没有延迟,微任务会紧跟着同步代码执行 | 宏任务通常会有延迟,因为它们需要等待上一轮的所有微任务执行完 |
案例
执行顺序:
-
console.log('script start');
这是一条同步任务,直接输出
script start
。 -
async1();
调用
async1()
函数,async1
函数会执行。在
async1
中,await async2()
会触发async2()
函数。async2
里的console.log('async2 end')
会 同步 执行,打印async2 end
。然后,await
会使得async1
函数在async2
执行完后继续,但此时async1
的剩余部分会被放入微任务队列 中,等待当前执行栈为空时执行。 -
setTimeout(function(){console.log('setTimeout')},0);
setTimeout
是宏任务,它的回调会被放入宏任务队列,并且会等到当前的所有同步代码和微任务都执行完后才会被执行。 -
new Promise(resolve => {…}).then(…).then(…);
在执行
new Promise()
的时候,console.log('Promise')
是同步代码,会立即执行。resolve()
会触发then()
中的回调函数,但这些回调会被放入微任务队列中,等待当前执行栈中的所有同步代码执行完之后再执行。 -
console.log('script end');
这条语句是同步代码,直接执行,打印
script end
。
输出顺序:
- script start
- async2 end
- Promise
- script end
- async1 end
- promise1
- promise2
- setTimeout
Node事件循环
Node.js 和浏览器中的事件循环的区别在于它们的执行环境、任务队列和优先级等方面。
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
Node.js 中的事件循环分为6个阶段:
- Timers:执行
setTimeout()
和setInterval()
的回调。 - I/O callbacks:执行几乎所有的 I/O 回调。
- Idle, prepare:内部用于准备和处理。
- Poll:执行 I/O 事件的回调,判断是否需要阻塞,或者执行一个回调。
- Check:执行
setImmediate()
的回调。 - Close callbacks:处理如
socket.on('close', ...)
之类的回调。