Skip to content

runtime/wasm: add single-worker Asyncify scheduler - #2192

Open
cpunion wants to merge 8 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-single-worker-asyncify-scheduler
Open

runtime/wasm: add single-worker Asyncify scheduler#2192
cpunion wants to merge 8 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-single-worker-asyncify-scheduler

Conversation

@cpunion

@cpunion cpunion commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Part of #2152. #2166 is merged and provides the runtime-owned G/M/P boundary used here; the independent #2208 wasm defer-lowering prerequisite is also merged. Asyncify/Fiber is the first deployable continuation backend and compatibility fallback; it is not the scheduler ABI or the permanent architecture described by the updated proposal.

Problem

#2166 moves go statement creation behind runtime.NewProc, but its only execution backend is one pthread per G. js/wasm has no scheduler that can suspend multiple G contexts on one Worker, so a runnable WebAssembly goroutine path is still missing.

The two supported js/wasm entry points also need distinct, unambiguous data models:

  • GOOS=js GOARCH=wasm llgo build keeps Go's 64-bit word model and uses Emscripten Memory64;
  • llgo build -target wasm implies GOOS=js GOARCH=wasm but selects the configured wasm32 model.

They must not require redundant GOOS/GOARCH flags or share incompatible package-cache entries.

Change

  • keep the existing pthread lifecycle in a backend-specific file and preserve its 1:1 behavior;
  • add a js/wasm backend with one M, one P, an allocation-free FIFO run queue, and Emscripten Fiber contexts backed by Asyncify;
  • route runtime.Gosched, park/ready test points, normal return, panic/defer/recover, and runtime.Goexit through that scheduler;
  • preserve compiler/runtime: add //llgo:tls and //llgo:gls package variables #2079 owner teardown by keeping it in the pthread Goexit backend;
  • reclaim a dead G context only after execution has switched to another C stack;
  • resolve -target wasm through its target configuration before platform-dependent output and package loading;
  • use the existing tinygo.wasm target tag and target name for wasm32, without adding another environment variable or build tag;
  • emit raw GOOS=js GOARCH=wasm as wasm64-unknown-emscripten with MEMORY64=1, while the named target remains wasm32-unknown-emscripten;
  • select LP64 C long for Memory64 and 32-bit C long for the configured target;
  • make C ssize_t follow pointer width, avoiding an i32/i64 write signature mismatch under Memory64;
  • allow explicit .js/.mjs output and include Node in the existing Emscripten environment list;
  • select the existing nogc runtime for wasm targets, because BDWGC is unavailable there;
  • correct G/M/P ID allocation for LLGo atomics, whose Add returns the previous value;
  • release a partially allocated Fiber stack plus the new G argument and runtime context when Fiber setup fails.

The default per-G reservation is one 64 KiB C stack plus one 64 KiB Asyncify area. The main G allocates only its Asyncify area on first switch. No pthread or Worker is created per G.

The Emscripten Fiber API is used only as the first raw unwind/rewind context adapter. The scheduler does not use Promise-owned Asyncify flow. A later PR owns the host-neutral context lifecycle so Go-style resumable ABI and standardized stack-switching experiments can be evaluated without changing scheduler policy. Backend selection remains compile-time source selection, with no interface dispatch or Asyncify state in native/embedded Gs. #2079 currently maps TLS and GLS to one physical owner; separating GLS per logical G on a multiplexed worker requires a later locality-layout change and is not partially implemented here.

Execution flow

  1. NewProc allocates G state and two stack areas, initializes a Fiber, and appends the G to the local FIFO.
  2. Gosched requeues the current G; park leaves it waiting; exit marks it dead.
  3. The scheduler assigns the shared M/P to the next runnable G and swaps Fiber contexts.
  4. After the new or resumed G is running on its own C stack, it releases the previously retired G context.

Scope

This is the single-Worker execution slice, not complete GOOS=js support. It does not add channels, select, timers, host async I/O, safe-point preemption, GC roots, browser Workers, or a WASI Asyncify scheduler. The existing WASI path is covered only as a build regression.

Memory64 is currently limited to the Emscripten js host. The pinned WASI SDK 25 provides only wasm32 sysroots and libraries, so raw wasip1/wasm retains its existing wasm32 compatibility path.

The independent diff over current main is +1294/-173 across 40 files. 497 added lines are tests and CI fixtures; the pthread portion is primarily a mechanical move out of the common lifecycle file. The latest main integration keeps the merged TLS/GLS runtime ownership model, excludes only js/wasm from the native TLS getg implementation, and leaves intrusive run-queue state in the wasm platform context instead of every native G.

Validation

  • Rebased onto xgo-dev/llgo@e82e95fbe; current head 6e2f57576. internal/build, internal/crosscompile, ssa, the runtime Emscripten/runqueue packages, native locality/Goexit acceptance, and J32/J64 normal/deadlock scheduler execution pass locally with GOMAXPROCS=2, GOMEMLIMIT=4GiB, and -p=1.

  • macOS arm64, Go 1.24.2 and Go 1.26.5, Emscripten 4.0.21, Node 25.2.1:

    • llgo build -target wasm produces wasm32 and executes the scheduler fixture;
    • GOOS=js GOARCH=wasm llgo build produces Memory64 and executes the same fixture;
    • each fixture asserts its expected uintptr and C long sizes (4 or 8 bytes);
    • both pass FIFO yield, park/ready, shared M/P ownership, panic/recover, defer, and Goexit checks.
    • both also verify that a worker exiting while main is parked reports a deadlock and terminates with status 2.
  • macOS arm64: focused build/crosscompile/SSA tests, runtime module tests/build, runtime: introduce G/M/P pthread backend #2166 native test/llgoext, raw js/wasm, and raw wasip1/wasm builds pass.

  • Pre-rebase integration (8ba692562), macOS arm64, Go 1.26.5: internal/build, internal/crosscompile, and ssa pass with 76.8%, 80.9%, and 93.2% package coverage. defaultBuildTags and effectiveTypeSizes are 100% covered. The runtime run queue, Emscripten wrapper, and runtime package tests pass.

  • The same integration builds and executes J32 and J64 normal/deadlock scheduler fixtures with Emscripten 4.0.21 and Node 25.2.1, executes a native println/fmt/C fixture, and builds a Cortex-M0 bare-metal ELF under GOMAXPROCS=2, GOMEMLIMIT=6GiB, and -p=1.

  • Ubuntu arm64 container, Go 1.26.5, --memory=15g --cpus=2: focused build/crosscompile/SSA tests and runtime module tests pass.

  • CI pins Node 25 and runs both wasm32 and Memory64 scheduler artifacts under Node for Go 1.24.2 and Go 1.26.5; the all-target sweep installs the same pinned Emscripten toolchain and also builds the configured wasm target.

  • The reduced test: parallelize llgo test packages #2193 matrix, in-command parallelism, and current main timeout policy are unchanged. main now supplies a 45-minute macOS / 30-minute Ubuntu test budget; this PR only adds the two scheduler artifacts to the existing wasm-runtime job.

  • CI for the pre-rebase integration (8ba692562) is complete: 40 checks pass across Ubuntu, macOS, both supported Go versions, wasm runtime execution, LTO, release artifacts, coverage, and Codecov. The release publication job is intentionally skipped for a PR. The local amd64 Ubuntu image runs through QEMU on Apple Silicon and reached only environment-speed 10-minute timeouts in LLVM object emission and first-time ESP libc construction, with no assertion failure or memory pressure.

  • After the continuation-boundary review, macOS J32/J64 normal and deadlock fixtures pass, native test/llgoext passes, and the runtime module passes in an offline container capped at 2 GiB/2 CPUs. The broader Ubuntu root-module run was stopped after its root-only unreadable-file test failed under container root; GitHub's non-root Ubuntu matrix remains the authoritative Linux integration run.

  • runtime/internal/runqueue has 100% statement coverage; target resolution, type-model selection, target triples, output naming, and both fixture model assertions have focused coverage.

No test or previously runnable path is skipped or ignored.

@cpunion
cpunion force-pushed the codex/wasm-single-worker-asyncify-scheduler branch 3 times, most recently from c241c73 to e32655c Compare July 27, 2026 05:52
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cpunion
cpunion marked this pull request as ready for review July 27, 2026 15:06

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: single-worker Asyncify WASM scheduler

Solid, well-structured PR. The G/M/P backend split (pthread / wasm-fiber / baremetal) is cleanly partitioned by mutually-exclusive build tags, the intrusive runqueue is allocation-free and O(1), the emscripten_fiber_t layout is guarded by a _Static_assert, and retired-G reaping is correctly deferred to the next resumed G on every switch path. Test coverage (Node-executed scheduler test, G/M/P ownership tests) is good.

Findings below. The most important is #1 — the new scheduler's fail-fast guards do not actually fail fast, because the compiled fatal is a non-aborting stub. Inline comments carry the concrete locations.

1. (High) fatal() returns instead of aborting — every new scheduler guard falls through

The compiled fatal is the stub at runtime/internal/lib/runtime/.../stubs.go:119, which only prints and returns (the real aborting fatal in panic.go is inside a /* ... */ block spanning lines 260–1412, so it is not built). The new scheduler writes its invariant guards as fatal(...); return and assumes fatal never returns. Because it does return, illegal states continue executing:

  • resumeDeadWasmG (proc_wasm.go:222) unwinds a dead fiber normally after the trailing fatal.
  • casgstatus (proc_atomic.go:39-43) does not apply the CAS on an illegal transition, then every caller proceeds as if the new status took effect.
  • goschedBackend / gopark / goexitBackend return after an empty-runq fatal, leaving a _Gdead/_Gwaiting G as the running context.

Recommend either making the compiled fatal actually abort the process (e.g. abort/c.Exit) or replacing these fatal(...); return sites with an explicit abort. This is pre-existing at the stub, but this PR is where the assumption becomes load-bearing.

2. (Low) goexitBackend reports a false deadlock when a worker exits while main is parked

goexitBackend (proc_wasm.go:198) pops the runq before marking gp dead; if a normal goroutine returns while main is parked (empty runq), it hits the "no goroutines (main called runtime.Goexit) - deadlock!" path even though this is not main and not Goexit. Combined with #1 (the return is reached), the finished goroutine's fiber then unwinds with gp still _Grunning and unreaped. Please confirm the intended "worker exits while main waits" handling.

3. (Low) Inconsistent ID generation: nextGoid/nextMid add +1, nextPid does not

See inline comment on proc_atomic.go. Not currently a functional bug (tests only assert non-zero/unique), but the undocumented asymmetry is a latent hazard — make the three uniform or comment the intent.

4. (Low) Stale/misleading doc on the shared g type

See inline comment on runtime2.go:38. runtime2.go has no build tag, so this g is shared by all backends, yet the comment describes only the pthread per-G M/P model — the exact model the new wasm backend (single shared wasmSched.m/.p) is built to violate. The "when those facilities are added" sentence is also stale: this PR adds suspend/resume.

5. (Info) Gosched doc overstates behavior on pthread

Gosched (proc.go:104) is documented as yielding to another goroutine, but goschedBackend is a no-op on the pthread backend (proc_pthread.go:105). Consider noting that yielding is backend-dependent.

Non-blocking follow-ups

  • No stack/context pool: each go on wasm does 2× AllocRoot (~128KB: 64KB G stack + ≥64KB asyncify stack) + 2× free per goroutine, never pooled. A free-list keyed on the default size would remove steady-state churn under high goroutine turnover.
  • resumeDeadWasmG is a near-duplicate of resumeWasmG but omits the nil-guards on old/next/next.context; consider factoring the shared switch sequence.

View job run

Comment thread runtime/internal/runtime/proc_wasm.go
Comment thread runtime/internal/runtime/proc_atomic.go
Comment thread runtime/internal/runtime/proc_atomic.go
Comment thread runtime/internal/runtime/proc_wasm.go Outdated
Comment thread runtime/internal/runtime/runtime2.go Outdated
@cpunion

cpunion commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up pushed in 8882b7c9f:

  • fatal now terminates through c.Exit(2), making scheduler/CAS guards fail-fast;
  • worker exit while main is parked is classified as all goroutines are asleep, after marking the worker dead;
  • G/M/P ID origins are documented and asserted as 1/1/0 under the LLGo pre-increment-value atomic semantics;
  • shared g and Gosched documentation now describe backend-specific ownership/yield behavior;
  • the existing wasm32 and Memory64 artifacts each run normal scheduling plus the fatal deadlock path, with no extra artifact build.

Local validation passed on macOS arm64 with Go 1.26.5 and an independent Go 1.24.11 GOROOT, Emscripten 4.0.21, and Node 25.2.1. The exact Go 1.24.2 artifact path remains covered by CI because its locally downloaded GOROOT lives under GOMODCACHE, where Go rejects LLGo source overlays.

@cpunion
cpunion force-pushed the codex/wasm-single-worker-asyncify-scheduler branch from 8882b7c to 0e631e5 Compare July 28, 2026 12:29
@cpunion cpunion changed the title runtime/wasm: add single-worker Asyncify scheduler (based on #2166) runtime/wasm: add single-worker Asyncify scheduler Jul 28, 2026
@cpunion

cpunion commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Disposition of the two non-blocking review follow-ups:

  • Stack/context pooling is intentionally deferred to the later wasm hardening/performance work. It changes retained-memory, reset, and lifetime behavior; this scheduler foundation keeps allocation and retirement explicit first.
  • resumeDeadWasmG remains separate from resumeWasmG: the dead-G path must never return to or reap on the retiring stack, while the ordinary path resumes and performs deferred reaping. Factoring the small shared sequence would obscure that lifecycle invariant without removing backend state.

@cpunion cpunion changed the title runtime/wasm: add single-worker Asyncify scheduler runtime/wasm: add single-worker Asyncify scheduler (based on #2215) Jul 29, 2026
@cpunion cpunion changed the title runtime/wasm: add single-worker Asyncify scheduler (based on #2215) runtime/wasm: add single-worker Asyncify scheduler Jul 30, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

6e2f57576767 | workflow run | long-term charts

Program measurements

Platform Workload File size vs main Build vs main Run vs main
Linux cprintf 18624 B +0.4% (worse) 294.046 ms -4.9% (better) 1.290 ms -5.7% (better)
Linux fmtprintf 1877728 B +0.0% (worse) 2.995 s -2.0% (better) 3.211 ms -10.5% (better)
Linux println 68288 B +0.3% (worse) 283.912 ms -7.4% (better) 1.589 ms -11.0% (better)
macOS cprintf 84672 B +0.0% 308.640 ms -0.2% (better) 2.647 ms +1.6% (worse)
macOS fmtprintf 1888208 B +0.0% 2.422 s +6.9% (worse) 11.760 ms +10.9% (worse)
macOS println 121200 B +0.0% 323.018 ms +13.2% (worse) 3.875 ms +11.4% (worse)
Core language and compiler benchmarks
Platform Benchmark ns/op vs main
Linux BenchmarkLookupPCRandom 13.300 ns/op +8.4% (worse)
Linux BenchmarkMergeCompilerFlags 151 ns/op +3.8% (worse)
Linux BenchmarkMergeLinkerFlags 94.530 ns/op -1.4% (better)
Linux BenchmarkChannelBuffered 34.720 ns/op -4.5% (better)
Linux BenchmarkChannelHandoff 30226 ns/op +29.4% (worse)
Linux BenchmarkDefer 47.600 ns/op +0.2% (worse)
Linux BenchmarkDirectCall 1.557 ns/op -11.4% (better)
Linux BenchmarkGlobalRead 1.556 ns/op -11.5% (better)
Linux BenchmarkGlobalWrite 2.480 ns/op -11.8% (better)
Linux BenchmarkGoroutine 31527 ns/op +3.3% (worse)
Linux BenchmarkInterfaceCall 8.094 ns/op -11.5% (better)
Linux BenchmarkRuntimeGetG 1.868 ns/op -11.6% (better)
macOS BenchmarkLookupPCRandom 11.480 ns/op +7.2% (worse)
macOS BenchmarkMergeCompilerFlags 102.900 ns/op +7.7% (worse)
macOS BenchmarkMergeLinkerFlags 66.560 ns/op +8.7% (worse)
macOS BenchmarkChannelBuffered 22.070 ns/op +7.9% (worse)
macOS BenchmarkChannelHandoff 6958 ns/op +7.8% (worse)
macOS BenchmarkDefer 29.990 ns/op +22.0% (worse)
macOS BenchmarkDirectCall 1.118 ns/op +7.6% (worse)
macOS BenchmarkGlobalRead 1.018 ns/op +0.4% (worse)
macOS BenchmarkGlobalWrite 1.020 ns/op +8.3% (worse)
macOS BenchmarkGoroutine 28981 ns/op -6.6% (better)
macOS BenchmarkInterfaceCall 4.880 ns/op +19.3% (worse)
macOS BenchmarkRuntimeGetG 2.099 ns/op +11.5% (worse)

Compared only with the latest matching platform in the main series.

@cpunion
cpunion force-pushed the codex/wasm-single-worker-asyncify-scheduler branch from 8ba6925 to 5fa7d6b Compare August 3, 2026 03:12
@cpunion
cpunion force-pushed the codex/wasm-single-worker-asyncify-scheduler branch 2 times, most recently from 32e1272 to 3085aa4 Compare August 4, 2026 01:25
@cpunion
cpunion force-pushed the codex/wasm-single-worker-asyncify-scheduler branch from 3085aa4 to 6e2f575 Compare August 9, 2026 14:39
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