public/js/stations/threads.js
// Station 5: workers and threads (§37–§42, §82).
import { h, station, panel, button, ms, bytes } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";
import { countPrimes } from "../lib/primes.js";
const worker = (name) => new Worker(new URL(`../workers/${name}.js`, import.meta.url), { type: "module" });
// A bouncing ball drawn every frame, plus a frame-time graph. If the main
// thread is blocked, both freeze: that is jank, made visible.
function ballPanel() {
const canvas = h("canvas", { width: 720, height: 200, "aria-label": "Animated ball and frame-time graph", role: "img" });
const ctx = canvas.getContext("2d");
const fps = h("b", {}, "—");
const worst = h("b", {}, "—");
const frames = [];
let x = 40, y = 60, vx = 4, vy = 3, last = performance.now(), raf = 0, worstGap = 0;
const color = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#3355ff";
const tick = (now) => {
const dt = now - last;
last = now;
frames.push(dt);
if (frames.length > 180) frames.shift();
worstGap = Math.max(worstGap, dt);
x += vx * (dt / 16.7);
y += vy * (dt / 16.7);
if (x < 14 || x > 706) vx = -vx, (x = Math.max(14, Math.min(706, x)));
if (y < 14 || y > 110) vy = -vy, (y = Math.max(14, Math.min(110, y)));
ctx.clearRect(0, 0, 720, 200);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 12, 0, Math.PI * 2);
ctx.fill();
// frame-time graph: one bar per frame, red when over the 16.7ms budget
frames.forEach((f, i) => {
ctx.fillStyle = f > 50 ? "#e53935" : f > 17.5 ? "#f5a623" : "#2eb872";
const hgt = Math.min(70, f);
ctx.fillRect(i * 4, 200 - hgt, 3, hgt);
});
ctx.fillStyle = "#888";
ctx.fillRect(0, 200 - 16.7, 720, 1);
fps.textContent = `${Math.round(1000 / (frames.slice(-30).reduce((a, b) => a + b, 0) / Math.min(30, frames.length)))}`;
worst.textContent = ms(worstGap);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return {
el: h("div", {}, canvas, h("div", { class: "readouts" },
h("div", { class: "readout" }, h("small", {}, "frames / second"), fps),
h("div", { class: "readout" }, h("small", {}, "worst frame gap"), worst))),
resetWorst: () => (worstGap = 0),
worst: () => worstGap,
stop: () => cancelAnimationFrame(raf),
};
}
function jankPanel(ball) {
const limit = h("select", { "aria-label": "How much work" },
h("option", { value: "3000000" }, "primes below 3M (~0.3s)"),
h("option", { value: "6000000", selected: true }, "primes below 6M (~1s)"),
h("option", { value: "12000000" }, "primes below 12M (~3s)"));
const out = h("ol", { class: "log", "aria-label": "Results" });
const lanes = h("div", { class: "lanes" },
h("div", { class: "lane" }, h("span", {}, "Main thread"), h("div", { class: "rail", "data-lane": "main" })),
h("div", { class: "lane" }, h("span", {}, "Worker thread"), h("div", { class: "rail", "data-lane": "worker" })));
const mark = (lane, startPct, widthPct) => {
const rail = lanes.querySelector(`[data-lane="${lane}"]`);
rail.replaceChildren(h("div", { class: `work ${lane === "worker" ? "worker" : ""}`, style: { left: `${startPct}%`, width: `${widthPct}%` } }));
};
let blockedOnMain = false;
const onMain = () => {
ball.resetWorst();
out.prepend(h("li", {}, "main thread: computing… (the ball will freeze)"));
mark("main", 5, 90);
lanes.querySelector('[data-lane="worker"]').replaceChildren();
// Let the message paint first, then block.
setTimeout(() => {
const t0 = performance.now();
const count = countPrimes(Number(limit.value));
const took = performance.now() - t0;
out.prepend(h("li", {}, `main thread: ${count.toLocaleString()} primes in ${ms(took)}. The page could not paint or respond for that long.`));
requestAnimationFrame(() => requestAnimationFrame(() => {
blockedOnMain = ball.worst() > 100;
out.prepend(h("li", {}, `worst frame gap: ${ms(ball.worst())}`));
}));
}, 50);
};
const inWorker = () => {
ball.resetWorst();
const w = worker("primes");
mark("worker", 5, 90);
mark("main", 5, 1);
const t0 = performance.now();
w.postMessage({ limit: Number(limit.value) });
w.onmessage = ({ data }) => {
out.prepend(h("li", {}, `worker: ${data.count.toLocaleString()} primes in ${ms(data.ms)} (round trip ${ms(performance.now() - t0)}), worst frame gap ${ms(ball.worst())}. The ball never stopped.`));
w.terminate();
if (blockedOnMain && ball.worst() < 100) missions.complete("threads", "jank");
};
};
return panel("Experiment: the same work on two threads",
h("div", { class: "row" }, limit, button("Run on the main thread", onMain, { class: "danger" }), button("Run in a Web Worker", inWorker, { class: "primary" })),
lanes, out);
}
function transferPanel() {
const out = h("ol", { class: "log", "aria-label": "Transfer results" });
const run = async (transfer) => {
const w = worker("echo");
const buffer = new ArrayBuffer(64 * 1024 * 1024);
const t0 = performance.now();
w.postMessage({ buffer }, transfer ? [buffer] : []);
const posted = performance.now() - t0;
const size = await new Promise((r) => (w.onmessage = (e) => r(e.data)));
out.prepend(h("li", {},
`${transfer ? "transfer" : "copy (structured clone)"}: postMessage took ${ms(posted)} on the main thread; worker got ${bytes(size)}; sender's buffer is now ${bytes(buffer.byteLength)}`));
w.terminate();
};
return panel("Copy vs transfer a 64 MB buffer",
h("p", {}, "postMessage copies by default. Listing the buffer as transferable moves ownership instead: no copy, and the sender's buffer is detached (length 0)."),
h("div", { class: "row" }, button("Copy it", () => run(false)), button("Transfer it", () => run(true), { class: "primary" })), out);
}
function racePanel() {
const out = h("ol", { class: "log", "aria-label": "Race results" });
const run = async (atomic) => {
if (!crossOriginIsolated) {
out.prepend(h("li", {}, "SharedArrayBuffer needs cross-origin isolation (COOP + COEP headers). Serve the lab with npm start."));
return;
}
const sab = new SharedArrayBuffer(4);
const N = 1_000_000;
const workers = Array.from({ length: 4 }, () => worker("counter"));
const t0 = performance.now();
await Promise.all(workers.map((w) => new Promise((r) => ((w.onmessage = r), w.postMessage({ sab, n: N, atomic })))));
const value = new Int32Array(sab)[0];
workers.forEach((w) => w.terminate());
const lost = 4 * N - value;
out.prepend(h("li", {}, `${atomic ? "Atomics.add" : "counter[0]++"}: expected ${(4 * N).toLocaleString()}, got ${value.toLocaleString()} → ${lost.toLocaleString()} updates lost (${ms(performance.now() - t0)})`));
raceState[atomic ? "atomicOk" : "lost"] ||= atomic ? lost === 0 : lost > 0;
if (raceState.lost && raceState.atomicOk) missions.complete("threads", "race");
};
const raceState = {};
return panel("Experiment: a data race on shared memory",
h("p", {}, "Four workers each add 1 to the same counter a million times, through one SharedArrayBuffer. ", h("code", {}, "counter[0]++"), " is load, add, store: two threads can load the same value and one increment vanishes."),
h("p", { class: "note" }, `crossOriginIsolated = ${crossOriginIsolated}`),
h("div", { class: "row" }, button("Race with counter[0]++", () => run(false), { class: "danger" }), button("Fix with Atomics.add", () => run(true), { class: "primary" })), out);
}
export function mount() {
const ball = ballPanel();
return {
el: station(
"Workers & threads",
"Your JavaScript, style, layout and paint share one main thread. Any long computation there freezes everything. Workers are real OS threads with their own event loop; they talk to the page by message passing or, with care, shared memory.",
missionList("threads"),
panel("The main thread's heartbeat", ball.el, h("p", { class: "note" }, "Green bars fit the 16.7ms frame budget, orange ones miss it, red ones are long frames.")),
jankPanel(ball),
h("div", { class: "grid-2" }, transferPanel(), racePanel()),
),
unmount: ball.stop,
};
}