What was the motivation for introducing a separate microtask queue which the event loop prioritises over the task queue?
Asked Answered
V

3

13

My understanding of how asynchronous tasks are scheduled in JS

Please do correct me if I'm wrong about anything:

The JS runtime engine agents are driven by an event loop, which collects any user and other events, enqueuing tasks to handle each callback.

The event loop runs continuously and has the following thought process:

  • Is the execution context stack (commonly referred to as the call stack) empty?
  • If it is, then insert any microtasks in the microtask queue (or job queue) into the call stack. Keep doing this until the microtask queue is empty.
  • If microtask queue is empty, then insert the oldest task from the task queue (or callback queue) into the call stack

So there are two key differences b/w how tasks and microtasks are handled:

  • Microtasks (e.g. promises use microtask queue to run their callbacks) are prioritised over tasks (e.g. callbacks from othe web APIs such as setTimeout)
  • Additionally, all microtasks are completed before any other event handling or rendering or any other task takes place. Thus, the application environment is basically the same between microtasks.

Promises were introduced in ES6 2015. I assume the microtask queue was also introduced in ES6.

My question

What was the motivation for introducing the microtask queue? Why not just keep using the task queue for promises as well?

Update #1 - I'm looking for a definite historical reason(s) for this change to the spec - i.e. what was the problem it was designed to solve, rather than an opinionated answer about the benefits of the microtask queue.

References:

Vomitory answered 13/2, 2021 at 22:30 Comment(3)
I guess "the application environment is basically the same between microtasks" nails it. In general, it allows promise code that chains synchronous things (Promise.resolve(1).then(x => x+1).then(console.log)) to run at once, without being interrupted by larger tasks like handling an event. It could as well have been done with a single loop serving multiple queues and clear priority rules.Hyperventilation
The historical reason to introduce this was to make it part of the ECMAScript spec, whereas the event loop is a feature defined by the embedder (and in the case of HTML, specified by the WHATWG)Hyperventilation
@Hyperventilation - I'd suggest you write an answer.Practically
B
9

Promises were introduced in ES6 2015. I assume the microtask queue was also introduced in ES6.

Actually the microtask task queue was not introduced by ECMAScript standards at all: the ES6 standard specified putting promise handling jobs for a settled promise in a queue named "PromiseJobs" under TriggerPromiseReactions, using the abstract process EnqueueJob to enter the job in a job queue implemented by the host environment, without prescribing how the host queue should be handled.

Prior to adoption by ECMAScript

Promise libraries were developed in user land. The bit of code that executed promise handlers, monitored if they threw or returned a value and had access to the resolve and reject functions of the next promise in a promise chain was called the "trampoline". While part of the Promise library, the trampoline was not considered part of user code and the claim of calling promise handlers with a clean stack excluded stack space occupied by the trampoline.

Settlement of a promises with a list of handlers to call for the settled status (fulfilled or rejected) required starting the trampoline to run promise jobs if it were not already running.

The means of starting trampoline execution with an empty stack was limited to existing Browser APIs including setTimeout, setImmediate and the Mutation Observer API. The Mutation Observer uses the microtask queue and may be the reason for its introduction (not sure of the exact browser history).

Of the event loop interfacing possibilities, setImmediate was never implemented by Mozilla at least, Mutation Observers were available in IE11 according to MDN, and setTimeout under some circumstances would be throttled so it would take at least some milliseconds to execute a callback even if the delay time were set to zero.

Developer Competition

To an outside observer promise library developers competed with each other to see who could come up with the fastest time to begin executing a promise handler after promise settlement.

This saw the introduction of setImmediate polyfills which picked the fastest strategy of starting a callback to the trampoline from the event loop depending on what was available in the browser. YuzuJS / setImmediate on GitHub is a prime example of such a polyfill and its readme well worth reading.

History After adoption in ECMAScript 2015

Promises were included in ES6 without specifying the priority host implementations should give to promise jobs.

The author of the YuzuJS/setImmediate polyfill above also made a submission to the TC39 committee to specify that promise jobs should be given high priority in ECMAScript. The submission was ultimately rejected as an implementation issue not belonging to the language standard. Arguments supporting the submission are unavailable on TC39's tracking site given it doesn't reference rejected proposals.

Subsequently the HTML5 specification introduced rules for Promise implementation in browsers. The section on how to implement ECMAScipt's EnqueueJob abstract operation in host browsers specifies that they go in the microtask queue.


Answer

What was the motivation for introducing the microtask queue? Why not just keep using the task queue for promises as well?

  • The micro task queue was introduced to support Mutation Observer Events, as detailed by Jake Archibald at JSConf Asia 2018 ( 24:07)1 during his "In the loop" presentation.

  • Early developers of promise libraries found ways to enter jobs in the micro task queue and in doing so minimized the time between settling promises and running their promise reaction jobs. To some extent this created competition between developers but also facilitated continuing asynchronous program operation as soon as possible after handling completion of one step in a sequence of asynchronous operations.

  • By design fulfillment and rejection handlers can be added to promises that have already been settled. If such cases there is no need to wait for something to happen before proceeding to the next step of a promise chain. Using the microtask queue here means the next promise handler is executed asynchronously, with a clean stack, more or less immediately.

Ultimately the decision to specify the microtask queue was made by prominent developers and corporations based on their expert opinion. While that may be excellent choice, the absolute necessity of doing so is moot.


See also Using microtasks in JavaScript with queueMicrotask() on MDN.


1 Thanks to @Minh Nghĩa 's comment for the link to Jake Archibald's "In the Loop" (0:00) talk - ☆☆☆☆☆. Highlights include

  • An event loop executes one task from the task queue at a time, all tasks in the animation queue except for tasks added while executing the queue, and all tasks in the microtask queue until it's empty.
  • Dependence on tricky execution order of event handlers and promise callbacks can cause unit testing failures because events dispatched programatically execute event handlers synchronously, not via the event loop.
Bilberry answered 26/2, 2021 at 13:1 Comment(10)
Excellent writeup! Too bad we cannot find that rejected proposal. I thought a presentation should at least be recorded in the meeting notes, even if the proposal is no longer listed anywhere. (And given the time, it might not have had a repository)Hyperventilation
any chance that queueMicrotask() was originally introduced as global.asap for enqueuing a microtask (Domenic and Brian) in archives.ecma-international.org/2014/TC39/tc39-2014-051.pdf ?Tartu
@Tartu The link references discussion of the possibility of including an operation in the ECMAScript specification to execute something as soon as possible. The terms 'asap' and 'microtask' are not in (e.g.) ECMAScript 2019 so I assume the idea was abandoned. More likely queueMicrotask was introduced in HTML Standards to expose functionality akin to that of 'setImmediate' without including setImmediate as a standardized timer in the spec, presumably by the whatwg group rather than the W3C.Bilberry
Very well-researched answer! But just a further question... why did the Mutation Observer Events/API use a separate queue instead of the main task queue?Blinnie
@Blinnie I have no information concerning the connection between the introduction of the microtask queue and that of the Mutation Observer API - my initial research was conducted some years ago while writing a Promise polyfill to learn how they worked, when promise documentation was lamentable.Bilberry
@Blinnie See this Jake Archibald talk, he explained clearly about the history of MO: youtu.be/cCOL7MC4Pl0?t=1447Pincas
Hi I wanna dig this up a lil bit. I read, in several places, that one of the reason is the difference between a "potentially async" and "definitely async" tasks. However, I can't find these comments/articles. Does anyone happen to know about this?Pincas
@MinhNghĩa Do you have a specific context or link to the source of such rumors? A) it doesn't sound like something that would affect Promise Job queue processing (in the microtask queue) which is not impacted by how promise settlement came about, and B) not all promise documentation and blogging is of equally high quality - I have yet to read any starting with "promises are an interface to event loop task management". FYI you may be interested the links put in this anwser I wrote to a more recent question about the event loop.Bilberry
@Bilberry Today I digged up the browser history and found those comments. Turn out it was about the wrong subject: This question is about whether a micro or maco task to queue. Stuff I read is about whether to run then() immediately or queue a task if the Promise is already resolved.Pincas
Hmm, calling then on a promise that is settled will synchronously queue a job to run a supplied handler (if it's applicable to the settled state) for you. If the promise is not settled, then, catch or finally will store their supplied handlers and what they're for in lists(s) held by the promise objects internally - they're not in a queue or held somewhere else. If these promises are "fullfilled" or "rejected" later, jobs are created to run applicable handler previously saved, in the order they were added, Not sure why adding extra logic might be needed...Bilberry
H
1

One advantage is fewer possible differences in observable behavior between implementations.

If these queues weren't categorized, then there would be undefined behavior when determining how to order a setTimeout(..., 0) callback vs. a promise.then(...) callback strictly according to the specification.

I would argue that the choice of categorizing these queues into microtasks and "macro" tasks decreases the kinds of bugs possible due to race conditions in asynchronicity.

This benefit appeals particularly to JavaScript library developers, whose goal is generally to produce highly optimized code while maintaining consistent observable behavior across engines.

Hooded answered 13/2, 2021 at 22:49 Comment(0)
K
-1

I found this (https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) relatively old blog post which explains it really well, and it also gives some examples on old browser versions.

Mainly, this separation is use to

  1. Improve browser performance
  2. Comply with the ECMAScript standard of execution order
  3. Separate HTML related tasks and 'job'/micro tasks related work.

I think this behavior is needed to support workers on the browser. A worker doesn't have access to the DOM, so they had to come up with a new mechanism for that as well.

Koph answered 23/2, 2021 at 23:52 Comment(3)
No, microtasks have nothing to do with workers. A worker has its own event loop as well, it works exactly like the main thread in that regard.Hyperventilation
I know it has it's own event loop, but it doesn't have access to the runtime environment tasks (DOM related events, timeouts and such). How ever it can perform async code. To support this on the runtime environment level implementation (browser, or nodejs), it makes more sense to have a separate queueu which is not tight to the runtime env tasks queue.Koph
Not sure why you refer to that as "the runtime env tasks queue". Both the main thread and worker threads have their own event loops with task queues, and while only the main thread has a queue for DOM events, all of them have their own queues for message communication, timers, and network events to enable asynchronous event handling. Adding a microtask queue to all of them is not what enables worker threads.Hyperventilation

© 2022 - 2024 — McMap. All rights reserved.