openlang

ECMA-262 never mentions an event loop

ECMA-262 never mentions an event loop. It defines jobs, and a host hook called HostEnqueuePromiseJob that the engine calls when a promise reaction is ready to run. When those jobs actually run, and what runs between them, is the host's business.

That's why setTimeout isn't in the language spec at all, and why arguing about whether a timer callback beats a .then callback can't be settled by reading ECMA-262 alone. The ordering everybody relies on, every promise job drained before the next timer task, comes from the HTML event loop and from Node, not from the core spec.

Still a useful split. Within one turn, promise reactions run in the order they were enqueued, and await on an already resolved value still waits for a job, so the code after it never runs synchronously. Dart has the same two-level shape, with work from scheduleMicrotask going ahead of queued events.

Hosts agree on more than they have to. I just wish fewer explanations started with a diagram the spec never drew.

后续讨论 8

顺着彼此的依据读下去
  1. 固定链接

    This maps almost one to one onto delta cycles in hardware simulation. In VHDL a signal assignment never takes effect immediately. It schedules a new value, the simulator finishes every process that woke up, then updates the signals and wakes whatever is sensitive to them, all without advancing simulated time. Time moves only once no further delta cycles are pending.

    Read "signal update" as "promise job" and "time step" as "task" and the structure is the same. A .then chain that keeps enqueuing work starves timers the way a combinational loop pins a simulator to one time point. GHDL stops such a run once it hits its delta-cycle limit. A browser tab just hangs.

    One difference is worth keeping. Processes resumed in the same delta all read the same signal values, whatever order they run in. Promise jobs see each other's side effects at once.

  2. 固定链接

    SystemVerilog gets closer still, since each time slot is split into named regions. #0 moves a process from Active to Inactive, and the update from a nonblocking <= waits for NBA.

    A testbench sprinkled with #0 to dodge a race is the same move as await null to let a pending .then go first. Works until somebody else adds one. Sample through a clocking block instead, so reads happen at a defined point relative to the clock.

  3. 固定链接

    Run it as order.cjs. sync, tick, then. Rename it order.mjs, run it again. sync, then, tick.

    Promise.resolve().then(() => console.log("then"));
    process.nextTick(() => console.log("tick"));
    console.log("sync");

    Nothing in the three lines changed, and it's one host. In the module the body is already running inside a job the host scheduled, so the promise queue drains before Node gets to its tick queue.

  4. 固定链接

    Dart writes down what JavaScript leaves to the host. Its docs describe both queues, microtasks and events, so this order is something you can lean on.

    import 'dart:async';
    
    void main() {
      Future(() => print('event'));
      scheduleMicrotask(() => print('micro'));
      print('sync');
    }

    sync, micro, event, every run. The trap is the starvation nadir08 described. Keep scheduling microtasks from microtasks and the event queue never gets a turn.

  5. 固定链接

    Here's where this split stopped being trivia for me. Click Save, the handler sets saving = true so the spinner shows, then works through the rows in a chain of .then steps because I thought each step gave the page room to breathe. The spinner never appeared. The button just froze and then the toast popped up (I'd even tried it on a small file, where it looked fine). Rendering happens between tasks, and the whole chain was promise jobs, so the page never got a turn to paint. Splitting the work with setTimeout sorted it out, and in hindsight that's the root's point in miniature, the paint rule lives in HTML and not in ECMA-262.

  6. 固定链接

    Both files print the same thing for this one, which surprised me more than the .cjs difference did.

    const p = Promise.resolve();
    
    async function plain() { return p; }
    async function awaited() { return await p; }
    
    plain().then(() => console.log('return p'));
    awaited().then(() => console.log('return await p'));
    p.then(() => console.log('tick 1'))
     .then(() => console.log('tick 2'))
     .then(() => console.log('tick 3'));

    Node prints tick 1, return await p, tick 2, return p, tick 3. The two functions have the same declared return type, Promise<void>, and the one that returns the promise directly settles later. Nothing in the types hints at it. I hit it in a test that waited a fixed number of await Promise.resolve() steps before checking a spy, and dropping a lint-suggested await from a return made the test fail.

  7. 固定链接

    And this one is all ECMA-262, no host involved, which is the fun part. return p inside an async function resolves the outer promise with a thenable, and resolving with a thenable queues a NewPromiseResolveThenableJob, which only then calls p.then. That's the extra hop. await p on a native promise skips the wrapping, since a spec change a while back made await cheaper, and return never got the same shortcut. So tess.p's test was counting steps that the spec really does pin down, it's just that nobody wants to count them. I'd delete the fixed number of awaits and wait on the thing the spy is supposed to see.

同一个问题的其他角度

  1. Same Int, two targets, two answers

    Found this while running the same Gleam module on both targets, and it's a runtime fact more than a language one. gleam run p…

    回复 3最近活动 gleam
  2. D's pure lets a function write through its arguments

    D's pure is weaker than the word suggests, and I only noticed because an evaluator experiment wanted to cache calls. pure in…

    回复 4最近活动 d
  3. CTFE happens where the declaration asks for it

    Had D's CTFE pictured wrong for a while. I assumed that once a function is fit for compile time evaluation, the compiler woul…

    回复 4最近活动 d