background

We all know that setTimeout and Promise are not in an asynchronous queue; the former is a MacroTask and the latter is a MicroTask.

Many articles introduce the difference between macro tasks and micro tasks, often with a similar topic like ++ I ++++ let everyone guess the execution of different tasks. This gives an accurate understanding of the execution timing of macro and micro tasks, but leaves one confused about the real differences between them.

More importantly, we shouldn’t rely on this small timing difference at all (just as we shouldn’t rely on undefined behavior in c++). Although the definition of macro task and micro task exists in the standard, different operating environments may not be able to accurately follow the standard, and promises in some scenarios are all kinds of strange polyfills.

In summary, this article does not focus on differences in execution timing, but only on performance.

asynchronous

Both macro and micro tasks are asynchronous tasks first and foremost. Asynchrony in JavaScript is implemented through event loops, taking the most common setTimeout as an example.

// Synchronize the codelet count = 1;

setTimeout(() => {// async count = 2; }, 0); // count = 3;Copy the code
Copy

An asynchronous task is thrown into a queue in an event loop, and the code is executed after the next synchronous execution (this timing is always reliable). In each event loop, the browser executes the tasks in the queue and then proceeds to the next event loop.

When the browser needs to do some rendering work, it waits for the rendering of this frame to complete before entering the next event loop

So why is there such a mechanism, and why are there microtasks, just to make people guess when different asynchronous tasks will be executed?

Why microtasks

Let’s look at an example of async function

const asyncTick = () => Promise.resolve();

(async function() {for (let i = 0; i < 10; i++) {
    await asyncTick();
  }
})()
Copy the code
Copy

We can see that there is no task waiting asynchronously, but if promise. resolve drops a task into the asynchronous queue each time as setTimeout does and waits for an event loop to execute it. It doesn’t seem like a big deal, because an “event loop” doesn’t sound fundamentally different from a for loop.

And in fact, an event loop takes much longer than a for loop. We all know that setTimeout(fn, 0) is not really executed immediately, but waits for at least 4ms (in fact, it could be 10ms) to execute.

MDN related documents

In modern browsers, setTimeout()/setInterval() calls are throttled to a minimum of once every 4 ms when successive calls are triggered due to callback nesting (where the nesting level is at least a certain depth), or after certain number of successive intervals. Note: 4 Ms is specified by the HTML5 spec and is consistent across Browsers released in 2010 and onward. Prior to (Firefox 5.0) / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms.

This means that without the concept of microtasks, we still use the mechanism of macro tasks to perform async functions (essentially promises), and the performance would be very poor.

It’s even worse for pages that are performing complex tasks (such as drawing), where the entire loop is blocked directly by the task.

Microtasks are designed to accommodate this scenario, and the biggest difference from macro tasks is that if we add a task to the task queue during a microtask, the browser will consume it all before moving on to the next loop. This is why there is a difference in timing between micro and macro tasks.

Look at an example:

// setThe Timeout versionfunction test(){
   console.log('test');
   setTimeout(test);
}
test(a); // Promise.resolve version // This will jam your tabsfunction test(){
   console.log('test');
   Promise.resolve().then(test);
}
test(a); // Sync version // This will jam your tabsfunction test(){
   console.log('test');
   test(a); }test(a);Copy the code
Copy

You’ll notice that the setTimeout version of the page still works, and that the test output on the console keeps increasing.

Resolve behaves the same as direct recursion (there are some differences, promise.resolve is still executed asynchronously), the TAB gets stuck, and the output on Chrome Devtools pops every once in a while.

I have to say that Chrome Devtools is really well optimized, in fact, there is an endless loop, JS thread is completely blocked

The performance of the Promise

Understanding the differences between macro and micro tasks helps us understand Promise performance.

In actual production, we often found that promises performed poorly in some environments, some with different container implementations, others with different versions of Polyfill implementations. In particular, some developers tend to prefer smaller polyfills, such as this one with 1.3K Star

Github.com/taylorhakes…

The default is to use setTimout to emulate promise.resolve, which we did at jsperf.com/promise-per… You can see an order of magnitude difference in performance (in fact, more complex asynchronous tasks will experience significant latency).

How to correctly simulate promise.resolve

In addition to Promise is a microtask, there are many apis that are also asynchronous tasks set through microtasks. In fact, if you know the Vue source code, you will notice that the $nextTick source code of Vue, Resolve is simulated using MutationObserver in the absence of promise.resolve.

Let’s look at a simplified version of vue. $nextTick:

const timerFunc = (cb) => {
    let counter = 1
    const observer = new MutationObserver(cb);
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}
Copy the code
Copy

The principle is as simple as manually constructing a MutationObserver and triggering changes to DOM elements that trigger asynchronous tasks.

Using this approach obviously pulls the order of magnitude back

Because the Promise itself is more volume oriented, the performance of benchmark here is still several times less than that of the original. However, in fact, bluebird and other performance-oriented implementations have the same performance as the original in the case that the timer function is constructed using MutationObserver. It’s even faster on some browsers

Of course, the actual implementation of NextTick in Vue is more subtle, such as avoiding multiple creations by reusing MutationObserver. But it’s only the difference between macro and micro tasks that makes the Promise implementation a hundredfold bigger in performance.

There are many other apis besides MutationObserver that also use microtasks, but MutationObserver is still the most widely used from a compatibility and performance perspective.

conclusion

The difference in mechanism between macro and micro tasks can cause a huge performance difference between different Promise implementations, large enough to directly affect the user’s immediate sense of motion. So again, we want to avoid the violent introduction of Promise polyfills, prioritise Native Promises on modern browsers, and avoid damaging performance degradations where polyfills are required.

Also, it really doesn’t matter which console.log executes first, because you should never rely on timing differences between macro tasks and microtasks to program.

Develop reading

  • [Video] Jake Archibald’s talk The Event Loop
  • Developer.mozilla.org/en-US/docs/…
  • Github.com/taylorhakes…
  • Jsperf.com/promise-vs-…