javascriptmultithreadingweb-workerevent-looptask-queue

Why is the worker's onmessage executing after a macro task?


Event loop execution order

I'm studying how the Javascript event loop works for a future presentation, but I've come across an unexpected behavior with the information I have so far. Simply put, when I post a message to the worker, the execution of postmessage always occurs after the setTimeout..

script.js:

new Worker('worker.js').postMessage('');

setTimeout( () => {
    const now = new Date().toISOString();
    console.log(`setTimeout execution: ${now}`);
}, 0);

worker.js:

onmessage = function (event) {
    const now = new Date().toISOString();
    console.log(`Worker execution: ${now}`); 
}

If you want to try it in the console by yourself:

const workerCode = `
self.onmessage = function (event) {
  const now = new Date().toISOString();
  self.postMessage("worker executed:     " + now);
}`;

const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerURL = URL.createObjectURL(blob);

const worker = new Worker(workerURL);

worker.onmessage = function (event) {console.log(event.data)};
worker.postMessage('');


setTimeout( () => {
    const now = new Date().toISOString();
    console.log(`setTimeout executed: ${now}`);
}, 0);

My question

Correct me if I'm wrong, but the event loop works in the following order:

  1. Execute all tasks in the call stack
  2. Execute all tasks in the micro task
  3. Execute a task in the macro task
  4. Render graphic/UI

If this is true, knowing that setTimeout is placed in the macro tasks queue, which is literally the last queue to be read, why is the postmessage executed after setTimeout? If during synchronous execution, it is placed in the queue (whatever that queue may be) before setTimeout?

What have I already tried?

I tried to put a delay, but this keeps happening even if setTimeout is delayed, so i believe the cause of this is not the fact that the worker is running in a parallel thread to the main one. Example:

setTimeout( () => {
    const start = performance.now();
    while (performance.now() - start < 1000) { };
    const now = new Date().toISOString();
    console.log(`Execution setTimeout: ${now}`);
}, 0);

Solution

  • Correct me if I'm wrong, but the event loop works in the following order:

    1. Execute all tasks in the call stack
    2. Execute all tasks in the micro task
    3. Execute a task in the macro task
    4. Render graphic/UI

    That's not exactly it, even though it doesn't really matter for the following.

    First, it's quite confusing to call what's on the call stack "tasks". A task as per the specs nomenclature is a specs construct that tells the browser how to perform some action. What you have on the call stack are JS script execution states (function, realm, scope, etc.).
    But yes, once the call stack is empty, i.e. when all the JS has been executed from top to bottom, a microtask-checkpoint is performed. Note that this can happen multiple times per task, and that some tasks may not execute any JS. So there isn't a direct relationship between tasks and microtasks. The only relationship is that they're both specs constructs to tell the browser to do something.

    Now, the rendering is performed as part of the update the rendering special task. Note that for a long time it's been specced as being a special part of the event loop, and only quite recently we updated it to be an actual task, as is actually implemented by most browsers.
    This special task is responsible for running a few callbacks, (which will all trigger a microtask checkpoint), and then will go on to the actual render. It generally has one of the highest priority in the task prioritization system, though it's not really specced (yet).


    knowing that setTimeout is placed in the macro tasks queue, which is literally the last queue to be read, why is the postMessage executed after setTimeout? If during synchronous execution, it is placed in the queue (whatever that queue may be) before setTimeout?

    Even though there are many queues in the event loop, there is still only one event loop (per context). So even if there is a prioritization system, the priority of each queue isn't specced, and browsers are free to pick whatever task they want to execute as long as it's the oldest one queued on its own task-source (note that a task-source is not a task queue, multiple task-source could end up in the same queue).
    Each MessagePort actually has its own task-source, and setTimeout tasks are queued on the timer task-source. Once again their priority isn't specced, and they generally have both the same, which would correspond to "user-visible" in the incoming Prioritized Task Scheduling API, so the order between a task queued from setTimeout(fn, 0), and a message event is not set and can't be assumed. Browsers are just free to do whatever they want here.


    But anyway, this isn't even the situation we're in here.

    In your case you are spawning a new worker, which will have its own event-loop.
    What you were missing is that while the Worker object is available synchronously, the actual worker thread is not. The browser first has to fetch the script (async), then it has to spawn a new context (async), once it's up and running, it will check if it received incoming messages from the main thread, and finally execute these.

    So what you're seeing makes perfect sense, you're just observing the async nature of spawning a worker.


    Ps: (Note that since we have OffscreenCanvas, at least Chrome will lock the worker's execution until the main thread that spawned it ends its current task, that's because they need to sync the rendering between both contexts, so even if "per specs" the order should be undefined, in Chrome at least, you'll always have main then worker, even if you do lock main).