You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implement a complete LLGo WebAssembly runtime in reviewable stages, with one scheduler and GC core shared by the required execution targets:
ID
Build entry
Data model
Host/runtime
Required
J32
llgo build -target wasm
wasm32 / LLGo 32-bit data model
Emscripten JS, tested with Node
yes
J64
GOOS=js GOARCH=wasm llgo build
Memory64 / Go-width data model
Emscripten JS, tested with Node
yes
P1
GOOS=wasip1 GOARCH=wasm llgo build
wasm32
WASI Preview 1, tested with Wasmtime
yes
P2
llgo build -target wasip2
wasm32 component
WASI 0.2 Component Model
optional
"Complete wasm runtime coverage" in the near-term plan means J32, J64, and P1 execute the same goroutine, synchronization, timer, panic, and GC semantics. P2 is an optional output adapter and does not block the required runtime work. wasm-unknown is a freestanding/library target with scheduler=none; it receives compile/link regression coverage but is not counted as an executable Go runtime target.
This proposal follows #1031 and uses the runtime-owned G/M/P boundary introduced by #2166. Asyncify is the first deployable continuation backend and remains the compatibility fallback; it is not the scheduler ABI or the permanent architecture. Alternative continuation implementations are evaluated behind the same wasm-only boundary and do not block the current runtime feature chain.
As of 2026-08-03, the deployable Asyncify path has draft implementations through runtime hardening in cpunion#100, #102, #105, #107, #111, #116, #119, #123, and #125. The opt-in Go-style resumable chain is also staged through K1 #134, K2 #142, K3 #143, and K4 #144. K2 removes steady-state frame-block churn; K3 composes the backend with bounded workers and multi-worker STW; K4 covers final indirect ABI, GC-root, C-export, pclntab, DWARF, reflection-metadata, and standard-library workloads. The required implementation and acceptance draft chain is complete, but K remains opt-in: resumable worker handoff is still 2.41x/1.73x slower for J32/J64 and forced J32 build time is 96.4% higher than Asyncify in the current measurements.
Direction and continuation backends
The architecture has three independent execution layers:
Logical G continuation. Preserve and resume a goroutine's execution state. Asyncify/Fiber is the initial implementation. A Go-style resumable ABI and future standardized Wasm stack switching are alternative implementations.
Physical M execution. Supply one worker or a bounded worker pool. Emscripten pthread/Web Worker and optional WASI host threads live here; they do not themselves implement goroutine suspension.
Host wait. Return control while waiting for timers or I/O. Emscripten async imports, JSPI, WASI polling, and future WASI async APIs live here; they do not themselves implement arbitrary G-to-G switching.
These layers must not be collapsed into one API. In particular, JSPI can replace an Asyncify host-Promise bridge but cannot enumerate suspended goroutine roots or switch between arbitrary runnable Gs. Web Workers provide physical parallelism but do not replace a continuation backend.
Backend selection is compile-time source selection. The common runtime must not store a Go interface, virtual dispatch table, Asyncify buffer, or unused run-queue link in every native or embedded G. Wasm compiler passes must return before changing non-wasm IR, and wasm-only globals, initialization, metadata, post-link tools, and runtime packages must not enter native or embedded artifacts.
Official Go wasm model
The official Go wasm backend does not use Asyncify, Emscripten Fiber, LLVM coroutines, or native Wasm stack switching. It implements a compiler/linker/runtime resumable ABI:
every goroutine owns a Go stack in linear memory;
a logical PC combines a function-table ID (PC_F) and an in-function resume block (PC_B);
Go calls leave resume addresses on the linear-memory Go stack;
a goroutine switch returns an unwind flag through the active Go call chain, unwinding the Wasm operand stack while preserving Go frames;
the runtime resume loop reads PC_F/PC_B and re-enters the saved function and block through indirect dispatch.
This avoids a separate fixed Asyncify save area and avoids whole-program suspend-effect coloring because all Go calls follow the resumable ABI. It is not a reusable library: the Go compiler, assembler, linker, stack growth, GC maps, traceback, panic/defer, reflection, and calling convention cooperate. LLGo also has ordinary LLVM/C ABI boundaries, so an LLGo prototype must initially define external C calls as non-suspending leaves or introduce explicit wrappers.
A Go-style LLGo backend is therefore a separate wasm-only experiment, not a rewrite of the current PR chain. It must first prove indirect calls, panic/defer/recover, stack growth, GC roots, debug information, LTO, and C-boundary behavior, then beat or materially improve Asyncify's bytes/G, switch latency, and code size before becoming a default.
Alternatives and maturity
Mechanism
Logical G continuation
Web
WASI
Current decision
Binaryen Asyncify
yes, by whole-module unwind/rewind transformation
broadly deployable
usable for P1 post-link
first backend and compatibility fallback
Emscripten Fiber
yes, as an Asyncify-based context wrapper
deployable
not the P1 implementation
J32/J64 adapter; not an independent alternative
JSPI
host Promise suspension only
standardized proposal, Emscripten integration still evolving
no
optional host-wait adapter
WebAssembly Stack Switching
direct engine continuation
not yet a deployment baseline
engine-dependent
preferred future standards backend
LLVM coroutine lowering
stackless coroutine frames
technically possible
technically possible
experiment only; frontend still must identify every suspending call chain
Compiler CPS/state machine
yes
yes
yes
possible but high compiler/ABI/debug/GC complexity
Official-Go-style resumable ABI
yes, universal Go call ABI
proven by Go
proven by Go
preferred non-Asyncify feasibility experiment for LLGo
Web Workers / pthreads
no; physical M only
stable with shared memory and deployment headers
separate host-thread APIs
retain as bounded M backend
Engine-specific fibers/async
yes inside a controlled engine
not portable to browsers
possible with a fixed engine
not a portable LLGo runtime dependency
Wasm data-model note
GOARCH=wasm is described by Go as a WebAssembly 32-bit target, while the Go wasm architecture uses 8-byte int, pointer, and register sizes and converts linear-memory addresses for wasm32 operations. LLGo currently uses two explicit profiles:
J32 uses -target wasm and an LLGo 32-bit data model.
J64 uses raw GOOS=js GOARCH=wasm source selection and LLVM Memory64 to preserve the Go-width type layout.
J64 is not the official Go wasm binary ABI; it is LLGo's current LLVM-compatible mapping of Go-width types. Changing that mapping is independent from continuation selection. An exact official-style wasm32-memory/64-bit-Go-pointer ABI belongs to the resumable-ABI experiment and must not be folded into #2192 without separate compatibility and migration analysis.
Rebased J32/J64 single-worker Asyncify scheduler and compatibility backend. Local J32/J64/P1, native, and embedded validation passes; the full Ubuntu/macOS matrix and Codecov pass.
Independent static wasm defer dispatch prerequisite. Its current head is mergeable and all required checks pass.
W
draft, rebuilt in fork; CI green
W is rebuilt over the refreshed A + X base as cpunion/llgo#100. Layout-fix head 50866cd89 has 39 passing checks across Ubuntu, macOS, Go 1.24/1.26, wasm runtime execution, LTO, coverage, release artifacts, and embedded targets; only release publication is intentionally skipped. The old upstream #2197 draft remains frozen.
B
draft, rebuilt in fork; CI green
B is rebuilt from the validated W head as cpunion/llgo#102. Its 39 checks pass across Ubuntu/macOS, Go 1.24/1.26, wasm runtime, LTO, coverage, release artifacts, and embedded targets; only release publication is intentionally skipped. The old upstream #2198 draft remains frozen.
T
draft, rebuilt in fork; CI green
T is rebuilt independently from W as cpunion/llgo#105. Its complete fork CI has 39 passing checks and one expected release skip; local J32/J64/P1, macOS/Ubuntu runtime, coverage, and default native/embedded/wasm size validation also pass. The old upstream #2203 draft remains frozen.
C
draft, rebuilt in fork; CI green
C is rebuilt from B as cpunion/llgo#107. Local J32/J64/P1, optimization, macOS/Ubuntu, coverage, scheduler/deadlock, and native/embedded size validation pass. The old upstream #2204 draft remains frozen.
D
draft, rebuilt in fork; CI green
D is rebuilt from the combined B + T base as cpunion/llgo#111. Local J32/J64/P1, optimization/LTO, macOS/Ubuntu, coverage, and native/embedded validation pass; fork CI has 39 passing checks and one expected release skip.
S
draft, rebuilt in fork; CI green
S is rebuilt from the combined C + D stack as cpunion/llgo#116. Local validation passes; fork CI has 39 passing checks and one expected release skip.
M1
draft, rebuilt in fork; CI green
M1 is rebuilt from the validated S head as cpunion/llgo#119. Local validation passes; fork CI has 39 passing checks and one expected release skip.
M2
draft, rebuilt in fork; CI green
M2 is rebuilt from #119 as cpunion/llgo#123. Local validation passes; fork CI has 39 successful checks and one expected release skip.
H
draft, rebuilt in fork; CI green
H is rebuilt from #123 as cpunion/llgo#125. Its independent diff is +366/-4; bounded local hardening and artifact-isolation validation passes.
Retained per-G frame arenas and package-local O(1) bump/rewind helpers reduce the J32 handoff median from 32.007 us/op to 5.643 us/op. Final J32/J64/P1 artifact deltas are +0.57%/+0.28%/-0.94%; the backend remains opt-in because it is still 2.03x slower than Asyncify.
Final J32/J64/P1 ABI, roots, C export, pclntab, debug, reflection metadata, and standard-library acceptance. Independent diff +581/-16; raw P1 dynamic reflection calls and full post-link DWARF range rewriting remain explicit boundaries.
P2/P3
optional, not implemented
Component output and native WASI async remain adapters after the required core runtime.
Repository-state snapshot: runtime/wasm: add single-worker Asyncify scheduler #2192 has been rebuilt on the latest main and its complete CI is green; ssa/wasm: use static defer continuation dispatch #2208 remains the independent clean SSA prerequisite. W is rebuilt as cpunion/llgo#100 over an explicit A + X fork base, with an independent diff of +1,438/-105. B is rebuilt from that validated W head as cpunion/llgo#102, with an independent diff of +892/-70; its complete fork CI has 39 passing checks and one expected release skip. T is rebuilt independently from W as cpunion/llgo#105, with an independent diff of +546/-62; its complete fork CI has 39 passing checks and one expected release skip. C is rebuilt from B as cpunion/llgo#107, with an independent diff of +1100/-84; its complete fork CI has 39 passing checks and one expected release skip. D is rebuilt from the combined B + T base as cpunion/llgo#111, with an independent diff of +1859/-68; its complete fork CI has 39 passing checks and one expected release skip. S is rebuilt from the combined C + D stack as cpunion/llgo#116, with an independent diff of +786/-30; its complete fork CI has 39 passing checks and one expected release skip. M1 is rebuilt from S as cpunion/llgo#119, with an independent diff of +2052/-118; its complete fork CI has 39 passing checks and one expected release skip. M2 is rebuilt from this validated M1 head as cpunion/llgo#123, with an independent diff of +687/-120; local validation passes; fork CI has 39 successful checks and one expected release skip. H is rebuilt from M2 as cpunion/llgo#125, with an independent diff of +366/-4; local J32/J64/P1, worker, optimization, 10,000 blocked-G, macOS/Ubuntu, and artifact-isolation validation passes, and fork CI has 39 successful checks and one expected release skip. K single-worker integration is cpunion/llgo#134, based on H, with an independent diff of +7,202/-56; it does not alter the Asyncify production chain. K2 is cpunion/llgo#142, independent diff +726/-90; K3 is cpunion/llgo#143, independent diff +674/-182; K4 is cpunion/llgo#144, independent diff +581/-16. Each stage has completed its bounded local target matrix and full fork CI.
runtime: introduce G/M/P pthread backend #2166 moves go lowering behind runtime.NewProc and introduces replaceable G/M/P runtime state while preserving the native one-pthread-per-G backend.
runtime/wasm: add single-worker Asyncify scheduler #2192 adds a single-worker scheduler for J32 and J64 using Emscripten Fiber as the Asyncify context adapter. It covers Gosched, park/ready probes, normal return, Goexit, panic/defer/recover, G/M/P ownership, and distinct J32/J64 data models. After the latest main integration, bounded local J32/J64/P1, native, and Cortex-M validation pass; the complete Ubuntu/macOS CI matrix and Codecov pass.
The active W staging update in cpunion/llgo#100 moves stack and Asyncify-buffer ownership into runtime/internal/wasmcontext. The scheduler supplies root-aware allocate/free callbacks only during cold creation/destruction; direct Swap/Resume/Suspend calls remain unchanged. macOS J32/J64/P1 execution, the runtime module, native LLGo acceptance, and a resource-limited Ubuntu package/selection check pass locally. Storage and run-queue units are 100% covered.
This ownership boundary changes final scheduler modules by +2,991 bytes for J32 and +1,567 bytes for J64 against the same pre-lifecycle tree, without changing the existing 64 KiB stack plus 64 KiB Asyncify reservation per G. The later LLVM-compatible wasm32 alignment correction changes the per-G context allocation from an incorrect 376 B to 384 B (+8 B) and fixes an out-of-bounds scheduler field; representative J32 and P1 B fixtures become 112 B and 376 B smaller respectively. The measured P1 +172-byte integration delta uses an older W baseline and is not treated as a pure lifecycle comparison.
Default P1 executables from the active W staging branch do not import pthreads or shared host memory. Explicit LLGO_WASI_THREADS=1 retains the historical pthread backend and skips Asyncify.
cpunion/llgo#102 is the active rebuilt B implementation based on cpunion/llgo#100; the old upstream runtime/wasm: support blocking primitives (based on #2197) #2198 draft is frozen. It routes channels, select, runtime semaphores, Mutex, RWMutex, WaitGroup, Cond, Once, Pool, atomic Value, and sync.Map through scheduler parking on default J32/J64/P1 builds while preserving native and explicit P1 pthread behavior. Bounded local validation, changed-helper coverage, and native/embedded size checks pass. Its complete fork CI has 39 passing checks across Ubuntu/macOS and Go 1.24/1.26, with one expected release skip.
cpunion/llgo#107 is the active rebuilt C implementation based on cpunion/llgo#102; the old upstream runtime/wasm: add timers and host event wakeups (based on #2198) #2204 draft is frozen. It adds a private stable timer heap, lazy event dispatch, scheduler polling/waiting, monotonic deadlines, J32/J64 emscripten_sleep wakeups, and default P1 poll wakeups. Host waits return to the scheduler before Go callbacks run, so Asyncify is never synchronously re-entered. Explicit P1 pthread behavior remains unchanged.
cpunion/llgo#107 covers time.Sleep, active/stopped/reset/expired timers, tickers, AfterFunc, timeout select, wall time, and local time zones on J32, J64, and P1. It also includes the required official //go:wasmimport lowering and current Emscripten embind invoker compatibility. Static wasm defer dispatch is supplied independently by ssa/wasm: use static defer continuation dispatch #2208.
Bounded macOS and Ubuntu validation for cpunion/llgo#107 passes. J32/J64/P1 timer and scheduler fixtures pass; P1 validates with wasm-tools; timer fixtures pass at -O0/-O3, with J64/P1 ThinLTO also covered. The timer queue is 93.9% covered and measures 7.653-8.021 ns/op, 0 B/op, 0 allocs/op on Apple M4 Max. Against cl: qsort example #102, default no-time scheduler artifacts change by J32 +6 B, J64 +6 B, and P1 +2,794 B; native sections are unchanged and the Cortex-M4 ELF is byte-identical. Its complete fork CI has 39 passing checks and one expected release skip.
cpunion/llgo#105 is the active rebuilt T implementation based directly on cpunion/llgo#100; the old upstream runtime/wasm: add opt-in non-moving collector (based on #2197) #2203 draft is frozen. It factors the existing non-moving collector behind platform hooks and adds J32/J64/P1 memory adapters, allocator ownership, reclamation, statistics, aligned allocation, and linear-memory growth tests without depending on B.
T is selected only by the temporary internal llgo_wasm_gc tag. Default wasm still selects nogc because live pointers may remain only in wasm locals and suspended-G roots are not yet published; D provides compiler-maintained roots; default enablement remains a later step after cooperative safepoint and stress coverage. Threaded P1 selection is rejected because this collector is intentionally single-worker.
Bounded rebuilt-T validation executes J32/J64 under Node and P1 under Wasmtime 39, including allocation, reclamation, root retention, alignment, memory growth, and data integrity. The runtime module passes on macOS arm64 and in an Ubuntu amd64 container limited to 2 CPUs/6 GiB; all three build helpers are 100% covered. Default J32/J64/P1 wasm payloads are byte-identical to W, native size is unchanged, and the Cortex-M4 ELF is byte-identical. On the same wasm-runtime input, tagged T adds 10,674 B (+9.0%) for J32, 16,689 B (+12.3%) for J64, and 14,061 B (+13.1%) for P1. Fork CI has 39 passing checks and one expected release skip.
cpunion/llgo#111 is the active rebuilt D implementation based on cpunion/llgo#102 and cpunion/llgo#105; the old upstream runtime/wasm: preserve suspended goroutine GC roots (based on #2198, #2203) #2207 draft is frozen. It adds an independent SSA root-liveness planner, explicit per-function root frames, scheduler-context root-chain save/restore, and panic/recover unwind restoration while retaining W's wasmcontext stack ownership. J32/J64/P1 suspended-G GC fixtures pass at default optimization and -O0/-O3; J64/P1 ThinLTO, macOS/Ubuntu package tests, coverage, and native/embedded size checks also pass locally. Fork CI has 39 passing checks and one expected release skip. Cooperative preemption polls and default GC enablement remain later work.
Bounded cpunion/llgo#116 validation covers J32/J64/P1 at -O0 and -O3, J64/P1 ThinLTO, existing scheduler/blocking/timer fixtures, timer-triggered GC of an active CPU-bound G, channel-waiting roots, panic/recover, reclamation, aligned allocation, and memory growth. Full macOS Go 1.26.5 ssa, internal/build, and runtime tests pass; Ubuntu 24.04/amd64 focused compiler and complete runtime tests pass under 2 CPU/6 GiB limits. Planner and poll-budget coverage are 100%; native section sizes are unchanged and Cortex-M4 output is byte-identical. Fork CI has 39 passing checks and one expected release skip.
cpunion/llgo#119 is the active rebuilt M1 implementation based on cpunion/llgo#116; the old upstream runtime/wasm: add bounded Web worker scheduling (based on #2214) #2216 draft is frozen. It retains the opt-in fixed Emscripten pthread pool selected by LLGO_WASM_WORKERS=N (2 <= N <= 16), one permanent M/P/run queue/system context and locality owner per worker, round-robin G ownership without migration, worker-safe channel/semaphore/notify/timer paths, worker-0 host-event ownership, and context-owned continuation storage. The default value 1 is inert.
Local cpunion/llgo#119 validation passes J32/J64 Node stress 10/10, J32/J64 Chrome COOP/COEP acceptance 10/10 each, default J32/J64/P1 GC regression, Go 1.24 compatibility, full macOS build/crosscompile/runtime tests, and resource-limited Ubuntu amd64 tests. internal/wasmworkers is 100% covered. Native sections are unchanged and Cortex-M4 output is byte-identical. M1 changes 39 files by +2052/-118 after folding the context-owned lifecycle adaptation into the implementation commit; fork CI has 39 passing checks and one expected release skip.
M1 runs package initialization and main.main as the schedulable main G through Emscripten's proxy-main entry. J32 and J64 execute under Node and cross-origin-isolated Chrome with COOP/COEP headers.
Bounded M1 validation passes on macOS arm64 and an Ubuntu 24.04 arm64 container limited to 15 GiB/2 CPUs. J32/J64 each pass repeated Node runs; Chrome passes both data models. Every run covers overlapping pthread execution, fixed G/M/P ownership, 5,000 G lifecycles, 100,000 cross-worker channel handoffs, Mutex, WaitGroup, atomics, timers, all configured workers, and deliberately non-LIFO returns from two pointer-bearing //llgo:gls users per worker. The old worker entry fails that regression with runtime: local context changed by nested entry; the persistent per-worker locality owner removes the strict-nesting assumption without adding a context-switch hot-path operation. A 30-run stress loop covers the Ready-before-Park false-deadlock regression.
M1's configuration package is 100% covered; the new build configuration path is 88.2% covered and worker entry generation is 100%. Its full Ubuntu/macOS CI matrix and Codecov pass. Default native/wasm behavior remains unchanged, and a Cortex-M0 empty image is byte-for-byte size-identical at text/data/bss 132/0/10.
M2 uses an odd/even epoch handshake with one collector owner and ready/stopped worker counts. The allocator and allocation-capable channel, semaphore/notify, and timer locks use a GC-cooperative worker mutex, so a contended worker can acknowledge STW instead of blocking behind the collector. Mark and sweep both run while every non-owner worker is stopped.
Rebuilt M2 validation passes J32/J64 Node and Chrome with 2 workers, J32/J64 Node with 4 workers, J32 -O0, J64 -O3 with ThinLTO and FullLTO, and single-worker J32/J64/P1 GC. Full macOS ssa, cl, and internal/build tests pass; focused Go 1.24 tests and a 2 CPU/6 GiB Ubuntu amd64 runtime matrix pass. Native file/section sizes are unchanged and the Cortex-M4 empty ELF is byte-identical at 1,804 B with text/data/bss 140/0/10. Fork CI has 39 successful checks and one expected release-publication skip; both coverage jobs pass, while external Codecov publishing is unavailable on the protected fork branch without a token.
targets/wasip2.json exists, but several declared fields (buildmode, scheduler, gc, stack size, WIT package/world) are not represented by internal/targets.Config, and the wasm/wasi special path in crosscompile.Use does not provide a complete component build. P2 remains optional and is not currently a supported runtime target.
LLGO_WASM_RESUME=1 selects K for J32, J64, or P1. It disables Asyncify while preserving the exception-encoding post-link stage required by LLVM 19 SjLj. Default Asyncify wasm, native, embedded, and explicit P1 pthread builds remain unchanged. The independent #134 diff over H is 28 commits across 78 files (+7,202/-56).
Compiler and link flow
internal/build resolves the target and K selector, adds the private llgo.wasm_resume build tag, fingerprints the selector for the build cache, and disables Asyncify.
ssa marks resumable InGo definitions and actual generated Go calls. Runtime ABI implementation functions, real //go:wasmimport declarations, and C boundaries remain synchronous leaves; same-name LLGo patch bodies are not incorrectly converted into imports.
internal/wasmresume inventories each marked function once, assigns deterministic resume IDs, computes CFG liveness including PHI-edge uses, lays out persistent slots, and emits leaf or state-machine entries.
Every package module is lowered before LLGo/C ABI lowering. The synthetic entry package is lowered through the same path; package init and main.main run as the root resumable task.
P1 still uses LLVM SjLj for the single scheduler panic catch. Its post-link path runs wasm-opt --translate-to-exnref without --asyncify, so Wasmtime 39 consumes standardized exception instructions.
ABI type metadata, closures, interfaces, reflected methods, equality/hash functions, and indirect Go entry points use generated start entries. Compiler root-frame allocas and derived pointers that cross a resumable call are retained in the persistent frame.
Direct calls use descriptor-known typed frames. Indirect calls, closures, interface methods, and reflected method values use generated __llgo_wasm_start.<function> entries while retaining LLGo's existing two-word {code, data} function-value layout. Cross-package descriptors inherit source linkage, so ordinary definitions remain external and generic/linkonce definitions do not become duplicate strong symbols.
Runtime memory layout
Each generated function has one immutable descriptor:
Each invocation has a typed frame whose common prefix is:
wasm32 wasm64
+0 parent 4 B +0 parent 8 B
+4 descriptor 4 B +8 descriptor 8 B
+8 pc 4 B +16 pc 4 B
+12 function slots ... +24 function slots ...
The public prefix is therefore 12 bytes on wasm32 and 24 bytes on wasm64. One private machine word immediately before the frame records the previous arena stack pointer; it does not enlarge the public ABI prefix. A measured leaf with one i64 parameter and one i64 result occupies 36 bytes on wasm32 and 48 bytes on wasm64 including that private word, before allocation-alignment padding.
One logical G owns:
runtimeContextPlatform {
Context {
top
returned
storage.current
}
gcRoot
runqNext
runqQueued
unwind
unwindRoot
}
Frame storage is a lazy per-context segmented arena. The first touched frame allocates one 2 KiB GC-root block; overflow adds stable segments without moving existing frames. Normal LIFO completion rewinds the bump pointer, drops empty child segments, and retains the root segment. Dynamic allocas use the same context-owned storage, so suspension never leaves a pointer into an expired native stack. Explicit over-alignment is preserved.
Unlike the Asyncify backend, K does not reserve a 64 KiB C stack plus a 64 KiB Asyncify save area for every G. Package and function frames are allocated only when called.
Normal dispatch is O(1): it reads Context.top, calls one resume entry, and switches on the current frame PC. It does not traverse the parent chain. SuspendCurrent stores the continuation PC and returns Suspend without allocating another frame; the scheduler later re-enters the same top frame.
go creates a runtime G with its own Context and start frame. The H single-worker G/M/P scheduler owns one run queue and changes G state around Gosched, park/ready, completion, Goexit, and deadlock. Channel, select, sync, timer, and safepoint paths reuse B/C/S policy and suspend through the selected continuation operation.
Before Context.Run, the scheduler installs the selected G's registered compiler-root context; after return or suspension it restores the scheduler root. Inactive Gs remain in the synchronized root registry. JS timer callbacks carry a generation and can only re-enter the single-worker scheduler once; stale callbacks are rejected. P1 keeps synchronous host waiting.
K3 retains this scheduler contract for worker builds. Each worker installs its identity and locality owner, restores the selected resumable context's compiler-root chain, runs it, and captures the chain again on suspension. Runnable contexts may move before first execution, but a started context remains pinned according to the existing M1 policy. STW waits occur through a non-suspending host callback, so no suspended Go frame retains a scheduler lock. Asyncify and resumable worker helpers are selected at build time; native and embedded targets gain no continuation interface or storage.
Panic/defer/recover and Goexit use an exceptional cold path:
SSA emits registration/clear markers around the existing defer CFG.
Lowering turns the marker into one defer-owner pointer slot only in functions that own defers; descriptors record the slot offset and generated defer-handler PC.
One native sigsetjmp catch surrounds a scheduler context run. Resumable defer frames use no per-defer native jump buffer.
Rethrow transfers to the scheduler catch. Context.Unwind scans the explicit parent chain only on this exceptional path, reclaims child frames, and redirects the owning frame to its generated defer state.
Existing defer/recover/Goexit control flow then runs normally. If no owner remains, the scheduler reports the panic or completes Goexit.
This changed the measured wasm32 frame for the suspend-then-panic acceptance function from 228 bytes with a native jump buffer to 40 bytes with the owner slot.
Current optimizations and measurements
Leaf functions emit direct resume entries without a PC state machine.
Normal call/return/suspend dispatch is O(1); parent-chain scanning is restricted to panic/Goexit unwind.
Known direct calls use descriptors; function values keep two words and do not add a descriptor word.
Runtime ABI helpers and C boundaries remain synchronous, preventing recursive lowering and unnecessary frames.
Only functions with a resumable call or defer-unwind state become state machines.
Post-rebase native helper microbenchmarks measure 3.346-3.369 ns/op for dispatch and 3.873-3.918 ns per hot arena allocate/release pair, both 0 B/op and 0 allocs/op on Apple M4 Max with preallocated storage.
End-to-end hardening results use the same fixture and default optimization on Apple M4 Max. Runtime values are medians from five alternating processes with 1,000 blocked Gs:
Profile
Asyncify roundtrip
K roundtrip
Ratio
Asyncify live bytes/G
K live bytes/G
J32
9.243 us
165.617 us
17.92x slower
about 131.6 KiB
about 2.55 KiB
J64
11.324 us
96.516 us
8.52x slower
about 132.1 KiB
about 3.03 KiB
P1
1.818 us
174.184 us
95.81x slower
about 131.6 KiB
about 2.57 KiB
At 10,000 blocked Gs, J32/J64 Asyncify exceed Emscripten's 2 GiB limit. K completes with about 27.7/30.4 MiB live heap and 49.8/50.1 MiB HeapSys. P1 changes from about 1.316 GiB live heap to about 25.5 MiB.
Combined J32/J64 JS loader plus wasm size decreases by 290,720 bytes (-11.32%) and 247,697 bytes (-8.72%). P1 wasm decreases by 360,889 bytes (-17.50%).
Three interleaved forced J32 builds have medians of 16.364 s for Asyncify and 29.991 s for K (+83.27%).
Temporary MemStats instrumentation around the J32 channel loop measures 20,739 B/op and 12.010 allocations/op for K versus 93 B/op and 2.001 allocations/op for Asyncify. A 100 us V8 profile attributes about 65% of wasm samples to allocator/bitmap paths.
K1's helper microbenchmarks did not represent the full path: a deep channel call chain overflowed the 2 KiB root block and repeatedly allocated and dropped child blocks. K2 retains high-water blocks and emits package-local O(1) bump/rewind helpers. On the same J32 handoff workload, K1 measured 32.007 us/op, K2 5.643 us/op, and Asyncify 2.776 us/op. K2 reduced the workload from about 20,739 B/op and 12.01 allocs/op to about 80 B/op and 2.00 allocs/op; J32/J64/P1 artifact deltas against K1 are +0.57%/+0.28%/-0.94%.
K3 worker hardening medians are 13.112 us/op for J32 and 10.755 us/op for J64, versus 5.447 us/op and 6.222 us/op for Asyncify workers (2.41x and 1.73x slower). Its J32/J64 artifacts are 14.54%/13.02% smaller, while forced J32 build time is 30.99 s versus 15.78 s (+96.4%). These results keep K opt-in despite its memory and size advantages. K4 changes acceptance and lowering correctness rather than the backend selection gate.
Validation
K1 covers J32/J64/P1 scheduler, channel/sync, timers, GC, safepoints, panic/defer/recover, C boundaries, hardening, O0/O3/LTO, native/embedded isolation, and 10,000 blocked Gs.
K2 covers retained arena reuse, pointer-range clearing, context reclamation, zero-allocation hot helper paths, and the same J32/J64/P1 optimization/runtime matrix.
K3 covers J32/J64 combined resumable workers under Node and Chrome, worker GC/STW, hardening, 1,000 blocked Gs, zero retained-root delta, P1 single-worker regression, and native/embedded isolation.
K4 covers variadic indirect suspension with live roots, maps and reflection metadata/typed methods, C exports, pclntab after resumption, bytes/base64/regexp/math workloads, J32/J64 DWARF verification, and P1 runtime/debug-section validation. Default non-resumable WASI remains covered.
Resource-bounded macOS and Ubuntu validation passes. Changed K4 helpers are 100% covered; internal/wasmresume is 95.2% and ssa is 93.3%.
Fork CI passes for K1, K2, K3, and K4 across the maintained Ubuntu/macOS, Go 1.24/1.26, wasm, LTO, coverage, build-cache, and artifact matrices; release publication is intentionally skipped on fork PRs.
Remaining work
The required K implementation and acceptance draft chain through K4 is complete. The remaining work is merge/default-readiness rather than another required proposal stage:
reduce worker handoff latency and compile time enough to satisfy the default-backend gates; Asyncify remains the compatibility/default backend until then;
add a compatible P1 invocation backend before claiming raw dynamic reflect.Value.Call or reflect.MakeFunc; K4 currently covers reflection metadata, typed methods, and map operations;
preserve full post-link P1 DWARF ranges when the existing Binaryen/WASI rewriting pipeline supports them; K4 verifies J32/J64 fully and asserts P1 runtime plus .debug_info/.debug_line presence;
review and merge the staged dependency chain in order, rebasing child PRs only after their parent is stable.
P2 component output, P3 native WASI async, JSPI, and standardized Wasm stack switching remain optional adapters and do not block the required J32/J64/P1 runtime chain.
Goals
Execute many goroutines on one wasm worker without one pthread or Worker per G.
Provide the same Go-level channel, select, timer, sync, panic/defer/recover, and GC behavior on J32, J64, and P1.
Keep scheduler policy independent from Emscripten, WASI, Binaryen, and host I/O APIs.
Keep logical G continuation, physical M execution, host wakeups, and memory/GC platform details in separate replaceable adapters.
Select continuation implementations at build time with target-specific concrete types; do not add interface dispatch, dormant fields, or per-G storage to native and embedded runtimes.
Keep Asyncify as a deployable fallback while measuring a Go-style resumable ABI and adopting standardized stack switching when it becomes broadly available.
Preserve objects referenced only by running or suspended goroutines.
Add bounded cooperative preemption before adding multiple workers.
Later support a bounded M:N worker model without changing the Go-facing runtime APIs.
Keep target capabilities typed and tested instead of inferring behavior from target-name prefixes or unconsumed JSON fields.
Pin and test external wasm tools used by the build pipeline.
Keep wasm scheduler, context, timer, and GC machinery out of native and embedded artifacts unless a platform explicitly selects it; native and embedded code size, startup, and steady-state runtime must not regress materially.
Non-goals
Do not make LLVM coroutine intrinsics, whole-program effect coloring, JSPI, or future Wasm stack switching a prerequisite for the current Asyncify runtime chain. Experimental backends remain separate until they satisfy the same correctness and resource gates.
Do not use one host thread or Worker per goroutine as the final wasm model.
Do not use LLVM's GC shadow-stack strategy.
Do not move the Go heap to WebAssembly GC types in the initial implementation.
Do not promise signal-based asynchronous preemption.
Do not make P2 or WASI 0.3 native async a prerequisite for J32/J64/P1 correctness.
Do not treat every JSON target whose LLVM triple contains wasm as an executable Go runtime target.
Architecture
Package boundaries
The implementation is split by responsibility:
runtime/internal/runtime owns G/P/M state, run queues, goroutine state transitions, scheduling policy, and the opaque park/ready handle used by runtime primitives.
A small wasm continuation package owns context creation, backend-owned storage, suspend/resume, destruction, thread-affinity capabilities, and root-context publication. Its first JS adapter wraps Emscripten Fiber; its first P1 adapter uses raw Binaryen Asyncify operations. Stack and Asyncify buffer allocation are implementation details and are not exposed in the scheduler contract. During cold context creation, the runtime supplies root-aware allocate/free callbacks; the selected backend chooses sizes, owns the returned pointers, and releases them. The callbacks are not retained in each G. Suspend/resume/switch remain concrete direct calls on the hot path. Later Go-style and standardized stack-switching implementations use the same scheduler-facing lifecycle.
Channel/select and semaphore code own Go synchronization semantics. They depend only on the scheduler waiter API; native pthread waits and wasm G parking are build-specific wait adapters.
A timer/event adapter owns deadline ordering and host waiting. The scheduler polls due events, and only calls the host wait adapter when its run queue is empty. J32/J64 suspend with emscripten_sleep; default P1 waits with poll. Go callbacks execute only after the host wait returns to the scheduler, never by synchronous re-entry during Asyncify unwind/rewind.
The GC package owns allocation, marking, sweeping, and root enumeration. JS and P1 adapters only supply linear-memory growth and platform memory boundaries.
internal/crosscompile describes target/toolchain capabilities. internal/build orchestrates link and post-link stages. Runtime source selection must use normal GOOS/GOARCH tags where possible; target names are not a substitute for runtime platform checks.
No Emscripten, WASI, Binaryen, channel, timer, and GC policy should be combined in one package.
Runtime execution flow
For each package, LLGo compiles normal Go functions and lowers a go f(x) statement to a runtime startup record plus runtime.NewProc. The package does not generate a host thread or a platform context itself.
At program startup:
The runtime initializes the main G, one M, one P, and the local runnable queue.
NewProc allocates target-independent G metadata, asks the selected continuation backend to create any required storage, and queues the new G.
Gosched requeues the running G. A blocking operation marks it waiting and does not requeue it. Exit marks it dead.
The scheduler chooses the next runnable G, transfers the shared M/P ownership, and asks the continuation backend to resume it. The scheduler does not inspect C stacks, Asyncify save areas, resume PCs, or engine continuations.
A completed G is reclaimed only after execution has moved to another stack/context.
P1 uses the same scheduler state machine. Only the context and host-event adapters differ.
Blocking flow
A channel, select case, semaphore, mutex, WaitGroup, or Cond wait stores an opaque scheduler waiter rather than a pthread condition variable:
The primitive publishes its wait record under its own synchronization.
Park changes the current G from running to waiting and enters the scheduler.
A matching operation atomically claims the wait record and calls Ready.
Ready changes the G from waiting to runnable and appends it to its owning P queue.
The scheduler resumes it when selected.
The common channel/select algorithms remain shared with native builds. Only the mechanism that sleeps and wakes the execution owner changes.
Asyncify backend and linking
The following pipeline applies only when the Asyncify continuation backend is selected. J32/J64 currently let Emscripten perform Asyncify and use its Fiber API. P1 requires an explicit post-link transform:
LLGo compiles packages and runtime code to LLVM/object inputs. SjLj lowering is enabled while package IR is compiled, not only when the final linker runs.
The normal host entry initializes the runtime on the system stack, then calls the P1 scheduler. A hidden C-ABI task wrapper runs package initialization and main.main on the main G context.
The WASI linker emits a core wasm module to a temporary output using LLVM 19 legacy EH, which Binaryen can currently Asyncify.
wasm-opt --asyncify --translate-to-exnref first instruments the complete core module and then converts legacy EH to standardized exnref-based EH accepted by Wasmtime.
The transformed file is atomically published as the final P1 executable. Archive and shared-library build modes do not enter this post-link stage.
The build must report a clear missing/incompatible Binaryen error. Debug/name information must be preserved according to LLGo's selected debug options. The Binaryen version is pinned in CI. The default P1 mode is single-worker and does not import shared memory or pthread host functions. Explicit LLGO_WASI_THREADS=1 remains a compatibility build mode: it selects the existing pthread backend and skips Asyncify post-link processing.
If P2 is enabled later, componentization occurs after the core module has been linked and Asyncified:
wasm-opt is not run on the final component. Basic P2 support initially means wasi:cli/command; other worlds are separate capabilities.
Garbage collection
The initial wasm collector is non-moving, stop-the-world, and single-worker:
reuse the existing non-moving allocator/collector core where correct;
add wasm linear-memory growth and boundary adapters instead of widening bare-metal build tags blindly;
scan globals, the active G, and every suspended G;
publish suspended-G roots independently of Binaryen's private save-area layout;
retain objects referenced only from suspended execution state;
reclaim unreachable allocations and release dead-G context/root records.
The maintained root ABI should use compiler-emitted root records or stack maps. A conservative Asyncify-area scan may be used only as a measured prototype, not as the sole long-term contract.
T deliberately remains behind the internal llgo_wasm_gc source-selection tag: linear-memory scans alone cannot observe a live pointer held only in an SSA/wasm local. D supplies compiler root records; S appends the internal tag by default only after enabling those roots and cooperative polling. Explicit P1 threads remain outside this single-worker collector.
M2 extends this model without changing the compiler/runtime ownership boundary:
Each worker keeps its current compiler root chain and active execution context in native TLS. Every system context and G context remains linked in the collector's synchronized global registry.
The collector serializes allocator access, marks the world epoch odd, wakes all workers, and waits for every non-owner worker to publish its active chain and acknowledge the same epoch.
The owner enumerates globals plus every registered context, then completes mark and sweep while the world remains stopped.
Advancing the epoch to the next even value resumes workers. A worker that observes a back-to-back odd epoch acknowledges it before returning to Go code.
Locks whose critical paths can allocate use a worker mutex that periodically polls the GC request while contended. Allocation-free scheduler run queues retain their existing pthread mutex. This keeps STW cooperation separate from channel, timer, and scheduler policy.
Cooperative preemption
S adds wasm safe-point polls at declared Go function entries and DFS cycle-closing loop backedges. Runtime packages, cgo bodies, //go:nosplit functions, and package-less synthetic forwarding wrappers are excluded. The inlined fast path decrements a single-worker budget; every 1,024 polls the slow path dispatches ready host events and yields only when another G is runnable. Existing blocking operations already park/yield, and allocation-triggered GC runs synchronously.
Because J32/J64/default P1 use one worker, a collection on the selected G is naturally stop-the-world: every other G is suspended with its compiler root chain owned by its runtime context. Long C/host calls remain non-preemptible until the host adapter returns or exposes asynchronous readiness. M1 provides the bounded Web worker pool; M2 adds TLS root ownership and the request/acknowledgement handshake needed to collect across it.
Implementation plan
Each item below is one reviewable PR, normally containing several ordered commits. PRs are not split further merely to isolate individual files.
W: WASI scheduler foundation (based on A + X) — cpunion/llgo#100 (active fork draft; upstream runtime/wasm: add WASI single-worker scheduler (based on #2192, #2208) #2197 frozen). Add typed wasm target/post-link capabilities, a host-neutral context boundary, raw Asyncify P1 context support, atomic wasm-opt post-link processing, and the same basic scheduler execution probe under Wasmtime. This PR contains the build and runtime halves together because neither is useful or testable alone.
B: Blocking primitives (based on W) — cpunion/llgo#102 (active fork draft; upstream runtime/wasm: support blocking primitives (based on #2197) #2198 frozen). Route channel, select, runtime semaphores, Mutex, RWMutex, WaitGroup, Cond, and related sync/internal/sync hooks through scheduler park/ready. Preserve pthread behavior on native targets and explicit P1 threads. Run one common fixture on J32, J64, and P1.
C: Timers and host wakeups (based on B) — cpunion/llgo#107 (active fork draft; upstream runtime/wasm: add timers and host event wakeups (based on #2198) #2204 frozen). Implement time.Sleep, timers, tickers, AfterFunc, timeout select, monotonic deadlines, and non-reentrant host waiting. J32/J64 use emscripten_sleep; default P1 uses clocks/polling. Due callbacks are dispatched by the scheduler after host wait returns.
T: wasm non-moving collector — cpunion/llgo#105 (active fork draft; upstream runtime/wasm: add opt-in non-moving collector (based on #2197) #2203 frozen; based on W). Factor the existing collector behind separate memory adapters and add opt-in J32/J64/P1 allocation, reclamation, aligned-allocation, statistics, and growth coverage. T makes no wasm-local or suspended-G root claim and therefore keeps the temporary internal llgo_wasm_gc gate until D. It stays independent from B/C to reduce stacked rebases, and all three profiles execute in the existing wasm-runtime CI job.
D: Compiler root frames and suspended-G roots (based on B + T) — cpunion/llgo#111 (active fork draft; upstream runtime/wasm: preserve suspended goroutine GC roots (based on #2198, #2203) #2207 frozen). Add SSA liveness planning, explicit root publication, scheduler-context root ownership, panic-unwind restoration, and suspended-G liveness fixtures. The rebuilt branch integrates root ownership with W's wasmcontext.Context lifecycle instead of restoring the old duplicate stack fields. C may merge before or after D.
M1: Web bounded workers (based on S) — cpunion/llgo#119 (active fork draft; upstream runtime/wasm: add bounded Web worker scheduling (based on #2214) #2216 frozen). Add an opt-in fixed Emscripten pthread pool, one pinned M/P/run queue per worker, worker-safe synchronization and timers, a persistent locality owner per physical worker, proxy-main scheduling, J32/J64 Node and COOP/COEP browser coverage, and bounded parallel-progress stress. Keep multi-worker GC disabled until M2.
H: runtime hardening (based on M2) — cpunion/llgo#125 (active fork draft). Add J32/J64/P1 process arguments and one shared lifecycle fixture covering indirect suspension, panic/defer/recover, scheduler handoffs, 10,000 blocked-G roots, C boundaries, worker GC, and shutdown. Default wasm, native, and embedded artifacts remain byte-identical to M2.
Optional host-thread work
P1T: optional WASI Preview1 host threads. Isolate legacy wasi-threads behind the M adapter; do not expose it as scheduler ABI.
Experimental continuation work
These experiments do not become dependencies of B/C/T/D/S/M1/M2 until they meet the same acceptance tests:
K1: Go-style resumable single-worker integration (cpunion/llgo#134, based on H). J32/J64/P1 execute B/C/D/S/H behavior without Asyncify, including direct/indirect calls, closure/interface entries, cross-package descriptors, channel/select/sync, timers, precise roots, default GC, safepoints, panic/defer/recover, C boundaries, and lifecycle hardening. Local acceptance and full fork CI pass.
K2: resumable arena optimization (cpunion/llgo#142, based on K1). Retain lazy per-G high-water blocks, generate package-local O(1) bump/rewind fast paths, clear released GC-root ranges, preserve context-close reclamation, and rerun the full cost matrix. The local macOS/Ubuntu matrix and full fork CI pass; CI has 39 successful checks and one expected release skip. This remains separate from worker policy so reviewers can evaluate the compiler/runtime memory contract and measured benefit in isolation.
K3: resumable worker composition (cpunion/llgo#143, based on K2). Reuse M1/M2 scheduling and STW policy with build-selected concrete context helpers. J32/J64 combined worker execution, Chrome/Node, worker GC/STW, P1 regression, artifact isolation, coverage, and full fork CI pass.
K4: final ABI/debug/workload acceptance (cpunion/llgo#144, based on K3). Covers variadic indirect calls, precise suspended roots, C exports, DWARF, pclntab, reflection metadata/typed methods, maps, and representative standard-library workloads across J32/J64/P1. Bounded local validation, coverage, and full fork CI pass; the documented P1 dynamic-reflection and post-link range boundaries remain.
J: optional JSPI host-wait adapter. Replace selected JS Promise boundaries without changing scheduler or G continuation semantics.
SS: future Wasm Stack Switching adapter. Add only after browser and required engine support is a deployable baseline.
Optional component work
P2: WASI 0.2 component output. Repair typed target selection, emit a wasi:cli/command component from the already-tested P1 core, pin wasm-tools and the Preview1 adapter, and run it with a compatible Wasmtime. It reuses W/B/C/T/D runtime behavior and does not block those PRs.
P3: WASI 0.3 native async. WASI 0.3 was released on 2026-06-11 and requires a newer runtime/toolchain baseline (Wasmtime 43+). Treat native async func, stream, and future mapping as a later host-I/O adapter, not as a rewrite of the scheduler core.
Dependency graph
#2166 -> #2192(A) ---------> W (cpunion/llgo#100) -> B (cpunion/llgo#102) -> C (cpunion/llgo#107) --------\
#2208(X) ----------------/ \ +-> S (cpunion/llgo#116) -> M1 (cpunion/llgo#119) -> M2 (cpunion/llgo#123) -> H (cpunion/llgo#125)
+-> D (cpunion/llgo#111) -/
W (cpunion/llgo#100) ------------------> T (cpunion/llgo#105) -/
W ----------------------------> optional P2
C ----------------------------> optional P3 host-async adapter
H (cpunion/llgo#125) - - - - -> K1 (cpunion/llgo#134) -> K2 (cpunion/llgo#142) -> K3 (cpunion/llgo#143) -> K4 (cpunion/llgo#144)
T remains independent from B/C but uses W for executable P1 post-link support. D depends on B and T. S is the integration point for C and D, so timer-driven scheduling and root publication are both present before default GC is enabled.
K is based on H only to reuse the complete runtime policy and acceptance fixture. No Asyncify-chain PR depends on K, so its merge order remains unaffected. K2 changes continuation storage only; K3 reuses M1/M2 worker and STW policy behind compile-time-selected concrete helpers rather than adding a second scheduler or GC implementation. K4 adds correctness and acceptance coverage without changing backend selection or exposing resumable ABI details outside wasm builds.
Test and CI matrix
Per required runtime PR
Test
J32
J64
P1
Native regression
compile/link
required
required
required
required
execute scheduler fixture
Node
Node
Wasmtime
n/a
panic/defer/recover
required
required
required
required
channel/select/sync for B+
required
required
required
required
timer/async for C+
required
required
required
required
GC allocation/liveness for T/D+
required
required
required
required
The three wasm variants run sequentially in one bounded CI job where practical, with explicit timeout and memory limits. Tests must not add skips for cases that previously ran. Tool download/network failures may retry, but semantic failures may not be hidden by retry or xfail.
P2 optional tests
validate the core module before and after Asyncify;
validate the final component with wasm-tools validate;
execute wasi:cli/command with the pinned Wasmtime version;
run the same scheduler/blocking fixture when the component pipeline is enabled.
Correctness stress
thousands of repeated yield/park/ready transitions;
channel close, select winner races, and semaphore contention;
goroutine return and Goexit reclamation;
panic/defer/recover across suspension points;
pointers held only by active, channel-waiting, timer-waiting, and runnable Gs;
memory growth while Gs are suspended;
pure Go loops responding to safe-point requests;
host callbacks arriving during unwind/rewind without synchronous re-entry.
Resource and performance gates
All local and CI stress runs must use explicit concurrency and memory bounds. Initial gates are regression-oriented rather than promises of Go-runtime parity:
no host thread or Worker allocation per G in single-worker builds;
bounded per-G stack and Asyncify reservation reported by tests/benchmarks; measure simultaneously blocked working sets of 1, 100, 1,000, and 10,000 Gs rather than only serial goroutine lifecycles;
report reserved bytes/G, committed or resident bytes/G where the host exposes them, context-switch latency, scheduler throughput, and wasm code-size growth;
no heap allocation in the run-queue fast path;
no pthread key or condition variable per wasm channel wait;
context-switch, channel handoff, timer wake, bytes/G, allocation throughput, and GC pause measurements recorded before and after each runtime stage;
no unbounded build parallelism; local heavy tests use small GOMAXPROCS/-p values and stay within the agreed machine memory budget;
code size is compared independently for J32, J64, and P1 because their toolchains and data models differ.
every runtime-stage PR compares representative native and embedded binaries against its direct base using identical flags, and records text/data/total size rather than relying only on the final file size;
wasm-only source files, globals, initialization hooks, root registration, and host adapters must be excluded from native and embedded builds by normal source selection, with no dormant runtime state linked into those artifacts;
representative native and embedded startup plus goroutine/channel/sync benchmarks are compared with the direct base; a statistically significant runtime regression or unexplained code-size increase blocks the PR rather than being deferred to H.
A performance optimization cannot weaken wakeup ordering, GC reachability, or target coverage.
Acceptance criteria
Single-worker required milestone
J32, J64, and P1 execute the same goroutine scheduler tests.
A go statement does not create a pthread/Worker per G.
Channels, select, timers, and supported sync primitives block a G rather than the only worker.
Panic/defer/recover and Goexit remain correct across suspension.
The collector reclaims unreachable allocations and retains objects reachable only from suspended Gs.
A pure Go loop yields within a bounded interval after a scheduler/GC request.
Build configuration consumes and tests every scheduler/GC/post-link capability it declares.
Native and embedded artifacts do not link or initialize wasm-only runtime machinery, and show no unexplained code-size or runtime regression against the direct base.
Multi-worker milestone
Web runs a bounded worker pool with multiple Gs per worker and demonstrates parallel progress.
Started Asyncify contexts remain pinned until a backend explicitly supports migration.
All workers participate in STW and suspended roots remain live.
Browser CI covers threaded and single-worker artifacts, including required COOP/COEP deployment behavior.
Optional component milestone
-target wasip2 produces a validated, executable wasi:cli/command component without fake linux/arm runtime semantics.
Componentization occurs after Asyncify and does not change scheduler/GC behavior.
P2 tool and interface versions are pinned and upgraded explicitly.
Alternatives
Option
Result
Decision
one pthread/Worker per G
CPU parallelism but poor goroutine scale and still requires multi-thread GC
compatibility backend only
single-worker Asyncify
deployable concurrency without CPU parallelism; fixed C/Asyncify stack reservations and transformed code require explicit resource gates
required first backend/fallback
bounded Workers x Asyncify Gs
parallelism plus many pinned Gs; memory scales with simultaneously resident contexts
current Web M:N implementation, still experimental
official-Go-style resumable ABI
growable linear-memory Go stacks and no Asyncify save area, but requires a wasm-specific call ABI and compiler/linker/runtime support
preferred separate feasibility experiment
LLVM coroutines / compiler CPS
explicit frames and roots but requires suspend-effect propagation through direct, indirect, interface, closure, and generic calls
retain research branches; not a current dependency
JSPI
useful Web host-async optimization but not a G scheduler or WASI solution
Summary
Implement a complete LLGo WebAssembly runtime in reviewable stages, with one scheduler and GC core shared by the required execution targets:
llgo build -target wasmGOOS=js GOARCH=wasm llgo buildGOOS=wasip1 GOARCH=wasm llgo buildllgo build -target wasip2"Complete wasm runtime coverage" in the near-term plan means J32, J64, and P1 execute the same goroutine, synchronization, timer, panic, and GC semantics. P2 is an optional output adapter and does not block the required runtime work.
wasm-unknownis a freestanding/library target withscheduler=none; it receives compile/link regression coverage but is not counted as an executable Go runtime target.This proposal follows #1031 and uses the runtime-owned G/M/P boundary introduced by #2166. Asyncify is the first deployable continuation backend and remains the compatibility fallback; it is not the scheduler ABI or the permanent architecture. Alternative continuation implementations are evaluated behind the same wasm-only boundary and do not block the current runtime feature chain.
As of 2026-08-03, the deployable Asyncify path has draft implementations through runtime hardening in cpunion#100, #102, #105, #107, #111, #116, #119, #123, and #125. The opt-in Go-style resumable chain is also staged through K1 #134, K2 #142, K3 #143, and K4 #144. K2 removes steady-state frame-block churn; K3 composes the backend with bounded workers and multi-worker STW; K4 covers final indirect ABI, GC-root, C-export, pclntab, DWARF, reflection-metadata, and standard-library workloads. The required implementation and acceptance draft chain is complete, but K remains opt-in: resumable worker handoff is still 2.41x/1.73x slower for J32/J64 and forced J32 build time is 96.4% higher than Asyncify in the current measurements.
Direction and continuation backends
The architecture has three independent execution layers:
These layers must not be collapsed into one API. In particular, JSPI can replace an Asyncify host-Promise bridge but cannot enumerate suspended goroutine roots or switch between arbitrary runnable Gs. Web Workers provide physical parallelism but do not replace a continuation backend.
Backend selection is compile-time source selection. The common runtime must not store a Go interface, virtual dispatch table, Asyncify buffer, or unused run-queue link in every native or embedded G. Wasm compiler passes must return before changing non-wasm IR, and wasm-only globals, initialization, metadata, post-link tools, and runtime packages must not enter native or embedded artifacts.
Official Go wasm model
The official Go wasm backend does not use Asyncify, Emscripten Fiber, LLVM coroutines, or native Wasm stack switching. It implements a compiler/linker/runtime resumable ABI:
PC_F) and an in-function resume block (PC_B);PC_F/PC_Band re-enters the saved function and block through indirect dispatch.This avoids a separate fixed Asyncify save area and avoids whole-program suspend-effect coloring because all Go calls follow the resumable ABI. It is not a reusable library: the Go compiler, assembler, linker, stack growth, GC maps, traceback, panic/defer, reflection, and calling convention cooperate. LLGo also has ordinary LLVM/C ABI boundaries, so an LLGo prototype must initially define external C calls as non-suspending leaves or introduce explicit wrappers.
A Go-style LLGo backend is therefore a separate wasm-only experiment, not a rewrite of the current PR chain. It must first prove indirect calls, panic/defer/recover, stack growth, GC roots, debug information, LTO, and C-boundary behavior, then beat or materially improve Asyncify's bytes/G, switch latency, and code size before becoming a default.
Alternatives and maturity
Wasm data-model note
GOARCH=wasmis described by Go as a WebAssembly 32-bit target, while the Go wasm architecture uses 8-byteint, pointer, and register sizes and converts linear-memory addresses for wasm32 operations. LLGo currently uses two explicit profiles:-target wasmand an LLGo 32-bit data model.GOOS=js GOARCH=wasmsource selection and LLVM Memory64 to preserve the Go-width type layout.J64 is not the official Go wasm binary ABI; it is LLGo's current LLVM-compatible mapping of Go-width types. Changing that mapping is independent from continuation selection. An exact official-style wasm32-memory/64-bit-Go-pointer ABI belongs to the resumable-ABI experiment and must not be folded into #2192 without separate compatibility and migration analysis.
Current status
As of 2026-08-03:
main.50866cd89has 39 passing checks across Ubuntu, macOS, Go 1.24/1.26, wasm runtime execution, LTO, coverage, release artifacts, and embedded targets; only release publication is intentionally skipped. The old upstream #2197 draft remains frozen.+674/-182; native/embedded outputs remain unchanged.+581/-16; raw P1 dynamic reflection calls and full post-link DWARF range rewriting remain explicit boundaries.mainand its complete CI is green; ssa/wasm: use static defer continuation dispatch #2208 remains the independent clean SSA prerequisite. W is rebuilt as cpunion/llgo#100 over an explicit A + X fork base, with an independent diff of +1,438/-105. B is rebuilt from that validated W head as cpunion/llgo#102, with an independent diff of +892/-70; its complete fork CI has 39 passing checks and one expected release skip. T is rebuilt independently from W as cpunion/llgo#105, with an independent diff of +546/-62; its complete fork CI has 39 passing checks and one expected release skip. C is rebuilt from B as cpunion/llgo#107, with an independent diff of +1100/-84; its complete fork CI has 39 passing checks and one expected release skip. D is rebuilt from the combined B + T base as cpunion/llgo#111, with an independent diff of +1859/-68; its complete fork CI has 39 passing checks and one expected release skip. S is rebuilt from the combined C + D stack as cpunion/llgo#116, with an independent diff of +786/-30; its complete fork CI has 39 passing checks and one expected release skip. M1 is rebuilt from S as cpunion/llgo#119, with an independent diff of +2052/-118; its complete fork CI has 39 passing checks and one expected release skip. M2 is rebuilt from this validated M1 head as cpunion/llgo#123, with an independent diff of +687/-120; local validation passes; fork CI has 39 successful checks and one expected release skip. H is rebuilt from M2 as cpunion/llgo#125, with an independent diff of +366/-4; local J32/J64/P1, worker, optimization, 10,000 blocked-G, macOS/Ubuntu, and artifact-isolation validation passes, and fork CI has 39 successful checks and one expected release skip. K single-worker integration is cpunion/llgo#134, based on H, with an independent diff of +7,202/-56; it does not alter the Asyncify production chain. K2 is cpunion/llgo#142, independent diff+726/-90; K3 is cpunion/llgo#143, independent diff+674/-182; K4 is cpunion/llgo#144, independent diff+581/-16. Each stage has completed its bounded local target matrix and full fork CI.golowering behindruntime.NewProcand introduces replaceable G/M/P runtime state while preserving the native one-pthread-per-G backend.Gosched, park/ready probes, normal return,Goexit, panic/defer/recover, G/M/P ownership, and distinct J32/J64 data models. After the latestmainintegration, bounded local J32/J64/P1, native, and Cortex-M validation pass; the complete Ubuntu/macOS CI matrix and Codecov pass.blockaddress/indirectbrwith a static selector on wasm, avoiding an LLVM 19 WASI SelectionDAG crash after compiler/runtime: add //llgo:tls and //llgo:gls package variables #2079 linked locality initializers. Non-wasm lowering is unchanged. Its full Ubuntu/macOS CI matrix and Codecov pass.runtime/internal/wasmcontext. The scheduler supplies root-aware allocate/free callbacks only during cold creation/destruction; directSwap/Resume/Suspendcalls remain unchanged. macOS J32/J64/P1 execution, the runtime module, native LLGo acceptance, and a resource-limited Ubuntu package/selection check pass locally. Storage and run-queue units are 100% covered.LLGO_WASI_THREADS=1retains the historical pthread backend and skips Asyncify.-O0,-O3, ThinLTO, and Ubuntu 24.04/Wasmtime. Its bounded local matrix, full Ubuntu/macOS CI matrix, and Codecov pass. Full LTO has a pre-existing LLVM 19 SelectionDAG crash reproduced on the unmodified runtime/wasm: add single-worker Asyncify scheduler #2192 base.emscripten_sleepwakeups, and default P1pollwakeups. Host waits return to the scheduler before Go callbacks run, so Asyncify is never synchronously re-entered. Explicit P1 pthread behavior remains unchanged.time.Sleep, active/stopped/reset/expired timers, tickers,AfterFunc, timeout select, wall time, and local time zones on J32, J64, and P1. It also includes the required official//go:wasmimportlowering and current Emscripten embind invoker compatibility. Static wasm defer dispatch is supplied independently by ssa/wasm: use static defer continuation dispatch #2208.wasm-tools; timer fixtures pass at-O0/-O3, with J64/P1 ThinLTO also covered. The timer queue is 93.9% covered and measures 7.653-8.021 ns/op, 0 B/op, 0 allocs/op on Apple M4 Max. Against cl: qsort example #102, default no-time scheduler artifacts change by J32 +6 B, J64 +6 B, and P1 +2,794 B; native sections are unchanged and the Cortex-M4 ELF is byte-identical. Its complete fork CI has 39 passing checks and one expected release skip.llgo_wasm_gctag. Default wasm still selectsnogcbecause live pointers may remain only in wasm locals and suspended-G roots are not yet published; D provides compiler-maintained roots; default enablement remains a later step after cooperative safepoint and stress coverage. Threaded P1 selection is rejected because this collector is intentionally single-worker.wasm-runtimeinput, tagged T adds 10,674 B (+9.0%) for J32, 16,689 B (+12.3%) for J64, and 14,061 B (+13.1%) for P1. Fork CI has 39 passing checks and one expected release skip.wasmcontextstack ownership. J32/J64/P1 suspended-G GC fixtures pass at default optimization and-O0/-O3; J64/P1 ThinLTO, macOS/Ubuntu package tests, coverage, and native/embedded size checks also pass locally. Fork CI has 39 passing checks and one expected release skip. Cooperative preemption polls and default GC enablement remain later work.-O0and-O3, J64/P1 ThinLTO, existing scheduler/blocking/timer fixtures, timer-triggered GC of an active CPU-bound G, channel-waiting roots, panic/recover, reclamation, aligned allocation, and memory growth. Full macOS Go 1.26.5ssa,internal/build, and runtime tests pass; Ubuntu 24.04/amd64 focused compiler and complete runtime tests pass under 2 CPU/6 GiB limits. Planner and poll-budget coverage are 100%; native section sizes are unchanged and Cortex-M4 output is byte-identical. Fork CI has 39 passing checks and one expected release skip.LLGO_WASM_WORKERS=N(2 <= N <= 16), one permanent M/P/run queue/system context and locality owner per worker, round-robin G ownership without migration, worker-safe channel/semaphore/notify/timer paths, worker-0 host-event ownership, and context-owned continuation storage. The default value1is inert.internal/wasmworkersis 100% covered. Native sections are unchanged and Cortex-M4 output is byte-identical. M1 changes 39 files by +2052/-118 after folding the context-owned lifecycle adaptation into the implementation commit; fork CI has 39 passing checks and one expected release skip.main.mainas the schedulable main G through Emscripten's proxy-main entry. J32 and J64 execute under Node and cross-origin-isolated Chrome with COOP/COEP headers.//llgo:glsusers per worker. The old worker entry fails that regression withruntime: local context changed by nested entry; the persistent per-worker locality owner removes the strict-nesting assumption without adding a context-switch hot-path operation. A 30-run stress loop covers the Ready-before-Park false-deadlock regression.132/0/10.-O0, J64-O3with ThinLTO and FullLTO, and single-worker J32/J64/P1 GC. Full macOSssa,cl, andinternal/buildtests pass; focused Go 1.24 tests and a 2 CPU/6 GiB Ubuntu amd64 runtime matrix pass. Native file/section sizes are unchanged and the Cortex-M4 empty ELF is byte-identical at 1,804 B with text/data/bss140/0/10. Fork CI has 39 successful checks and one expected release-publication skip; both coverage jobs pass, while external Codecov publishing is unavailable on the protected fork branch without a token.targets/wasip2.jsonexists, but several declared fields (buildmode,scheduler,gc, stack size, WIT package/world) are not represented byinternal/targets.Config, and the wasm/wasi special path incrosscompile.Usedoes not provide a complete component build. P2 remains optional and is not currently a supported runtime target.K resumable-ABI status
Draft chain: K1 cpunion/llgo#134 based on H, K2 cpunion/llgo#142, K3 cpunion/llgo#143, and K4 cpunion/llgo#144. The earlier core-only cpunion/llgo#127 is superseded. Independent diffs are K1
+7,202/-56, K2+726/-90, K3+674/-182, and K4+581/-16.LLGO_WASM_RESUME=1selects K for J32, J64, or P1. It disables Asyncify while preserving the exception-encoding post-link stage required by LLVM 19 SjLj. Default Asyncify wasm, native, embedded, and explicit P1 pthread builds remain unchanged. The independent #134 diff over H is 28 commits across 78 files (+7,202/-56).Compiler and link flow
internal/buildresolves the target and K selector, adds the privatellgo.wasm_resumebuild tag, fingerprints the selector for the build cache, and disables Asyncify.ssamarks resumableInGodefinitions and actual generated Go calls. Runtime ABI implementation functions, real//go:wasmimportdeclarations, and C boundaries remain synchronous leaves; same-name LLGo patch bodies are not incorrectly converted into imports.internal/wasmresumeinventories each marked function once, assigns deterministic resume IDs, computes CFG liveness including PHI-edge uses, lays out persistent slots, and emits leaf or state-machine entries.main.mainrun as the root resumable task.wasm-opt --translate-to-exnrefwithout--asyncify, so Wasmtime 39 consumes standardized exception instructions.Direct calls use descriptor-known typed frames. Indirect calls, closures, interface methods, and reflected method values use generated
__llgo_wasm_start.<function>entries while retaining LLGo's existing two-word{code, data}function-value layout. Cross-package descriptors inherit source linkage, so ordinary definitions remain external and generic/linkonce definitions do not become duplicate strong symbols.Runtime memory layout
Each generated function has one immutable descriptor:
Each invocation has a typed frame whose common prefix is:
The public prefix is therefore 12 bytes on wasm32 and 24 bytes on wasm64. One private machine word immediately before the frame records the previous arena stack pointer; it does not enlarge the public ABI prefix. A measured leaf with one
i64parameter and onei64result occupies 36 bytes on wasm32 and 48 bytes on wasm64 including that private word, before allocation-alignment padding.One logical G owns:
Frame storage is a lazy per-context segmented arena. The first touched frame allocates one 2 KiB GC-root block; overflow adds stable segments without moving existing frames. Normal LIFO completion rewinds the bump pointer, drops empty child segments, and retains the root segment. Dynamic allocas use the same context-owned storage, so suspension never leaves a pointer into an expired native stack. Explicit over-alignment is preserved.
Unlike the Asyncify backend, K does not reserve a 64 KiB C stack plus a 64 KiB Asyncify save area for every G. Package and function frames are allocated only when called.
Execution flow
Normal call and return:
Normal dispatch is O(1): it reads
Context.top, calls one resume entry, and switches on the current frame PC. It does not traverse the parent chain.SuspendCurrentstores the continuation PC and returnsSuspendwithout allocating another frame; the scheduler later re-enters the same top frame.gocreates a runtime G with its ownContextand start frame. The H single-worker G/M/P scheduler owns one run queue and changes G state aroundGosched, park/ready, completion,Goexit, and deadlock. Channel, select,sync, timer, and safepoint paths reuse B/C/S policy and suspend through the selected continuation operation.Before
Context.Run, the scheduler installs the selected G's registered compiler-root context; after return or suspension it restores the scheduler root. Inactive Gs remain in the synchronized root registry. JS timer callbacks carry a generation and can only re-enter the single-worker scheduler once; stale callbacks are rejected. P1 keeps synchronous host waiting.K3 retains this scheduler contract for worker builds. Each worker installs its identity and locality owner, restores the selected resumable context's compiler-root chain, runs it, and captures the chain again on suspension. Runnable contexts may move before first execution, but a started context remains pinned according to the existing M1 policy. STW waits occur through a non-suspending host callback, so no suspended Go frame retains a scheduler lock. Asyncify and resumable worker helpers are selected at build time; native and embedded targets gain no continuation interface or storage.
Panic/defer/recover and
Goexituse an exceptional cold path:sigsetjmpcatch surrounds a scheduler context run. Resumable defer frames use no per-defer native jump buffer.Rethrowtransfers to the scheduler catch.Context.Unwindscans the explicit parent chain only on this exceptional path, reclaims child frames, and redirects the owning frame to its generated defer state.Goexit.This changed the measured wasm32 frame for the suspend-then-panic acceptance function from 228 bytes with a native jump buffer to 40 bytes with the owner slot.
Current optimizations and measurements
End-to-end hardening results use the same fixture and default optimization on Apple M4 Max. Runtime values are medians from five alternating processes with 1,000 blocked Gs:
HeapSys. P1 changes from about 1.316 GiB live heap to about 25.5 MiB.MemStatsinstrumentation around the J32 channel loop measures 20,739 B/op and 12.010 allocations/op for K versus 93 B/op and 2.001 allocations/op for Asyncify. A 100 us V8 profile attributes about 65% of wasm samples to allocator/bitmap paths.K1's helper microbenchmarks did not represent the full path: a deep channel call chain overflowed the 2 KiB root block and repeatedly allocated and dropped child blocks. K2 retains high-water blocks and emits package-local O(1) bump/rewind helpers. On the same J32 handoff workload, K1 measured 32.007 us/op, K2 5.643 us/op, and Asyncify 2.776 us/op. K2 reduced the workload from about 20,739 B/op and 12.01 allocs/op to about 80 B/op and 2.00 allocs/op; J32/J64/P1 artifact deltas against K1 are +0.57%/+0.28%/-0.94%.
K3 worker hardening medians are 13.112 us/op for J32 and 10.755 us/op for J64, versus 5.447 us/op and 6.222 us/op for Asyncify workers (2.41x and 1.73x slower). Its J32/J64 artifacts are 14.54%/13.02% smaller, while forced J32 build time is 30.99 s versus 15.78 s (+96.4%). These results keep K opt-in despite its memory and size advantages. K4 changes acceptance and lowering correctness rather than the backend selection gate.
Validation
internal/wasmresumeis 95.2% andssais 93.3%.Remaining work
The required K implementation and acceptance draft chain through K4 is complete. The remaining work is merge/default-readiness rather than another required proposal stage:
reflect.Value.Callorreflect.MakeFunc; K4 currently covers reflection metadata, typed methods, and map operations;.debug_info/.debug_linepresence;P2 component output, P3 native WASI async, JSPI, and standardized Wasm stack switching remain optional adapters and do not block the required J32/J64/P1 runtime chain.
Goals
sync, panic/defer/recover, and GC behavior on J32, J64, and P1.Non-goals
wasmas an executable Go runtime target.Architecture
Package boundaries
The implementation is split by responsibility:
runtime/internal/runtimeowns G/P/M state, run queues, goroutine state transitions, scheduling policy, and the opaque park/ready handle used by runtime primitives.emscripten_sleep; default P1 waits withpoll. Go callbacks execute only after the host wait returns to the scheduler, never by synchronous re-entry during Asyncify unwind/rewind.internal/crosscompiledescribes target/toolchain capabilities.internal/buildorchestrates link and post-link stages. Runtime source selection must use normal GOOS/GOARCH tags where possible; target names are not a substitute for runtime platform checks.No Emscripten, WASI, Binaryen, channel, timer, and GC policy should be combined in one package.
Runtime execution flow
For each package, LLGo compiles normal Go functions and lowers a
go f(x)statement to a runtime startup record plusruntime.NewProc. The package does not generate a host thread or a platform context itself.At program startup:
NewProcallocates target-independent G metadata, asks the selected continuation backend to create any required storage, and queues the new G.Goschedrequeues the running G. A blocking operation marks it waiting and does not requeue it. Exit marks it dead.P1 uses the same scheduler state machine. Only the context and host-event adapters differ.
Blocking flow
A channel, select case, semaphore, mutex, WaitGroup, or Cond wait stores an opaque scheduler waiter rather than a pthread condition variable:
Parkchanges the current G from running to waiting and enters the scheduler.Ready.Readychanges the G from waiting to runnable and appends it to its owning P queue.The common channel/select algorithms remain shared with native builds. Only the mechanism that sleeps and wakes the execution owner changes.
Asyncify backend and linking
The following pipeline applies only when the Asyncify continuation backend is selected. J32/J64 currently let Emscripten perform Asyncify and use its Fiber API. P1 requires an explicit post-link transform:
main.mainon the main G context.wasm-opt --asyncify --translate-to-exnreffirst instruments the complete core module and then converts legacy EH to standardized exnref-based EH accepted by Wasmtime.The build must report a clear missing/incompatible Binaryen error. Debug/name information must be preserved according to LLGo's selected debug options. The Binaryen version is pinned in CI. The default P1 mode is single-worker and does not import shared memory or pthread host functions. Explicit
LLGO_WASI_THREADS=1remains a compatibility build mode: it selects the existing pthread backend and skips Asyncify post-link processing.If P2 is enabled later, componentization occurs after the core module has been linked and Asyncified:
LLVM/object -> core wasm link -> Asyncify -> Preview1 adapter/WIT componentization -> componentwasm-optis not run on the final component. Basic P2 support initially meanswasi:cli/command; other worlds are separate capabilities.Garbage collection
The initial wasm collector is non-moving, stop-the-world, and single-worker:
The maintained root ABI should use compiler-emitted root records or stack maps. A conservative Asyncify-area scan may be used only as a measured prototype, not as the sole long-term contract.
T deliberately remains behind the internal
llgo_wasm_gcsource-selection tag: linear-memory scans alone cannot observe a live pointer held only in an SSA/wasm local. D supplies compiler root records; S appends the internal tag by default only after enabling those roots and cooperative polling. Explicit P1 threads remain outside this single-worker collector.M2 extends this model without changing the compiler/runtime ownership boundary:
Locks whose critical paths can allocate use a worker mutex that periodically polls the GC request while contended. Allocation-free scheduler run queues retain their existing pthread mutex. This keeps STW cooperation separate from channel, timer, and scheduler policy.
Cooperative preemption
S adds wasm safe-point polls at declared Go function entries and DFS cycle-closing loop backedges. Runtime packages, cgo bodies,
//go:nosplitfunctions, and package-less synthetic forwarding wrappers are excluded. The inlined fast path decrements a single-worker budget; every 1,024 polls the slow path dispatches ready host events and yields only when another G is runnable. Existing blocking operations already park/yield, and allocation-triggered GC runs synchronously.Because J32/J64/default P1 use one worker, a collection on the selected G is naturally stop-the-world: every other G is suspended with its compiler root chain owned by its runtime context. Long C/host calls remain non-preemptible until the host adapter returns or exposes asynchronous readiness. M1 provides the bounded Web worker pool; M2 adds TLS root ownership and the request/acknowledgement handshake needed to collect across it.
Implementation plan
Each item below is one reviewable PR, normally containing several ordered commits. PRs are not split further merely to isolate individual files.
Transition impact on the current PR stack
Existing foundation
Implemented draft PRs
wasm-optpost-link processing, and the same basic scheduler execution probe under Wasmtime. This PR contains the build and runtime halves together because neither is useful or testable alone.sync/internal/synchooks through scheduler park/ready. Preserve pthread behavior on native targets and explicit P1 threads. Run one common fixture on J32, J64, and P1.time.Sleep, timers, tickers,AfterFunc, timeout select, monotonic deadlines, and non-reentrant host waiting. J32/J64 useemscripten_sleep; default P1 uses clocks/polling. Due callbacks are dispatched by the scheduler after host wait returns.llgo_wasm_gcgate until D. It stays independent from B/C to reduce stacked rebases, and all three profiles execute in the existing wasm-runtime CI job.wasmcontext.Contextlifecycle instead of restoring the old duplicate stack fields. C may merge before or after D.Optional host-thread work
Experimental continuation work
These experiments do not become dependencies of B/C/T/D/S/M1/M2 until they meet the same acceptance tests:
Optional component work
wasi:cli/commandcomponent from the already-tested P1 core, pinwasm-toolsand the Preview1 adapter, and run it with a compatible Wasmtime. It reuses W/B/C/T/D runtime behavior and does not block those PRs.async func,stream, andfuturemapping as a later host-I/O adapter, not as a rewrite of the scheduler core.Dependency graph
T remains independent from B/C but uses W for executable P1 post-link support. D depends on B and T. S is the integration point for C and D, so timer-driven scheduling and root publication are both present before default GC is enabled.
K is based on H only to reuse the complete runtime policy and acceptance fixture. No Asyncify-chain PR depends on K, so its merge order remains unaffected. K2 changes continuation storage only; K3 reuses M1/M2 worker and STW policy behind compile-time-selected concrete helpers rather than adding a second scheduler or GC implementation. K4 adds correctness and acceptance coverage without changing backend selection or exposing resumable ABI details outside wasm builds.
Test and CI matrix
Per required runtime PR
The three wasm variants run sequentially in one bounded CI job where practical, with explicit timeout and memory limits. Tests must not add skips for cases that previously ran. Tool download/network failures may retry, but semantic failures may not be hidden by retry or xfail.
P2 optional tests
wasm-tools validate;wasi:cli/commandwith the pinned Wasmtime version;Correctness stress
Goexitreclamation;Resource and performance gates
All local and CI stress runs must use explicit concurrency and memory bounds. Initial gates are regression-oriented rather than promises of Go-runtime parity:
GOMAXPROCS/-pvalues and stay within the agreed machine memory budget;A performance optimization cannot weaken wakeup ordering, GC reachability, or target coverage.
Acceptance criteria
Single-worker required milestone
gostatement does not create a pthread/Worker per G.syncprimitives block a G rather than the only worker.Goexitremain correct across suspension.Multi-worker milestone
Optional component milestone
-target wasip2produces a validated, executablewasi:cli/commandcomponent without fakelinux/armruntime semantics.Alternatives
References