JavaScript中的TaskQueue,Macrotask 与 Microtask

通常,JavaScript会通过使用事件机制或timer的方式以达到在特定事件或时间调度执行某段代码(块),这种类型的异步通常在JavaScript中称为Event loop(事件循环)机制。本文,我们将探讨事件循环机制的工作原理,并演示其任务队列的执行过程。

JavaScript中的Event Loop(事件循环)与Call Stack(调用栈)

由于是单线程的,JS使用事件循环的概念来创建异步运行多个任务(我们的JS代码只运行在一个线程中,而JS使用事件循环异步运行代码)。我们将listener(监听器)附加到事件上,因此无论何时触发事件,附加到该事件的回调代码都会被执行。在进一步深入之前,让我们先了解一下JavaScript引擎是如何工作的。

JavaScript引擎由Stack(栈),Heap(堆)及Task Queue(任务队列)组成。

  • Stack

Stack是一个类似数组的结构,用于跟踪当前正在执行的函数。

function m() {
    a()
    b()
}
m()

我们有一个m函数,该函数在函数体中调用了a函数和b函数。在开始执行时,内存中m函数的地址将被压入call stack(调用栈)。同样,JavaScript引擎在执行a函数和b函数前也会将它们的地址压入调用栈。

先等等,你是否会有这样的疑问:为什么JavaScript引擎要存储函数地址及其参数到调用栈呢?

在底层(以x86汇编语言为例),CPU会利用EAX,EBX,ECX,ESP,EIPregisters(寄存器)来临时存储变量并运行我们的程序(前提是已经加载到内存中)。其中EAXEBX用于计算,ECX用于counter job(计数器作业,如存储for循环的次数)。ESP(堆栈指针)保存堆栈的当前地址,EIP(指令指针)保存要执行的程序的当前地址。

RAM                 EIP = 10
0 |     |           ESP = 21
1 |a(){}|
2 |     |             Call Stack
3 |b(){}|             14|   |
4 |     |             15|   |
5 |     |             16|   |
6 |m(){ |             17|   |
7 | a() |             18|   |
8 | b() |             19|   |
9 |}    |             20|   |
10|m()  |             21|   | 

以上是我们的程序运行时在内存中表示的草图,我们看到程序被加载,然后是调用堆栈、ESP和EIP。程序的入口是m(),这就是为什么EIP是10(语句在内存中的位置)。在执行期间,CPU通过查看EIP来知道从哪里开始执行。

每当调用函数时,执行都会跳转到内存中的函数并从那里执行。 然后,在函数运行完成时,必须从其跳转的位置继续上一个函数,因此返回地址必须保存,调用栈就是用于解决这个问题的。 在每个函数调用中,EIP中的当前值都被推送到调用堆栈中。 在我们的示例中,当调用a()时,我们的调用堆栈如下所示:

RAM                   EIP = 1
  0 |     |           ESP = 19
➥ 1 |a(){}|
  2 |     |             Call Stack
  3 |b(){}|             14|   |
  4 |     |             15|   |
  5 |     |             16|   |
  6 |m(){ |             17|   |
  7 | a() |             18|   |
  8 | b() |             19|   |
  9 |}    |             20| 7 |
  10|m()  |             21| 10| 

a()执行完成后,调用栈会将栈顶的7弹出并赋值给EIP,来达到告知CPU继续执行内存地址为7之后的指令。

为什么函数执行参数也要推入调用栈?其实在执行带参数的函数时,该函数使用EBP寄存器从堆栈中获取值,这些值就是它的参数。因此,在调用方函数调用一个函数之前,它必须首先推送被调用方函数要访问的参数,然后推送EIP和ESP地址。

  • Heap

通常在new一个Objects(对象)时,会将新创建的对象分配到堆中。

const apple = new Fruit('apple', 'very_tasty');

上述代码将在堆上创建一个Fruit对象,并将地址返回给apple变量。由于堆本质上不是有序的,因此操作系统必须找到一种方法来实现内存管理,以防止内存泄漏。

  • Task Queue

后续由JavaScript引擎处理的任务会进入任务队列。事件循环机制会不断检查调用栈是否为空,为空的话会继续执行任务队列中排队的所有回调。

Microtask(微任务)与Macrotask(宏任务)

上面我们探讨了JavaScript引擎的工作方式以及任务队列的基本作用。当我们进一步探讨任务队列时,发现 任务进一步被细分为微任务宏任务

事件循环的每次循环:

while (eventLoop.waitForTask()) {
  eventLoop.processNextTask()
}

每次循环中只处理一个宏任务(任务队列是宏任务队列), 完成此操作后,将在同一循环内处理微任务队列中入队的所有微任务。 这些微任务可以入队其他微任务,这些微任务将一直运行直到微任务队列为空。

while (eventLoop.waitForTask()) {
  const taskQueue = eventLoop.selectTaskQueue()
  if (taskQueue.hasNextTask()) {
    taskQueue.processNextTask()
  }

  const microtaskQueue = eventLoop.microTaskQueue
  while (microtaskQueue.hasNextMicrotask()) {
    microtaskQueue.processNextMicrotask()
  }
}

举个实际栗子以更好帮助我们了解微任务及宏任务的处理机制:

// example.js
console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

当我们运行上述代码时,将会得到下述结果:

script start
script end
promise1
promise2
setTimeout

PS:宏任务可通过setTimeoutsetIntervalsetImmediateevent等方式入队;微任务通过process.nextTickPromisesMutationObserver等方式入队。

上面提到每次事件循环都会先处理一个宏任务,之后再处理微任务队列中的所有微任务。因此你也许会觉得setTimeout应该先于promise1与promise2打印,毕竟似乎没有其他宏任务先于setTimeout产生的宏任务入队?

JavaScript是事件驱动的,因此除非产生了事件,没有代码会在JavaScript引擎中运行。其实在执行任何JS文件前,JavaScript引擎都会将其中的内容包装进一个函数,并且将该函数绑定了startlaunch事件。然后JavaScript引擎会主动触发start事件,该事件的回调函数(其实就是包装代码的函数)将会被加入到宏任务队列中。

总结

  • 任务从任务队列中获取
  • 从任务队列中获取的任务是宏任务,而不是微任务
  • 微任务在当前任务结束时进行处理,在下一个宏任务处理前会处理完微任务队列中所以微任务。
  • 微任务可以入队其他微任务,但微任务队列中所有的微任务都会在下一轮宏任务开始前执行完。
  • UI渲染运行在所有微任务执行完成后。

参考资料

《JavaScript中的TaskQueue,Macrotask 与 Microtask》上有1条评论

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注