Tasks, microtasks, queues and plans

I ’m bringing to your attention a translation of the article “Tasks, microtasks, queues and schedules” by Jake Achibald, who holds the position of Developer Advocate for Google Chrome.

When I told my colleague Matt Gant that I was thinking of writing an article about the sequence of microtasks and the order of their execution inside the browser event cycle, he said, “Jake, I'll be honest, I won’t read about it.” Well, I still wrote, so sit back and let's figure it out together, okay?

In fact, if it will be easier for you to watch the video, there is a wonderful performance by Philip Roberts at JSConf, which talks about the event cycle - it does not cover microtasks, but otherwise is an excellent introduction to the topic. In any case, they drove ...

Let's look at the following JavaScript code:
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');

What do you think, in what order should the logs be displayed?

The correct answer: script start, script end, promise1, promise2and setTimeout, but for the time being in order in different browsers often different.

Microsoft Edge, Firefox 40, iOS Safari and desktop Safari 8.0.8 log setTimeoutin front of promise1and promise2. Which is really strange, because Firefox 39 and Safari 8.0.7 worked correctly.

Why it happens


For a more accurate understanding of the process, you must first imagine how the event cycle processes tasks and microtasks. For the first time, this may seem too complicated. Deep breath ...

Each “stream” has its own event loop, and therefore each web worker, so that they can be executed independently, while all windows from the same domain (according to the same origin rule) share the same event loop, because they can simultaneously communicate with each other. The event loop runs continuously, completing the queued tasks. Tasks are performed sequentially and cannot overlap. Okay, don’t leave ...

Tasks are planned in such a way that the browser can step out of their jungle to the ground of JavaScript / DOM and be sure that these actions occur in turn. Handling the mouse click event callback requires task scheduling, as does HTML parsing setTimeoutfrom the example above.

setTimeoutwaiting for a given delay and then planning a new task for his callback. Therefore, it setTimeoutis displayed in the log after script end, since logging script endis part of the first task, and the output of the word setTimeoutis the second. Be patient, we are almost there, the most interesting is ahead ...

Micro-tasks are usually planned for things that should be executed immediately after the current executable script. For example, responding to a bunch of actions or in order to do something asynchronously without having to lose performance from scratch due to a completely new task. A microtask queue is deployed at the end of each complete task, as well as after the callbacks if no other JavaScript is being executed. Any additional microtasks that are queued during deployment of the microtask queue are added to the end of the queue and are also processed. Micro-tasks include Mutation observer and Promises, as in the example above.

As soon as a promise is resolved, or if it has already been resolved, it queues the microtask to execute the callback. This gives confidence that promise calls are executed asynchronously even if they are already resolved. So, the challenge .then(func)at the resolved promise immediately puts the microtask in the queue. That is why it is promise1also promise2output to the log after script end, because the current executable script must end before microtasks begin to be processed. promise1and promise2are displayed in the log before setTimeoutbecause micro-tasks are always deployed to the next big task.

Note translator: in this place, the author has in the original inserted a great visual presentation of the work of the JavaScript planner, however, I hardly have the technical ability to repeat this on Habré, for this I send the curious reader to the original page.

Yes, I really made a step-by-step animated diagram. How did you spend your Saturday, probably walked somewhere in the fresh air with friends? Well, I do not. In case something is not clear in my awesome UI, try clicking the left and right arrows.

What is wrong in some browsers?


They take in the journal script start, script end, setTimeout, promise1and promise2. Callbacks of promises are executed after these setTimeout. It seems that for promis callbacks a whole separate task is set up instead of a simple microtask. Such behavior can lead to performance problems when using promises, because callbacks can be undeservedly delayed until rendering and other things that are related to a big task. Here are the applications for fixing the anomaly in Edge and Firefox (translator's note: at the time of writing the translation in the application for Firefox, it turned out that only the 40th and 41st versions suffer from unexpected behavior, but starting from the 42nd anomaly does not play). The nightly builds of WebKit behave as expected, so I assume that soon Safari will return to the righteous path again.

How to understand when tasks are used, and when - microtasks


Although in this way we make the assumption that the implementation is correct, the only way is to test. See the log output order for promises and setTimeout.

The exact way is to look at the specification. For example, it шаг 14 setTimeoutqueues a task, while in the specification for fixing a mutation, step 5 creates a microtask.

In the ECMAScript world, microtasks are called jobs. On шаге 8.a спецификации PerformPromiseThento queue the microtask EnqueueJob. Unfortunately, for the time being there is no explicit relationship between jobs and microtasks, however , one of the es-discuss mailing lists mentioned that they should use a common queue.

Now let's take a look at a more comprehensive example.In the hall, someone will cry embarrassedly, “No, they are not ready!” Do not pay attention, you are ready.

Level 1: Boss Fight


The following task might seem complicated to me before I wrote this post. Here is a small piece of HTML:

Logically, what will the following JavaScript code output to the log if I click div.inner?
// Придержим ссылки на эти элементы
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
// Послушаем изменения атрибутов внешнего
// элемента с классом outer
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});
// А вот и колбек…
function onClick() {
  console.log('click');
  setTimeout(function() {
    console.log('timeout');
  }, 0);
  Promise.resolve().then(function() {
    console.log('promise');
  });
  outer.setAttribute('data-random', Math.random());
}
// …который мы повесим на оба элемента
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

Try to think before you go to the answer. Hint: logs can be displayed more than once.

Test


Note translator: the author at this point in the blog has an interactive DOM element (direct link) on which you can personally verify the behavior of your browser.

You thought it would be different? I hasten to reassure you, perhaps you were right. Unfortunately, different browsers have different degrees of acceptance of this opinion:
  • click
  • promise
  • mutate
  • click
  • promise
  • mutate
  • timeout
  • timeout

  • click
  • mutate
  • click
  • mutate
  • timeout
  • promise
  • promise
  • timeout
  • click
  • mutate
  • click
  • mutate
  • promise
  • promise
  • timeout
  • timeout
  • click
  • click
  • mutate
  • timeout
  • promise
  • timeout
  • promise

Who is right?


Handling the click event is a task. The Mutation observer and Promise callbacks are queued as microtasks. Kolbek setTimeoutis a task. (Note of the translator: here again, an interactive diagram explaining step by step the principle of operation of the code given earlier, I recommend

taking a look.) So Chrome behaves correctly. For me, the news was to find out that the micro-tasks are deployed after the callbacks (unless this is part of the execution of another JavaScript script), I thought that their deployment was limited only by the completion of the task. This rule is described in the HTML callback call specification:
If the stack of script settings objects is now empty, perform a microtask checkpoint
- HTML: Cleaning up after a callback , step 3
... and the microtask checkpoint means nothing more than expanding the microtask queue, unless we are already deploying the microtask queue. And here is what the ECMAScript specification about jobs (“jobs”) tells us:
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty ...
- ECMAScript: Jobs and Job Queues
... although “can be” in the context of HTML is “must be”, i.e. "must".

What did browsers misunderstand?


Firefox and Safari correctly emptying the microtask queue between click handlers, as seen by mutation callbacks, but promises are queued differently. This could be forgiven, especially given the nebulous connection between the job ("jobs") and the microtask, but I expected that they would be executed between the handlers. Application for Firefox. Application for Safari.

We already realized that Edge queues promises incorrectly, but it also did not empty the queue of microtasks between click handlers, instead the queue was deployed only after calling all handlers, which explains the only conclusion mutateafter both clickin the log. This is mistake.

Level 1 Boss Evil Brother


Pancake! But what if we add to the previous example:
inner.click();

The event will begin to be processed in the same way as before, but by means of a call from a script, and not from real user interaction.

Test


Note translator: in the original there is another interactive platform where you can click a button and find out the correct answer for your browser (direct link).
  • click
  • click
  • promise
  • mutate
  • promise
  • timeout
  • timeout
  • click
  • click
  • mutate
  • timeout
  • promise
  • promise
  • timeout
  • click
  • click
  • mutate
  • promise
  • promise
  • timeout
  • timeout
  • click
  • click
  • mutate
  • timeout
  • promise
  • timeout
  • promise
And I do not stop getting various results in Chrome, I have updated this table a hundred times already thinking that before that I mistakenly checked in Canary. If you have other results in Chrome, tell me in the comments on which version you have.

Why is it different now?


Note translator: in this place, one more last time, the author gives us the opportunity to enjoy the visualization of the wonders of engineering of browser builders (link, again, direct).

Thus, the correct procedure is as follows: click, click, promise, mutate, promise, timeoutand the last timeout, it seems, is that Chrome is working correctly.

After each of the click handlers is called ...
If the stack of script settings objects is now empty, perform a microtask checkpoint
- HTML: Cleaning up after a callback , step 3
Previously, this meant that microtasks would be executed between click handlers, however the explicit one .click()happens synchronously, so the script that called .click()between the click handlers will still be on the stack. The above rule confirms that microtasks do not interrupt the execution of JavaScript code. This means that the microtask queue will not be deployed until all handlers are completed; the queue before microtasks will reach only after all event handlers.

Is that really important?


Still, it will eat you from the inside (UV). I came across this when I tried to create a concise wrapper over IndexedDB using promises instead of the awful IDBRequest objects. With it, IDB almost pleased me .

When a success event is triggered in the IDB, the transaction object becomes inactive after the transfer of control (step 4). If I create a promise that is resolved during the initiation of this event, the handlers should execute before step 4 while the transaction is still active, but this does not happen in any browser other than Chrome, which makes the library seem to be useless.

In Firefox, this can be dealt with, because polyphiles of promises, such as es6-promise, use Mutation observers for callbacks, which are nothing more than microtasks. Safari enters the race state with this fix, but the problem is most likely with their broken IDB implementation . Unfortunately, IE / Edge cannot be fixed right now, since mutation events do not occur after the callbacks.

One can only hope that in this matter we will someday be able to observe interchangeability.

We did it!


Finally:
  • Tasks are executed in order and the browser can render in between
  • Micro tasks are executed in order and are executed:
    • after each callback, unless that is part of the execution of some other script
    • at the end of each task

I hope that after reading everything, it has become easier for you to think in terms of the event cycle; at least there was another reason to relax.

Has anyone stayed here? Hello ?! Hello?

Also popular now: