Skip to content

runtime/wasm: stop all workers for garbage collection (based on #2216) - #2217

Draft
cpunion wants to merge 68 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-multi-worker-gc
Draft

runtime/wasm: stop all workers for garbage collection (based on #2216)#2217
cpunion wants to merge 68 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-multi-worker-gc

Conversation

@cpunion

@cpunion cpunion commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Depends on #2216.

Implements the M2 multi-worker GC stage of #2152.

Problem

#2216 deliberately disables the collector when LLGO_WASM_WORKERS>1. The existing compiler root-chain slot and active-context state are process globals, the allocator is unsynchronized, and one worker can collect while another worker is still executing with unpublished wasm-local roots. Enabling GC without this PR can therefore corrupt allocator metadata or reclaim objects that remain live on another worker.

Design

  • Compiler root frames use one native LLVM TLS chain per Web worker. The runtime provides the matching //llgo:tls storage and keeps every system/G context in a synchronized global enumeration registry.
  • Every worker registers its system context; every G registers its own root context. Fiber boundaries save and restore the corresponding chain. Before acknowledging STW, a worker publishes its active chain into that context.
  • GC uses an odd/even epoch protocol with an owner worker, ready-worker count, and stopped-worker acknowledgements. The collector wakes all workers, waits until every non-owner worker has published roots and stopped, performs both mark and sweep while stopped, then advances to the even resume epoch.
  • A worker that observes back-to-back epochs acknowledges the new odd epoch before returning to Go code.
  • The TinyGo-derived allocator is serialized by a zero-value worker mutex. Contended waits periodically poll the cooperative safepoint so a worker blocked behind the allocator can still acknowledge STW.
  • Channel, semaphore/notify, and timer locks use the same GC-aware worker mutex where their critical paths may allocate. The allocation-free scheduler run queue retains its existing pthread mutex.
  • Emscripten's worker-side __libc_malloc, calloc, realloc, and free entry points are routed to the selected collector when MALLOC=none.
  • LLGO_WASM_WORKERS>1 now enables the same default collector/root planning as single-worker J32/J64. Native, embedded, single-worker wasm, and explicit P1 thread selection remain source-isolated by existing build tags.

The compiler owns only root-frame emission and TLS selection. runtime/internal/gcroot owns execution-context root publication; runtime/internal/runtime owns worker/STW policy; runtime/internal/runtime/tinygogc owns collector hooks; and the small runtime/internal/wasmsync package owns the GC-cooperative mutex.

Validation

  • macOS arm64, Go 1.26.5, LLVM 19.1.7, Emscripten 4.0.21, Node 25.2.1:
    • J32 and J64 multi-worker GC fixtures pass under Node.
    • J32 and J64 5,000-G / 100,000-handoff worker stress passes.
    • J32/J64 GC and worker artifacts pass headless Chrome with the existing 15-second COOP/COEP browser budget.
    • Single-worker J32/J64 GC and P1 GC remain passing.
  • Ubuntu 24.04 arm64 container, hard-limited to 15 GiB, 2 CPUs, and no swap:
    • J32 and J64 GC fixtures pass with the CI Emscripten 4.0.21 and Node 25.2.1 toolchain.
    • J32 and J64 worker stress passes.
    • J32 and J64 GC fixtures pass 10 consecutive runs each.
    • Peak container memory was 2.84 GiB; no high-memory, OOM, or swap event occurred.
    • Full internal/build coverage tests pass as a non-root user. The runtime and affected compiler/build tests pass.
  • configureWasmGC is 100% covered. Every new SSA TLS/root-chain function is 100% covered; runtime gcroot is 94.1% covered.
  • go build -p=1 ./..., affected compiler tests, runtime module tests, and git diff --check pass.

The J64 stress process can print Emscripten's post-exit “user callback triggered after runtime exited” diagnostic after the success line; it exits 0 and does not occur in the GC fixture. Shutdown hardening remains part of H rather than being hidden by a retry or skip.

Resource and size results

Against #2216, the same two-worker stress fixture grows by 267,854 bytes for J32 (1,556,088 -> 1,823,942) and 360,614 bytes for J64 (1,710,749 -> 2,071,363). Generated JS size is byte-identical; the wasm increase is the collector, root publication, synchronized allocator, and STW machinery that M1 intentionally omitted.

On five macOS J64 runs, the fixture median moves from 0.72 s on M1 to 2.31 s on M2. This is a stage-level comparison, not a pure STW benchmark: M1 uses Emscripten allocation with no collector, while M2 performs compiler root publication and GC-managed allocation. The single-worker wasm comparison changes by only +516 bytes for J32 and +4,029 bytes for J64, isolating most of the direct size increase to enabling the collector in worker builds. Allocator/GC throughput optimization remains in H after correctness is established.

A representative native binary has identical text/data section sizes against #2216. The Cortex-M0 empty image is byte-for-byte identical and remains text/data/bss 132/0/10.

cpunion added 30 commits July 28, 2026 12:56
cpunion added 26 commits July 29, 2026 07:54
…orker-asyncify-scheduler

# Conflicts:
#	runtime/internal/runtime/z_default.go
# Conflicts:
#	.github/workflows/llgo.yml
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant