deno
70ac96e9 - perf(workers): incremental message-passing cleanups (#35108)

Commit
115 days ago
perf(workers): incremental message-passing cleanups (#35108) Incremental, low-risk optimizations to the worker `postMessage`/receive hot path. They do not change the architecture of how messages are delivered; they remove redundant work and an inconsistency in the receive loops, and are independently mergeable. Companion to the larger architectural change in #11561. ### 1. Drain symmetry — Web `Worker` main side drains queued messages within the turn The worker global (`runtime/js/99_main.js` `pollForMessages`) and the `node:worker_threads` polyfill (`ext/node/polyfills/worker_threads.ts` `#pollMessages`) drain already-queued messages with the cheap sync op (`op_*_recv_message_sync()`) after an async receive, instead of taking the full async op + Promise path for each. The Web `Worker` main-side loop (`runtime/js/11_workers.js` `#pollMessages`) did not — every message went through an async op round-trip. This adds the same drain to `11_workers.js`. It does not help a strict ping-pong (the queue is empty between turns, so the drain exits immediately and stays on the fast path) but speeds up any burst or fan-in delivered to the main thread of a Web `Worker`, processing the whole burst within one event-loop turn. **Correctness — microtask checkpoint between drained messages.** Each message dispatch is its own task, so a microtask checkpoint must run between them. WPT `workers/Worker-structure-message.html` posts two messages from the worker in one turn and re-assigns `onmessage` inside a `.then` between them; without a checkpoint the second message is delivered to the stale handler and lost (the test hangs). A *synchronous* checkpoint can't help — the drain runs while already inside V8's microtask checkpoint (the `await` continuation), and V8 won't run microtasks reentrantly. So the drain peeks the next message with the sync op and, only when one is queued, yields a microtask (`await new Promise(queueMicrotask)`, the same trick the MessagePort `start()` loop uses) before delivering it. This same yield is applied to all three receive loops so they drain identically and correctly (the worker-global and node loops had the same latent hazard). ### 2. Trim per-message allocations in message dispatch The three `MessageEvent` dispatch sites — `dispatchWorkerMessage` (`99_main.js`), `#dispatchWorkerMessage` (`11_workers.js`) and `dispatchPortMessageData` (`13_message_port.js`) — ran an `ArrayPrototypeFilter` over the transferables to extract `ports` for **every** message. For the overwhelmingly common no-transferables case this is pure churn. They now pass `ports: undefined` when there are no transferables, which lets the `MessageEvent` constructor take its existing cheap `ports == null` branch: a single frozen empty array with no iterator-validation loop. This removes both the per-message filtered-array allocation and the validation walk. Spec-observable behavior for the transferables case (`event.ports`, multiple `addEventListener` listeners, the trusted flag) is unchanged — the `MessageEvent` is still constructed and dispatched through the full `EventTarget` machinery. ### 3. Avoid serde_v8 struct serialization on the receive op return `op_host_recv_message` and the worker/port equivalents returned `Option<JsMessageData>` via `#[serde]`, so serde_v8 rebuilt a JS object per message even though the payload is essentially a `DetachedBuffer` plus a usually-empty transferables list. `JsMessageData` now derives `deno_core::ToV8` (with `#[to_v8(serde)]` on the `data` buffer and the transferables list). The derive builds the result object directly — interned `data`/`transferables` keys and a single `Object::with_prototype_and_properties` call — instead of routing the whole struct through serde_v8's `Serializer`. The `#[serde]` return annotation is removed from the seven receive ops (`op_message_port_recv_message`/`_sync`, `op_worker_recv_message`/`_sync`, `op_host_recv_message`/`_sync`, `op_node_worker_thread_recv_message`). `Deserialize` is kept for the post-message ops (which take `JsMessageData` as input) and `Serialize` for the one-shot worker-metadata bootstrap in `web_worker.rs`. ### Tests - `tests/unit/worker_test.ts`: a Web `Worker` that synchronously posts a burst of 500 messages to the host delivers all of them, in order, with no drops (sync drain); and a worker that replies with two messages while the host re-arms `onmessage` between them in a `.then` (mirrors WPT `Worker-structure-message`, the microtask-yield case). New `tests/testdata/workers/message_burst.ts` and `structure_message.ts` fixtures. - `tests/unit/message_channel_test.ts`: a no-transferables message yields an empty, frozen `ports` array (dispatch fast path + `ToV8` recv-op return). Verified locally: `worker_test`, `message_channel_test` and `unit_node::worker_threads_test` pass, and a local reproduction of WPT `Worker-structure-message` passes. ### Benchmarks Re-ran the #11561 ping-pong and fire-and-forget benchmarks and compared against Node.js, as requested. **Node v24.8.0** is the reference runtime from the issue; **Deno base** is stock `2.8.2`; **Deno patched** is this branch as a release build. Each benchmark sends 100k messages; lower is better. Host: Linux x86_64 GCP VM. Absolute numbers differ from the macOS arm64 machine in the original investigation, so the useful signal is the before→after delta and the gap to Node on the same host. | Benchmark | Node v24.8.0 | Deno base | Deno patched | Delta vs base | Node gap | |---|---:|---:|---:|---:|---:| | `node:worker_threads` ping-pong, payload=0 | 2.293s | 5.125s | **4.349s** | **-15%** | 2.23x -> **1.90x** | | `node:worker_threads` ping-pong, payload=100 | 4.532s | 8.460s | **7.608s** | **-10%** | 1.87x -> **1.68x** | | `node:worker_threads` fire-and-forget | 0.129s | 0.752s | **0.350s** | **-53%** | 5.83x -> **2.71x** | | Web `Worker` ping-pong | n/a | 5.290s | **4.267s** | **-19%** | n/a | | Web `Worker` fan-in/burst | n/a | 0.379s | **0.358s** | **-6%** | n/a | The largest win is the one-way throughput path, where skipping no-transferables dispatch churn and returning recv-op data without serde_v8 more than doubles throughput. Ping-pong improves 10-19%; the remaining gap is the larger architectural work tracked in #35025. The Web Worker fan-in benchmark is roughly neutral because the sync drain removes async-op overhead but must yield a microtask between drained messages to preserve task boundaries. Refs #35024 Closes denoland/divybot#560 --------- Co-authored-by: divybot <divybot@users.noreply.github.com> Co-authored-by: Divy Srivastava <me@littledivy.com>
Author
Parents
Loading