diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml
index d97b0b837f..69913350c9 100644
--- a/.github/workflows/llgo.yml
+++ b/.github/workflows/llgo.yml
@@ -484,6 +484,12 @@ jobs:
test "$output" = "wasm timers ok"
}
+ run_wasm_workers() {
+ local output
+ output=$(node -e "import('$1').then(module => module.default()).catch(error => { console.error(error); process.exit(1); });" 2>&1)
+ grep -Fxq "wasm workers ok" <<<"$output"
+ }
+
run_wasi_timers() {
local output
wasm-tools validate --features all "$1"
@@ -508,6 +514,34 @@ jobs:
run_wasm_timers "$RUNNER_TEMP/wasm-timers.mjs"
GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-timers-wasip1.wasm" ./internal/build/testdata/wasm-timers
run_wasi_timers "$RUNNER_TEMP/wasm-timers-wasip1.wasm"
+ LLGO_WASM_WORKERS=2 GOOS=js GOARCH=wasm llgo build \
+ -o "$RUNNER_TEMP/wasm-workers-go.mjs" ./internal/build/testdata/wasm-workers
+ run_wasm_workers "$RUNNER_TEMP/wasm-workers-go.mjs"
+ LLGO_WASM_WORKERS=2 llgo build -target wasm \
+ -o "$RUNNER_TEMP/wasm-workers.mjs" ./internal/build/testdata/wasm-workers
+ run_wasm_workers "$RUNNER_TEMP/wasm-workers.mjs"
+ cp ./internal/build/testdata/wasm-workers/browser.html "$RUNNER_TEMP/browser.html"
+ node ./internal/build/testdata/wasm-workers/server.mjs "$RUNNER_TEMP" 8123 &
+ browser_server=$!
+ trap 'kill "$browser_server" 2>/dev/null || true' EXIT
+ for attempt in {1..50}; do
+ if curl -fsS http://127.0.0.1:8123/ >/dev/null; then
+ break
+ fi
+ sleep 0.1
+ done
+ curl -fsS http://127.0.0.1:8123/ >/dev/null
+ browser="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium || true)"
+ test -n "$browser"
+ for module in wasm-timers.mjs wasm-workers.mjs wasm-workers-go.mjs; do
+ html=$("$browser" --headless=new --no-sandbox --disable-gpu \
+ --disable-dev-shm-usage --virtual-time-budget=15000 --dump-dom \
+ "http://127.0.0.1:8123/browser.html?module=$module")
+ grep -Fq 'data-result="pass"' <<<"$html"
+ done
+ kill "$browser_server"
+ wait "$browser_server" || true
+ trap - EXIT
GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-gc-go.mjs" ./internal/build/testdata/wasm-gc
node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc-go.mjs'; await Module();"
llgo build -target wasm -o "$RUNNER_TEMP/wasm-gc.mjs" ./internal/build/testdata/wasm-gc
diff --git a/benchmark/wasm_workers/main.go b/benchmark/wasm_workers/main.go
new file mode 100644
index 0000000000..34209f68bc
--- /dev/null
+++ b/benchmark/wasm_workers/main.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+ "sync"
+ "time"
+)
+
+const (
+ lifecycleIterations = 10_000
+ channelIterations = 100_000
+ cpuIterations = 20_000_000
+)
+
+var cpuResult uint64
+
+func main() {
+ report("WasmGoroutineLifecycle", lifecycleIterations, benchmarkLifecycle())
+ report("WasmChannelRoundTrip", channelIterations, benchmarkChannel())
+ cpuResult = cpuWork(0)
+ report("WasmOneCPUJob", 1, benchmarkCPUJobs(1))
+ report("WasmTwoCPUJobs", 2, benchmarkCPUJobs(2))
+ if cpuResult == 0 {
+ panic("unexpected CPU benchmark result")
+ }
+}
+
+func benchmarkLifecycle() time.Duration {
+ done := make(chan struct{}, 1)
+ start := time.Now()
+ for range lifecycleIterations {
+ go func() {
+ done <- struct{}{}
+ }()
+ <-done
+ }
+ return time.Since(start)
+}
+
+func benchmarkChannel() time.Duration {
+ request := make(chan struct{})
+ response := make(chan struct{})
+ go func() {
+ for range channelIterations {
+ <-request
+ response <- struct{}{}
+ }
+ }()
+
+ start := time.Now()
+ for range channelIterations {
+ request <- struct{}{}
+ <-response
+ }
+ return time.Since(start)
+}
+
+func benchmarkCPUJobs(jobs int) time.Duration {
+ var wg sync.WaitGroup
+ results := make([]uint64, jobs)
+ wg.Add(jobs)
+ start := time.Now()
+ for i := range results {
+ go func() {
+ results[i] = cpuWork(uint64(i + 1))
+ wg.Done()
+ }()
+ }
+ wg.Wait()
+ cpuResult = 0
+ for _, result := range results {
+ cpuResult ^= result
+ }
+ return time.Since(start)
+}
+
+//go:noinline
+func cpuWork(value uint64) uint64 {
+ for i := uint64(0); i < cpuIterations; i++ {
+ value = value*1664525 + 1013904223 + i
+ }
+ return value
+}
+
+func report(name string, iterations int, elapsed time.Duration) {
+ println("Benchmark"+name+"-1", iterations, elapsed.Nanoseconds()/int64(iterations), "ns/op")
+}
diff --git a/internal/build/build.go b/internal/build/build.go
index a0ec84d95f..d412f957da 100644
--- a/internal/build/build.go
+++ b/internal/build/build.go
@@ -60,6 +60,7 @@ import (
"github.com/goplus/llgo/internal/pclnmap"
"github.com/goplus/llgo/internal/pclnpost"
"github.com/goplus/llgo/internal/typepatch"
+ "github.com/goplus/llgo/internal/wasmworkers"
"github.com/goplus/llgo/ssa/abi"
xenv "github.com/goplus/llgo/xtool/env"
gllvm "github.com/xgo-dev/llvm"
@@ -386,7 +387,11 @@ func Build(inv Invocation) ([]Package, error) {
if conf.Target != "" && export.GOARCH != "" {
conf.Goarch = export.GOARCH
}
- wasmGC, err := configureWasmGC(conf, &export)
+ wasmWorkers, err := configureWasmWorkers(conf, &export)
+ if err != nil {
+ return nil, err
+ }
+ wasmGC, err := configureWasmGC(conf, &export, wasmWorkers.Enabled())
if err != nil {
return nil, err
}
@@ -459,7 +464,7 @@ func Build(inv Invocation) ([]Package, error) {
prog.EnableGoGlobalDCE(conf.goGlobalDCEEnabled())
prog.EnableDeadcodeDrop(conf.deadcodeDropEnabled())
prog.EnableGCRoots(wasmGC)
- prog.EnableCooperativeSafepoints(wasmGC)
+ prog.EnableCooperativeSafepoints(wasmGC || wasmWorkers.Enabled())
if conf.PthreadStackSize > 0 {
prog.SetPthreadStackSize(uint64(conf.PthreadStackSize))
}
@@ -785,7 +790,37 @@ func defaultBuildTags(goarch, target string) string {
return tags
}
-func configureWasmGC(conf *Config, export *crosscompile.Export) (bool, error) {
+func configureWasmWorkers(conf *Config, export *crosscompile.Export) (wasmworkers.Config, error) {
+ config, err := wasmworkers.Parse(os.Getenv(llgoWasmWorkers))
+ if err != nil {
+ return config, err
+ }
+ if err := config.ValidateTarget(conf.Goos, conf.Goarch); err != nil {
+ return config, err
+ }
+ if !config.Enabled() {
+ return config, nil
+ }
+ workers := strconv.Itoa(config.Count)
+ poolSize := strconv.Itoa(config.Count)
+ preJS := wasmworkers.PreJSPath(env.LLGoROOT())
+ if _, err := os.Stat(preJS); err != nil {
+ return config, fmt.Errorf("locate WebAssembly worker host shim: %w", err)
+ }
+ export.BuildTags = append(export.BuildTags, "llgo.wasm_workers")
+ export.CCFLAGS = append(export.CCFLAGS, "-pthread", "-DLLGO_WASM_WORKERS="+workers)
+ export.LDFLAGS = append(export.LDFLAGS,
+ "--pre-js", preJS,
+ "-pthread",
+ "-sPTHREAD_POOL_SIZE="+poolSize,
+ "-sPROXY_TO_PTHREAD=1",
+ "-sEXIT_RUNTIME=1",
+ )
+ export.WasmRuntime.RunMainTask = true
+ return config, nil
+}
+
+func configureWasmGC(conf *Config, export *crosscompile.Export, wasmWorkers bool) (bool, error) {
explicit := hasBuildTag(conf.Tags, "llgo_wasm_gc")
if conf.Goarch != "wasm" {
if explicit {
@@ -795,6 +830,12 @@ func configureWasmGC(conf *Config, export *crosscompile.Export) (bool, error) {
}
switch conf.Goos {
case "js":
+ if wasmWorkers {
+ if explicit {
+ return false, errors.New("llgo_wasm_gc does not yet support multiple WebAssembly workers")
+ }
+ return false, nil
+ }
if !slices.Contains(export.LDFLAGS, "-sMALLOC=none") {
export.LDFLAGS = append(export.LDFLAGS, "-sMALLOC=none")
}
@@ -2419,6 +2460,7 @@ const llgoTrace = "LLGO_TRACE"
const llgoOptimize = "LLGO_OPTIMIZE"
const llgoWasmRuntime = "LLGO_WASM_RUNTIME"
const llgoWasiThreads = "LLGO_WASI_THREADS"
+const llgoWasmWorkers = "LLGO_WASM_WORKERS"
const llgoStdioNobuf = "LLGO_STDIO_NOBUF"
const llgoFullRpath = "LLGO_FULL_RPATH"
const llgoBuildCache = "LLGO_BUILD_CACHE"
diff --git a/internal/build/build_test.go b/internal/build/build_test.go
index e780ef996e..cfc5c51ef5 100644
--- a/internal/build/build_test.go
+++ b/internal/build/build_test.go
@@ -31,6 +31,7 @@ import (
"github.com/goplus/llgo/internal/meta"
"github.com/goplus/llgo/internal/mockable"
"github.com/goplus/llgo/internal/packages"
+ "github.com/goplus/llgo/internal/wasmworkers"
llssa "github.com/goplus/llgo/ssa"
"github.com/xgo-dev/llvm"
)
@@ -344,7 +345,7 @@ func TestConfigureWasmGC(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
export := crosscompile.Export{}
- enabled, err := configureWasmGC(&test.conf, &export)
+ enabled, err := configureWasmGC(&test.conf, &export, false)
if (err != nil) != test.err {
t.Fatalf("configureWasmGC error = %v, want error %v", err, test.err)
}
@@ -364,7 +365,7 @@ func TestConfigureWasmGC(t *testing.T) {
func TestConfigureWasmGCRejectsWASIThreads(t *testing.T) {
t.Setenv("LLGO_WASI_THREADS", "1")
conf := Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"}
- if _, err := configureWasmGC(&conf, &crosscompile.Export{}); err == nil {
+ if _, err := configureWasmGC(&conf, &crosscompile.Export{}, false); err == nil {
t.Fatal("expected llgo_wasm_gc with WASI threads to fail")
}
}
@@ -372,7 +373,7 @@ func TestConfigureWasmGCRejectsWASIThreads(t *testing.T) {
func TestConfigureWasmGCLeavesWASIThreadsDisabled(t *testing.T) {
t.Setenv("LLGO_WASI_THREADS", "1")
conf := Config{Goos: "wasip1", Goarch: "wasm"}
- enabled, err := configureWasmGC(&conf, &crosscompile.Export{})
+ enabled, err := configureWasmGC(&conf, &crosscompile.Export{}, false)
if err != nil {
t.Fatal(err)
}
@@ -381,6 +382,66 @@ func TestConfigureWasmGCLeavesWASIThreadsDisabled(t *testing.T) {
}
}
+func TestConfigureWasmWorkers(t *testing.T) {
+ t.Setenv(llgoWasmWorkers, "4")
+ conf := Config{Goos: "js", Goarch: "wasm"}
+ export := crosscompile.Export{}
+ config, err := configureWasmWorkers(&conf, &export)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.Count != 4 || !config.Enabled() {
+ t.Fatalf("worker config = %+v, want four enabled workers", config)
+ }
+ for _, flag := range []string{"-pthread", "-DLLGO_WASM_WORKERS=4"} {
+ if !slices.Contains(export.CCFLAGS, flag) {
+ t.Fatalf("CCFLAGS do not contain %q: %v", flag, export.CCFLAGS)
+ }
+ }
+ for _, flag := range []string{"-pthread", "-sPTHREAD_POOL_SIZE=4", "-sPROXY_TO_PTHREAD=1", "-sEXIT_RUNTIME=1"} {
+ if !slices.Contains(export.LDFLAGS, flag) {
+ t.Fatalf("LDFLAGS do not contain %q: %v", flag, export.LDFLAGS)
+ }
+ }
+ preJS := wasmworkers.PreJSPath(env.LLGoROOT())
+ if i := slices.Index(export.LDFLAGS, "--pre-js"); i < 0 || i+1 == len(export.LDFLAGS) || export.LDFLAGS[i+1] != preJS {
+ t.Fatalf("LDFLAGS do not select worker host shim %q: %v", preJS, export.LDFLAGS)
+ }
+ if !slices.Contains(export.BuildTags, "llgo.wasm_workers") {
+ t.Fatalf("BuildTags do not select the worker runtime: %v", export.BuildTags)
+ }
+ if !export.WasmRuntime.RunMainTask {
+ t.Fatal("worker runtime does not run main as a schedulable task")
+ }
+}
+
+func TestConfigureWasmWorkersDefaultIsInert(t *testing.T) {
+ conf := Config{Goos: "linux", Goarch: "amd64"}
+ export := crosscompile.Export{}
+ config, err := configureWasmWorkers(&conf, &export)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.Enabled() || len(export.CCFLAGS) != 0 || len(export.LDFLAGS) != 0 || len(export.BuildTags) != 0 {
+ t.Fatalf("default worker config changed native build: config=%+v export=%+v", config, export)
+ }
+}
+
+func TestConfigureWasmWorkersRejectsUnsupportedTarget(t *testing.T) {
+ t.Setenv(llgoWasmWorkers, "2")
+ conf := Config{Goos: "wasip1", Goarch: "wasm"}
+ if _, err := configureWasmWorkers(&conf, &crosscompile.Export{}); err == nil {
+ t.Fatal("WASI worker configuration succeeded")
+ }
+}
+
+func TestConfigureWasmWorkersRejectsCurrentGC(t *testing.T) {
+ conf := Config{Goos: "js", Goarch: "wasm", Tags: "llgo_wasm_gc"}
+ if _, err := configureWasmGC(&conf, &crosscompile.Export{}, true); err == nil {
+ t.Fatal("multi-worker wasm GC configuration succeeded before M2")
+ }
+}
+
func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) {
runtimeDir := filepath.Join(env.LLGoRuntimeDir(), "internal", "lib", "runtime")
for _, goos := range []string{"js", "wasip1"} {
diff --git a/internal/build/collect.go b/internal/build/collect.go
index 66da887c21..7f3861cb64 100644
--- a/internal/build/collect.go
+++ b/internal/build/collect.go
@@ -86,6 +86,7 @@ func (c *context) collectEnvInputs(m *manifestBuilder) {
llgoOptimize,
llgoWasmRuntime,
llgoWasiThreads,
+ llgoWasmWorkers,
llgoStdioNobuf,
llgoFullRpath,
}
diff --git a/internal/build/main_module.go b/internal/build/main_module.go
index 02d8ee9e84..4ddb977cdc 100644
--- a/internal/build/main_module.go
+++ b/internal/build/main_module.go
@@ -128,7 +128,7 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g
}
var wasmRunMain llssa.Function
- if ctx.crossCompile.WasmPostLink.Asyncify {
+ if ctx.crossCompile.WasmPostLink.Asyncify || ctx.crossCompile.WasmRuntime.RunMainTask {
defineWasmMainTask(mainPkg, mainInit, mainMain)
wasmRunMain = declareNoArgFunc(mainPkg, rtPkgPath+".RunWasmMain")
}
@@ -143,7 +143,7 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g
abiInit: abiInit,
})
- if needStart(ctx) {
+ if needStart(ctx) && !ctx.crossCompile.WasmRuntime.RunMainTask {
defineStart(mainPkg, entryFn, argvValueType)
}
@@ -248,7 +248,8 @@ type entryFunctions struct {
func defineEntryFunction(ctx *context, pkg llssa.Package, argcVar, argvVar llssa.Global, argvType llssa.Type, fns entryFunctions) llssa.Function {
prog := pkg.Prog
entryName := "main"
- if !needStart(ctx) && isWasmTarget(ctx.buildConf.Goos) {
+ if isWasmTarget(ctx.buildConf.Goos) &&
+ (!needStart(ctx) || ctx.crossCompile.WasmRuntime.RunMainTask) {
entryName = "__main_argc_argv"
}
sig := newEntrySignature(argvType.RawType())
diff --git a/internal/build/main_module_test.go b/internal/build/main_module_test.go
index b577b12cdd..97b081261a 100644
--- a/internal/build/main_module_test.go
+++ b/internal/build/main_module_test.go
@@ -99,6 +99,36 @@ func TestGenMainModuleWASIAsyncifyEntry(t *testing.T) {
}
}
+func TestGenMainModuleWasmWorkerEntry(t *testing.T) {
+ llvm.InitializeAllTargets()
+ t.Setenv(llgoStdioNobuf, "")
+ ctx := &context{
+ prog: llssa.NewProgram(nil),
+ buildConf: &Config{
+ BuildMode: BuildModeExe,
+ Goos: "js",
+ Goarch: "wasm",
+ },
+ crossCompile: crosscompile.Export{
+ WasmRuntime: crosscompile.WasmRuntime{RunMainTask: true},
+ },
+ }
+ pkg := &packages.Package{PkgPath: "example.com/foo", ExportFile: "foo.a"}
+ ir := genMainModule(ctx, llssa.PkgRuntime, pkg, &genConfig{}).LPkg.String()
+ if !strings.Contains(ir, "define hidden i32 @__main_argc_argv(") {
+ t.Fatalf("worker module missing Emscripten host entry:\n%s", ir)
+ }
+ if !strings.Contains(ir, `call void @"github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain"()`) {
+ t.Fatalf("worker host entry does not start the runtime main task:\n%s", ir)
+ }
+ if strings.Contains(ir, "define i32 @main(") {
+ t.Fatalf("worker module should let Emscripten provide main:\n%s", ir)
+ }
+ if strings.Contains(ir, "define weak void @_start()") {
+ t.Fatalf("worker module should let Emscripten provide _start:\n%s", ir)
+ }
+}
+
func TestGenMainModuleLibrary(t *testing.T) {
llvm.InitializeAllTargets()
t.Setenv(llgoStdioNobuf, "")
diff --git a/internal/build/testdata/wasm-workers/abi.go b/internal/build/testdata/wasm-workers/abi.go
new file mode 100644
index 0000000000..0cba615e3d
--- /dev/null
+++ b/internal/build/testdata/wasm-workers/abi.go
@@ -0,0 +1,17 @@
+package main
+
+import _ "unsafe"
+
+const LLGoFiles = "workers.c"
+
+//go:linkname parallelWorkerBarrier C.llgo_test_parallel_worker_barrier
+func parallelWorkerBarrier() int32
+
+//go:linkname parallelWorkerThread C.llgo_test_parallel_worker_thread
+func parallelWorkerThread(slot int32) uintptr
+
+//go:linkname currentWorkerThread C.llgo_test_current_worker_thread
+func currentWorkerThread() uintptr
+
+//go:linkname configuredWorkerCount C.llgo_test_worker_count
+func configuredWorkerCount() int32
diff --git a/internal/build/testdata/wasm-workers/browser.html b/internal/build/testdata/wasm-workers/browser.html
new file mode 100644
index 0000000000..a892d2e887
--- /dev/null
+++ b/internal/build/testdata/wasm-workers/browser.html
@@ -0,0 +1,25 @@
+
+
+
+
LLGo wasm runtime test
+running
+
+
diff --git a/internal/build/testdata/wasm-workers/main.go b/internal/build/testdata/wasm-workers/main.go
new file mode 100644
index 0000000000..0463fc3cb6
--- /dev/null
+++ b/internal/build/testdata/wasm-workers/main.go
@@ -0,0 +1,244 @@
+package main
+
+import (
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "time"
+ _ "unsafe"
+)
+
+//go:linkname gmpForTesting github.com/goplus/llgo/runtime/internal/runtime.GMPForTesting
+func gmpForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool)
+
+type workerIdentity struct {
+ mid int64
+ pid int32
+ thread uintptr
+}
+
+//llgo:gls
+var workerLocalState *int
+
+func main() {
+ testParallelWorkers()
+ testPinnedGoroutine()
+ testBoundedWorkerLifecycle()
+ testCrossWorkerChannelHandoffs()
+ testCrossWorkerSynchronization()
+ testInterleavedWorkerLocality()
+ testCrossWorkerTimerWake()
+ println("wasm workers ok")
+}
+
+func testParallelWorkers() {
+ var identities [2]workerIdentity
+ record := func() {
+ _, _, mid, pid, gstatus, pstatus, linked := gmpForTesting()
+ if gstatus != 2 || pstatus != 1 || !linked {
+ panic("invalid worker G/M/P state")
+ }
+ slot := parallelWorkerBarrier()
+ if slot < 0 || int(slot) >= len(identities) {
+ panic("parallel worker barrier timed out")
+ }
+ identities[slot] = workerIdentity{
+ mid: mid,
+ pid: pid,
+ thread: parallelWorkerThread(slot),
+ }
+ }
+
+ done := make(chan struct{})
+ go func() {
+ record()
+ close(done)
+ }()
+ record()
+ <-done
+
+ left, right := identities[0], identities[1]
+ if left.mid == right.mid || left.pid == right.pid {
+ panic("goroutines did not run on distinct scheduler workers")
+ }
+ if left.thread == 0 || right.thread == 0 || left.thread == right.thread {
+ panic("goroutines did not overlap on distinct pthreads")
+ }
+}
+
+func testPinnedGoroutine() {
+ done := make(chan struct{})
+ go func() {
+ _, _, mid, pid, _, _, linked := gmpForTesting()
+ thread := currentWorkerThread()
+ if !linked || thread == 0 {
+ panic("invalid initial worker identity")
+ }
+ for range 32 {
+ runtime.Gosched()
+ }
+ time.Sleep(time.Millisecond)
+ _, _, currentMid, currentPid, _, _, currentLinked := gmpForTesting()
+ if !currentLinked || currentMid != mid || currentPid != pid || currentWorkerThread() != thread {
+ panic("started goroutine migrated between workers")
+ }
+ close(done)
+ }()
+ <-done
+}
+
+func testBoundedWorkerLifecycle() {
+ const goroutines = 5000
+ workerCount := int(configuredWorkerCount())
+ var (
+ done atomic.Uint32
+ mid int64
+ thread uintptr
+ )
+ mids := make(map[int64]struct{})
+ threads := make(map[uintptr]struct{})
+ for i := uint32(1); i <= goroutines; i++ {
+ go func() {
+ _, _, currentMID, _, _, _, linked := gmpForTesting()
+ currentThread := currentWorkerThread()
+ if !linked || currentThread == 0 {
+ panic("invalid lifecycle worker identity")
+ }
+ mid = currentMID
+ thread = currentThread
+ done.Store(i)
+ }()
+ for done.Load() != i {
+ runtime.Gosched()
+ }
+ mids[mid] = struct{}{}
+ threads[thread] = struct{}{}
+ }
+ if len(mids) != workerCount || len(threads) != workerCount {
+ panic("goroutine lifecycle escaped the bounded worker pool")
+ }
+}
+
+func testCrossWorkerChannelHandoffs() {
+ const handoffs = 100_000
+ values := make(chan int)
+ done := make(chan struct{})
+ go func() {
+ for i := range handoffs {
+ values <- i
+ }
+ close(done)
+ }()
+ for i := range handoffs {
+ if value := <-values; value != i {
+ panic("cross-worker channel handoff lost ordering")
+ }
+ }
+ <-done
+}
+
+func testCrossWorkerSynchronization() {
+ workerCount := int(configuredWorkerCount())
+ goroutines := workerCount * 2
+ var (
+ counter atomic.Uint32
+ mu sync.Mutex
+ wg sync.WaitGroup
+ )
+ values := make(chan int, goroutines)
+ workers := make(map[int64]int)
+ wg.Add(goroutines)
+ for i := range goroutines {
+ go func() {
+ _, _, mid, _, _, _, _ := gmpForTesting()
+ mu.Lock()
+ counter.Add(1)
+ workers[mid]++
+ mu.Unlock()
+ values <- i
+ wg.Done()
+ }()
+ }
+ wg.Wait()
+ close(values)
+
+ seen := 0
+ for range values {
+ seen++
+ }
+ if seen != goroutines || counter.Load() != uint32(goroutines) {
+ panic("cross-worker synchronization lost work")
+ }
+ if len(workers) != workerCount {
+ panic("goroutines did not use the bounded worker pool")
+ }
+ for _, count := range workers {
+ if count < 2 {
+ panic("worker did not execute multiple goroutines")
+ }
+ }
+}
+
+type localityWaiter struct {
+ mid int64
+ release chan struct{}
+ done chan struct{}
+}
+
+func testInterleavedWorkerLocality() {
+ workerCount := int(configuredWorkerCount())
+ ready := make(chan localityWaiter, workerCount*2)
+ startBatch := func() {
+ for range workerCount {
+ release := make(chan struct{})
+ done := make(chan struct{})
+ go func() {
+ if workerLocalState == nil {
+ value := 1
+ workerLocalState = &value
+ }
+ if *workerLocalState != 1 {
+ panic("worker-local state was corrupted")
+ }
+ _, _, mid, _, _, _, _ := gmpForTesting()
+ ready <- localityWaiter{mid: mid, release: release, done: done}
+ <-release
+ close(done)
+ }()
+ }
+ }
+
+ byWorker := make(map[int64][]localityWaiter, workerCount)
+ for range 2 {
+ startBatch()
+ for range workerCount {
+ waiter := <-ready
+ byWorker[waiter.mid] = append(byWorker[waiter.mid], waiter)
+ }
+ }
+ if len(byWorker) != workerCount {
+ panic("locality test did not cover every worker")
+ }
+ for _, waiters := range byWorker {
+ if len(waiters) != 2 {
+ panic("locality test did not interleave two goroutines per worker")
+ }
+ close(waiters[0].release)
+ <-waiters[0].done
+ close(waiters[1].release)
+ <-waiters[1].done
+ }
+}
+
+func testCrossWorkerTimerWake() {
+ done := make(chan struct{})
+ go func() {
+ time.Sleep(5 * time.Millisecond)
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ panic("timer did not wake a worker")
+ }
+}
diff --git a/internal/build/testdata/wasm-workers/server.mjs b/internal/build/testdata/wasm-workers/server.mjs
new file mode 100644
index 0000000000..78f9480b2b
--- /dev/null
+++ b/internal/build/testdata/wasm-workers/server.mjs
@@ -0,0 +1,35 @@
+import { createReadStream, statSync } from "node:fs";
+import { createServer } from "node:http";
+import { extname, resolve, sep } from "node:path";
+
+const root = resolve(process.argv[2]);
+const port = Number(process.argv[3]);
+const types = new Map([
+ [".html", "text/html; charset=utf-8"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".mjs", "text/javascript; charset=utf-8"],
+ [".wasm", "application/wasm"],
+]);
+
+createServer((request, response) => {
+ const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
+ const file = resolve(root, pathname.replace(/^\/+/, "") || "browser.html");
+ if (file !== root && !file.startsWith(root + sep)) {
+ response.writeHead(403).end();
+ return;
+ }
+ try {
+ if (!statSync(file).isFile()) {
+ throw new Error("not a file");
+ }
+ response.writeHead(200, {
+ "Content-Type": types.get(extname(file)) || "application/octet-stream",
+ "Cross-Origin-Embedder-Policy": "require-corp",
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Resource-Policy": "same-origin",
+ });
+ createReadStream(file).pipe(response);
+ } catch {
+ response.writeHead(404).end();
+ }
+}).listen(port, "127.0.0.1");
diff --git a/internal/build/testdata/wasm-workers/workers.c b/internal/build/testdata/wasm-workers/workers.c
new file mode 100644
index 0000000000..d46158fb71
--- /dev/null
+++ b/internal/build/testdata/wasm-workers/workers.c
@@ -0,0 +1,50 @@
+#include
+#include
+#include
+#include
+
+#ifndef LLGO_WASM_WORKERS
+#define LLGO_WASM_WORKERS 1
+#endif
+
+static _Atomic uint32_t llgo_test_parallel_workers;
+static uintptr_t llgo_test_parallel_threads[2];
+
+int32_t llgo_test_parallel_worker_barrier(void) {
+ uint32_t slot = atomic_fetch_add_explicit(
+ &llgo_test_parallel_workers, 1, memory_order_acq_rel);
+ if (slot >= 2) {
+ return -1;
+ }
+ llgo_test_parallel_threads[slot] = (uintptr_t)pthread_self();
+ if (slot == 0) {
+ uint32_t attempts = 0;
+ while (atomic_load_explicit(
+ &llgo_test_parallel_workers, memory_order_acquire) != 2) {
+ if (attempts++ == 10) {
+ return -1;
+ }
+ emscripten_futex_wait(
+ (volatile void *)&llgo_test_parallel_workers, 1, 100.0);
+ }
+ } else {
+ emscripten_futex_wake(
+ (volatile void *)&llgo_test_parallel_workers, 1);
+ }
+ return (int32_t)slot;
+}
+
+uintptr_t llgo_test_parallel_worker_thread(int32_t slot) {
+ if (slot < 0 || slot >= 2) {
+ return 0;
+ }
+ return llgo_test_parallel_threads[slot];
+}
+
+uintptr_t llgo_test_current_worker_thread(void) {
+ return (uintptr_t)pthread_self();
+}
+
+int32_t llgo_test_worker_count(void) {
+ return LLGO_WASM_WORKERS;
+}
diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go
index 69a42b93c6..4ddabe7edc 100644
--- a/internal/crosscompile/crosscompile.go
+++ b/internal/crosscompile/crosscompile.go
@@ -43,11 +43,17 @@ type Export struct {
Emulator string // Emulator command template (e.g., "qemu-system-arm -M {} -kernel {}")
DebugInfo DebugInfoPolicy
WasmPostLink WasmPostLink
+ WasmRuntime WasmRuntime
// Flashing/Debugging configuration
Device flash.Device // Device configuration for flashing/debugging
}
+// WasmRuntime describes entry behavior implemented by the selected runtime.
+type WasmRuntime struct {
+ RunMainTask bool
+}
+
// WasmPostLink describes transformations required after the core module is
// linked. Build orchestration owns tool discovery and atomic output handling.
type WasmPostLink struct {
diff --git a/internal/wasmworkers/config.go b/internal/wasmworkers/config.go
new file mode 100644
index 0000000000..a26dd6f5f8
--- /dev/null
+++ b/internal/wasmworkers/config.go
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Package wasmworkers validates the bounded WebAssembly worker-pool setting.
+package wasmworkers
+
+import (
+ "fmt"
+ "path/filepath"
+ "strconv"
+)
+
+const (
+ DefaultCount = 1
+ MaxCount = 16
+)
+
+type Config struct {
+ Count int
+}
+
+func Parse(value string) (Config, error) {
+ if value == "" {
+ return Config{Count: DefaultCount}, nil
+ }
+ count, err := strconv.Atoi(value)
+ if err != nil || count < 1 || count > MaxCount {
+ return Config{}, fmt.Errorf("LLGO_WASM_WORKERS must be an integer from 1 through %d", MaxCount)
+ }
+ return Config{Count: count}, nil
+}
+
+func (c Config) Enabled() bool {
+ return c.Count > DefaultCount
+}
+
+func (c Config) ValidateTarget(goos, goarch string) error {
+ if !c.Enabled() {
+ return nil
+ }
+ if goos != "js" || goarch != "wasm" {
+ return fmt.Errorf("LLGO_WASM_WORKERS requires GOOS=js GOARCH=wasm")
+ }
+ return nil
+}
+
+func PreJSPath(llgoRoot string) string {
+ return filepath.Join(llgoRoot, "internal", "wasmworkers", "worker_pre.js")
+}
diff --git a/internal/wasmworkers/config_test.go b/internal/wasmworkers/config_test.go
new file mode 100644
index 0000000000..65e31e978d
--- /dev/null
+++ b/internal/wasmworkers/config_test.go
@@ -0,0 +1,51 @@
+package wasmworkers
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestParse(t *testing.T) {
+ for _, test := range []struct {
+ value string
+ count int
+ err bool
+ }{
+ {count: 1},
+ {value: "1", count: 1},
+ {value: "2", count: 2},
+ {value: "16", count: 16},
+ {value: "0", err: true},
+ {value: "17", err: true},
+ {value: "two", err: true},
+ } {
+ got, err := Parse(test.value)
+ if (err != nil) != test.err {
+ t.Fatalf("Parse(%q) error = %v, want error %v", test.value, err, test.err)
+ }
+ if !test.err && got.Count != test.count {
+ t.Fatalf("Parse(%q).Count = %d, want %d", test.value, got.Count, test.count)
+ }
+ }
+}
+
+func TestValidateTarget(t *testing.T) {
+ if err := (Config{Count: 2}).ValidateTarget("js", "wasm"); err != nil {
+ t.Fatal(err)
+ }
+ for _, target := range [][2]string{{"wasip1", "wasm"}, {"linux", "amd64"}} {
+ if err := (Config{Count: 2}).ValidateTarget(target[0], target[1]); err == nil {
+ t.Fatalf("ValidateTarget(%q, %q) succeeded", target[0], target[1])
+ }
+ }
+ if err := (Config{Count: 1}).ValidateTarget("linux", "amd64"); err != nil {
+ t.Fatalf("disabled config rejected native target: %v", err)
+ }
+}
+
+func TestPreJSPath(t *testing.T) {
+ want := filepath.Join("llgo", "internal", "wasmworkers", "worker_pre.js")
+ if got := PreJSPath("llgo"); got != want {
+ t.Fatalf("PreJSPath() = %q, want %q", got, want)
+ }
+}
diff --git a/internal/wasmworkers/worker_pre.js b/internal/wasmworkers/worker_pre.js
new file mode 100644
index 0000000000..d9f6f9417c
--- /dev/null
+++ b/internal/wasmworkers/worker_pre.js
@@ -0,0 +1,87 @@
+(() => {
+ const host = globalThis;
+ host.global ||= host;
+ host.require ||= typeof require !== "undefined" ? require : undefined;
+
+ if (host.require) {
+ host.fs ||= host.require("node:fs");
+ host.path ||= host.require("node:path");
+ }
+
+ const enosys = () => {
+ const err = new Error("not implemented");
+ err.code = "ENOSYS";
+ return err;
+ };
+
+ if (!host.fs) {
+ let outputBuf = "";
+ const decoder = new TextDecoder("utf-8");
+ host.fs = {
+ constants: {
+ O_WRONLY: -1,
+ O_RDWR: -1,
+ O_CREAT: -1,
+ O_TRUNC: -1,
+ O_APPEND: -1,
+ O_EXCL: -1,
+ },
+ writeSync(fd, buf) {
+ outputBuf += decoder.decode(buf);
+ const newline = outputBuf.lastIndexOf("\n");
+ if (newline !== -1) {
+ console.log(outputBuf.slice(0, newline));
+ outputBuf = outputBuf.slice(newline + 1);
+ }
+ return buf.length;
+ },
+ write(fd, buf, offset, length, position, callback) {
+ if (offset !== 0 || length !== buf.length || position !== null) {
+ callback(enosys());
+ return;
+ }
+ callback(null, this.writeSync(fd, buf));
+ },
+ chmod(path, mode, callback) { callback(enosys()); },
+ chown(path, uid, gid, callback) { callback(enosys()); },
+ close(fd, callback) { callback(enosys()); },
+ fchmod(fd, mode, callback) { callback(enosys()); },
+ fchown(fd, uid, gid, callback) { callback(enosys()); },
+ fstat(fd, callback) { callback(enosys()); },
+ fsync(fd, callback) { callback(null); },
+ ftruncate(fd, length, callback) { callback(enosys()); },
+ lchown(path, uid, gid, callback) { callback(enosys()); },
+ link(path, link, callback) { callback(enosys()); },
+ lstat(path, callback) { callback(enosys()); },
+ mkdir(path, perm, callback) { callback(enosys()); },
+ open(path, flags, mode, callback) { callback(enosys()); },
+ read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
+ readdir(path, callback) { callback(enosys()); },
+ readlink(path, callback) { callback(enosys()); },
+ rename(from, to, callback) { callback(enosys()); },
+ rmdir(path, callback) { callback(enosys()); },
+ stat(path, callback) { callback(enosys()); },
+ symlink(path, link, callback) { callback(enosys()); },
+ truncate(path, length, callback) { callback(enosys()); },
+ unlink(path, callback) { callback(enosys()); },
+ utimes(path, atime, mtime, callback) { callback(enosys()); },
+ };
+ }
+
+ host.path ||= {
+ resolve(path) { return path; },
+ };
+
+ host.process ||= {
+ getuid() { return -1; },
+ getgid() { return -1; },
+ geteuid() { return -1; },
+ getegid() { return -1; },
+ getgroups() { throw enosys(); },
+ pid: -1,
+ ppid: -1,
+ umask() { throw enosys(); },
+ cwd() { return "/"; },
+ chdir() { throw enosys(); },
+ };
+})();
diff --git a/runtime/internal/lib/runtime/sema_wasm_llgo.go b/runtime/internal/lib/runtime/sema_wasm_llgo.go
index 3370596563..a18130ec39 100644
--- a/runtime/internal/lib/runtime/sema_wasm_llgo.go
+++ b/runtime/internal/lib/runtime/sema_wasm_llgo.go
@@ -85,33 +85,6 @@ func semaQueue(addr *uint32) *wasmWaitQueue {
return q
}
-func semaAcquire(addr *uint32, lifo bool) {
- value := latomic.LoadUint32(addr)
- if value != 0 && latomic.CompareAndSwapUint32(addr, value, value-1) {
- return
- }
- w := &wasmWaiter{waiter: llruntime.CurrentSchedulerWaiter()}
- semaQueue(addr).push(w, lifo)
- w.waiter.Park()
-}
-
-func semaRelease(addr *uint32, handoff bool) {
- key := uintptr(unsafe.Pointer(addr))
- if q := semaQueues[key]; q != nil {
- if w := q.pop(); w != nil {
- if q.head == nil {
- delete(semaQueues, key)
- }
- w.waiter.Ready()
- if handoff {
- llruntime.Gosched()
- }
- return
- }
- }
- latomic.AddUint32(addr, 1)
-}
-
//go:linkname sync_runtime_Semacquire sync.runtime_Semacquire
func sync_runtime_Semacquire(addr *uint32) {
semaAcquire(addr, false)
@@ -229,61 +202,6 @@ func sync_runtime_notifyListAdd(l *notifyList) uint32 {
return latomic.AddUint32(&l.wait, 1) - 1
}
-//go:linkname sync_runtime_notifyListWait sync.runtime_notifyListWait
-func sync_runtime_notifyListWait(l *notifyList, ticket uint32) {
- if ticketLess(ticket, latomic.LoadUint32(&l.notify)) {
- return
- }
- w := &wasmWaiter{
- waiter: llruntime.CurrentSchedulerWaiter(),
- ticket: ticket,
- }
- notifyQueue(l).push(w, false)
- w.waiter.Park()
-}
-
-//go:linkname sync_runtime_notifyListNotifyAll sync.runtime_notifyListNotifyAll
-func sync_runtime_notifyListNotifyAll(l *notifyList) {
- wait := latomic.LoadUint32(&l.wait)
- if latomic.LoadUint32(&l.notify) == wait {
- return
- }
- latomic.StoreUint32(&l.notify, wait)
- key := uintptr(unsafe.Pointer(l))
- q := notifyQueues[key]
- if q == nil {
- return
- }
- delete(notifyQueues, key)
- for {
- w := q.pop()
- if w == nil {
- return
- }
- w.waiter.Ready()
- }
-}
-
-//go:linkname sync_runtime_notifyListNotifyOne sync.runtime_notifyListNotifyOne
-func sync_runtime_notifyListNotifyOne(l *notifyList) {
- notify := latomic.LoadUint32(&l.notify)
- if notify == latomic.LoadUint32(&l.wait) {
- return
- }
- latomic.StoreUint32(&l.notify, notify+1)
- key := uintptr(unsafe.Pointer(l))
- q := notifyQueues[key]
- if q == nil {
- return
- }
- if w := q.removeTicket(notify); w != nil {
- if q.head == nil {
- delete(notifyQueues, key)
- }
- w.waiter.Ready()
- }
-}
-
//go:linkname sync_runtime_notifyListCheck sync.runtime_notifyListCheck
func sync_runtime_notifyListCheck(size uintptr) {
if size != unsafe.Sizeof(notifyList{}) {
@@ -297,19 +215,3 @@ var poolCleanup func()
func sync_runtime_registerPoolCleanup(cleanup func()) {
poolCleanup = cleanup
}
-
-//go:linkname sync_runtime_procPin sync.runtime_procPin
-func sync_runtime_procPin() int {
- return 0
-}
-
-//go:linkname sync_runtime_procUnpin sync.runtime_procUnpin
-func sync_runtime_procUnpin() {}
-
-//go:linkname atomic_runtime_procPin sync/atomic.runtime_procPin
-func atomic_runtime_procPin() int {
- return 0
-}
-
-//go:linkname atomic_runtime_procUnpin sync/atomic.runtime_procUnpin
-func atomic_runtime_procUnpin() {}
diff --git a/runtime/internal/lib/runtime/sema_wasm_single_llgo.go b/runtime/internal/lib/runtime/sema_wasm_single_llgo.go
new file mode 100644
index 0000000000..4a189e25e8
--- /dev/null
+++ b/runtime/internal/lib/runtime/sema_wasm_single_llgo.go
@@ -0,0 +1,108 @@
+//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
+
+package runtime
+
+import (
+ "unsafe"
+
+ latomic "github.com/goplus/llgo/runtime/internal/lib/sync/atomic"
+ llruntime "github.com/goplus/llgo/runtime/internal/runtime"
+)
+
+func semaAcquire(addr *uint32, lifo bool) {
+ value := latomic.LoadUint32(addr)
+ if value != 0 && latomic.CompareAndSwapUint32(addr, value, value-1) {
+ return
+ }
+ w := &wasmWaiter{waiter: llruntime.CurrentSchedulerWaiter()}
+ semaQueue(addr).push(w, lifo)
+ w.waiter.Park()
+}
+
+func semaRelease(addr *uint32, handoff bool) {
+ key := uintptr(unsafe.Pointer(addr))
+ if q := semaQueues[key]; q != nil {
+ if w := q.pop(); w != nil {
+ if q.head == nil {
+ delete(semaQueues, key)
+ }
+ w.waiter.Ready()
+ if handoff {
+ llruntime.Gosched()
+ }
+ return
+ }
+ }
+ latomic.AddUint32(addr, 1)
+}
+
+//go:linkname sync_runtime_notifyListWait sync.runtime_notifyListWait
+func sync_runtime_notifyListWait(l *notifyList, ticket uint32) {
+ if ticketLess(ticket, latomic.LoadUint32(&l.notify)) {
+ return
+ }
+ w := &wasmWaiter{
+ waiter: llruntime.CurrentSchedulerWaiter(),
+ ticket: ticket,
+ }
+ notifyQueue(l).push(w, false)
+ w.waiter.Park()
+}
+
+//go:linkname sync_runtime_notifyListNotifyAll sync.runtime_notifyListNotifyAll
+func sync_runtime_notifyListNotifyAll(l *notifyList) {
+ wait := latomic.LoadUint32(&l.wait)
+ if latomic.LoadUint32(&l.notify) == wait {
+ return
+ }
+ latomic.StoreUint32(&l.notify, wait)
+ key := uintptr(unsafe.Pointer(l))
+ q := notifyQueues[key]
+ if q == nil {
+ return
+ }
+ delete(notifyQueues, key)
+ for {
+ w := q.pop()
+ if w == nil {
+ return
+ }
+ w.waiter.Ready()
+ }
+}
+
+//go:linkname sync_runtime_notifyListNotifyOne sync.runtime_notifyListNotifyOne
+func sync_runtime_notifyListNotifyOne(l *notifyList) {
+ notify := latomic.LoadUint32(&l.notify)
+ if notify == latomic.LoadUint32(&l.wait) {
+ return
+ }
+ latomic.StoreUint32(&l.notify, notify+1)
+ key := uintptr(unsafe.Pointer(l))
+ q := notifyQueues[key]
+ if q == nil {
+ return
+ }
+ if w := q.removeTicket(notify); w != nil {
+ if q.head == nil {
+ delete(notifyQueues, key)
+ }
+ w.waiter.Ready()
+ }
+}
+
+//go:linkname sync_runtime_procPin sync.runtime_procPin
+func sync_runtime_procPin() int {
+ return 0
+}
+
+//go:linkname sync_runtime_procUnpin sync.runtime_procUnpin
+func sync_runtime_procUnpin() {}
+
+//go:linkname atomic_runtime_procPin sync/atomic.runtime_procPin
+func atomic_runtime_procPin() int {
+ return 0
+}
+
+//go:linkname atomic_runtime_procUnpin sync/atomic.runtime_procUnpin
+func atomic_runtime_procUnpin() {}
diff --git a/runtime/internal/lib/runtime/sema_wasm_workers_llgo.go b/runtime/internal/lib/runtime/sema_wasm_workers_llgo.go
new file mode 100644
index 0000000000..1bc68a69ed
--- /dev/null
+++ b/runtime/internal/lib/runtime/sema_wasm_workers_llgo.go
@@ -0,0 +1,157 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package runtime
+
+import (
+ "unsafe"
+
+ psync "github.com/goplus/llgo/runtime/internal/clite/pthread/sync"
+ latomic "github.com/goplus/llgo/runtime/internal/lib/sync/atomic"
+ llruntime "github.com/goplus/llgo/runtime/internal/runtime"
+)
+
+var semaQueuesLock = newWasmSemaMutex()
+var notifyQueuesLock = newWasmSemaMutex()
+
+type wasmSemaMutex struct {
+ mutex psync.Mutex
+}
+
+func newWasmSemaMutex() wasmSemaMutex {
+ var result wasmSemaMutex
+ if result.mutex.Init(nil) != 0 {
+ panic("runtime: failed to initialize WebAssembly semaphore mutex")
+ }
+ return result
+}
+
+func (m *wasmSemaMutex) Lock() {
+ m.mutex.Lock()
+}
+
+func (m *wasmSemaMutex) Unlock() {
+ m.mutex.Unlock()
+}
+
+func semaAcquire(addr *uint32, lifo bool) {
+ value := latomic.LoadUint32(addr)
+ if value != 0 && latomic.CompareAndSwapUint32(addr, value, value-1) {
+ return
+ }
+ semaQueuesLock.Lock()
+ value = latomic.LoadUint32(addr)
+ if value != 0 && latomic.CompareAndSwapUint32(addr, value, value-1) {
+ semaQueuesLock.Unlock()
+ return
+ }
+ w := &wasmWaiter{waiter: llruntime.CurrentSchedulerWaiter()}
+ semaQueue(addr).push(w, lifo)
+ semaQueuesLock.Unlock()
+ w.waiter.Park()
+}
+
+func semaRelease(addr *uint32, handoff bool) {
+ key := uintptr(unsafe.Pointer(addr))
+ semaQueuesLock.Lock()
+ if q := semaQueues[key]; q != nil {
+ if w := q.pop(); w != nil {
+ if q.head == nil {
+ delete(semaQueues, key)
+ }
+ semaQueuesLock.Unlock()
+ w.waiter.Ready()
+ if handoff {
+ llruntime.Gosched()
+ }
+ return
+ }
+ }
+ latomic.AddUint32(addr, 1)
+ semaQueuesLock.Unlock()
+}
+
+//go:linkname sync_runtime_notifyListWait sync.runtime_notifyListWait
+func sync_runtime_notifyListWait(l *notifyList, ticket uint32) {
+ if ticketLess(ticket, latomic.LoadUint32(&l.notify)) {
+ return
+ }
+ w := &wasmWaiter{
+ waiter: llruntime.CurrentSchedulerWaiter(),
+ ticket: ticket,
+ }
+ notifyQueuesLock.Lock()
+ if ticketLess(ticket, latomic.LoadUint32(&l.notify)) {
+ notifyQueuesLock.Unlock()
+ return
+ }
+ notifyQueue(l).push(w, false)
+ notifyQueuesLock.Unlock()
+ w.waiter.Park()
+}
+
+//go:linkname sync_runtime_notifyListNotifyAll sync.runtime_notifyListNotifyAll
+func sync_runtime_notifyListNotifyAll(l *notifyList) {
+ notifyQueuesLock.Lock()
+ wait := latomic.LoadUint32(&l.wait)
+ if latomic.LoadUint32(&l.notify) == wait {
+ notifyQueuesLock.Unlock()
+ return
+ }
+ latomic.StoreUint32(&l.notify, wait)
+ key := uintptr(unsafe.Pointer(l))
+ q := notifyQueues[key]
+ if q == nil {
+ notifyQueuesLock.Unlock()
+ return
+ }
+ delete(notifyQueues, key)
+ notifyQueuesLock.Unlock()
+ for {
+ w := q.pop()
+ if w == nil {
+ return
+ }
+ w.waiter.Ready()
+ }
+}
+
+//go:linkname sync_runtime_notifyListNotifyOne sync.runtime_notifyListNotifyOne
+func sync_runtime_notifyListNotifyOne(l *notifyList) {
+ notifyQueuesLock.Lock()
+ notify := latomic.LoadUint32(&l.notify)
+ if notify == latomic.LoadUint32(&l.wait) {
+ notifyQueuesLock.Unlock()
+ return
+ }
+ latomic.StoreUint32(&l.notify, notify+1)
+ key := uintptr(unsafe.Pointer(l))
+ q := notifyQueues[key]
+ if q == nil {
+ notifyQueuesLock.Unlock()
+ return
+ }
+ w := q.removeTicket(notify)
+ if q.head == nil {
+ delete(notifyQueues, key)
+ }
+ notifyQueuesLock.Unlock()
+ if w != nil {
+ w.waiter.Ready()
+ }
+}
+
+//go:linkname sync_runtime_procPin sync.runtime_procPin
+func sync_runtime_procPin() int {
+ return llruntime.SchedulerProcID()
+}
+
+//go:linkname sync_runtime_procUnpin sync.runtime_procUnpin
+func sync_runtime_procUnpin() {}
+
+//go:linkname atomic_runtime_procPin sync/atomic.runtime_procPin
+func atomic_runtime_procPin() int {
+ return llruntime.SchedulerProcID()
+}
+
+//go:linkname atomic_runtime_procUnpin sync/atomic.runtime_procUnpin
+func atomic_runtime_procUnpin() {}
diff --git a/runtime/internal/runtime/chan_sync_wasm.go b/runtime/internal/runtime/chan_sync_wasm.go
index d3877770b3..fd59d389d9 100644
--- a/runtime/internal/runtime/chan_sync_wasm.go
+++ b/runtime/internal/runtime/chan_sync_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads)
+//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
package runtime
diff --git a/runtime/internal/runtime/chan_sync_wasm_workers.go b/runtime/internal/runtime/chan_sync_wasm_workers.go
new file mode 100644
index 0000000000..9c00ad5747
--- /dev/null
+++ b/runtime/internal/runtime/chan_sync_wasm_workers.go
@@ -0,0 +1,68 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package runtime
+
+import (
+ "github.com/goplus/llgo/runtime/internal/clite/pthread/sync"
+ "github.com/goplus/llgo/runtime/internal/clite/sync/atomic"
+ "github.com/goplus/llgo/runtime/internal/wasmworkers"
+)
+
+type chanMutex struct {
+ mutex sync.Mutex
+}
+
+func (m *chanMutex) init() {
+ if m.mutex.Init(nil) != 0 {
+ fatal("runtime: failed to initialize channel mutex")
+ }
+}
+
+func (m *chanMutex) Lock() {
+ m.mutex.Lock()
+}
+
+func (m *chanMutex) Unlock() {
+ m.mutex.Unlock()
+}
+
+type chanSignal struct {
+ lockWord uint32
+ waiter SchedulerWaiter
+}
+
+func (s *chanSignal) init() {
+ s.waiter = CurrentSchedulerWaiter()
+}
+
+func (s *chanSignal) lock() {
+ for {
+ if _, ok := atomic.CompareAndExchange(&s.lockWord, uint32(0), uint32(1)); ok {
+ return
+ }
+ wasmworkers.Wait(&s.lockWord, 1, -1)
+ }
+}
+
+func (s *chanSignal) unlock() {
+ atomic.Store(&s.lockWord, uint32(0))
+ wasmworkers.Wake(&s.lockWord)
+}
+
+func (s *chanSignal) park() {
+ s.unlock()
+ s.waiter.Park()
+ s.lock()
+}
+
+func (s *chanSignal) ready() {
+ s.waiter.Ready()
+}
+
+func (*chanSignal) destroy() {}
+
+func chanBlockForever() {
+ waiter := CurrentSchedulerWaiter()
+ waiter.Park()
+ fatal("runtime: permanently parked goroutine was resumed")
+}
diff --git a/runtime/internal/runtime/g_wasm.go b/runtime/internal/runtime/g_wasm.go
index 78746da0e5..0dc11d214e 100644
--- a/runtime/internal/runtime/g_wasm.go
+++ b/runtime/internal/runtime/g_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads)
+//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
diff --git a/runtime/internal/runtime/g_wasm_workers.go b/runtime/internal/runtime/g_wasm_workers.go
new file mode 100644
index 0000000000..350fd91893
--- /dev/null
+++ b/runtime/internal/runtime/g_wasm_workers.go
@@ -0,0 +1,36 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package runtime
+
+import (
+ "unsafe"
+
+ "github.com/goplus/llgo/runtime/internal/wasmworkers"
+)
+
+func getg() *g {
+ if worker := currentWasmWorker(); worker != nil {
+ return worker.m.curg
+ }
+ if wasmMultiSched.started {
+ return nil
+ }
+ return initRuntimeContext(allocRuntimeContext(), nil, _Grunning)
+}
+
+func setg(gp *g) {
+ worker := currentWasmWorker()
+ if worker == nil {
+ fatal("runtime: setg without a WebAssembly worker")
+ return
+ }
+ worker.m.curg = gp
+}
+
+func currentWasmWorker() *wasmWorker {
+ return (*wasmWorker)(wasmworkers.Current())
+}
+
+func setCurrentWasmWorker(worker *wasmWorker) {
+ wasmworkers.SetCurrent(unsafe.Pointer(worker))
+}
diff --git a/runtime/internal/runtime/os_wasm.go b/runtime/internal/runtime/os_wasm.go
index 02af93043b..4851564c6a 100644
--- a/runtime/internal/runtime/os_wasm.go
+++ b/runtime/internal/runtime/os_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads)
+//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
diff --git a/runtime/internal/runtime/os_wasm_workers.go b/runtime/internal/runtime/os_wasm_workers.go
new file mode 100644
index 0000000000..10314aab35
--- /dev/null
+++ b/runtime/internal/runtime/os_wasm_workers.go
@@ -0,0 +1,7 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package runtime
+
+// Worker threads are owned by the scheduler pool rather than individual M
+// records, so the host-specific M payload stays empty.
+type mOS struct{}
diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go
index e6f239ddd8..6db76a2e54 100644
--- a/runtime/internal/runtime/proc_wasm.go
+++ b/runtime/internal/runtime/proc_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && js && wasm
+//go:build llgo && js && wasm && !llgo.wasm_workers
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
diff --git a/runtime/internal/runtime/proc_wasm_workers.go b/runtime/internal/runtime/proc_wasm_workers.go
new file mode 100644
index 0000000000..38f55b729d
--- /dev/null
+++ b/runtime/internal/runtime/proc_wasm_workers.go
@@ -0,0 +1,433 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+/*
+ * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package runtime
+
+import (
+ "unsafe"
+
+ c "github.com/goplus/llgo/runtime/internal/clite"
+ "github.com/goplus/llgo/runtime/internal/clite/pthread/sync"
+ "github.com/goplus/llgo/runtime/internal/clite/sync/atomic"
+ "github.com/goplus/llgo/runtime/internal/pollbudget"
+ "github.com/goplus/llgo/runtime/internal/runqueue"
+ "github.com/goplus/llgo/runtime/internal/wasmcontext"
+ "github.com/goplus/llgo/runtime/internal/wasmevent"
+ "github.com/goplus/llgo/runtime/internal/wasmworkers"
+)
+
+const maxWasmWorkers = 16
+
+type runtimeContextPlatform struct {
+ context wasmcontext.Context
+ gcRoot wasmGCRootContext
+ runqNext *g
+ runqQueued bool
+ owner *wasmWorker
+}
+
+type wasmWorker struct {
+ m m
+ p p
+
+ lock sync.Mutex
+ runq runqueue.Queue[*g]
+ wake uint32
+
+ system wasmcontext.Context
+ index int
+ safepointBudget pollbudget.Budget
+}
+
+var wasmMultiSched struct {
+ workers [maxWasmWorkers]wasmWorker
+ count int
+
+ nextWorker uint32
+ active uint32
+ started bool
+
+ mainReturned bool
+ mainGoexit bool
+}
+
+func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g {
+ gp := initG(ctx, callergp, status)
+ if status == _Grunning {
+ initWasmScheduler(gp)
+ }
+ return gp
+}
+
+func initWasmScheduler(gp *g) {
+ if wasmMultiSched.started {
+ fatal("runtime: WebAssembly scheduler initialized twice")
+ return
+ }
+ count := wasmworkers.Count()
+ if count < 2 || count > maxWasmWorkers {
+ fatal("runtime: invalid WebAssembly worker count")
+ return
+ }
+ wasmMultiSched.count = count
+ // atomic.Add returns the pre-increment value, so the first child starts
+ // away from the main worker.
+ wasmMultiSched.nextWorker = 1
+ wasmMultiSched.active = 1
+
+ for i := 0; i < count; i++ {
+ worker := &wasmMultiSched.workers[i]
+ worker.index = i
+ worker.safepointBudget = pollbudget.New(wasmSafepointQuantum)
+ if worker.lock.Init(nil) != 0 {
+ fatal("runtime: failed to initialize WebAssembly worker queue")
+ return
+ }
+ worker.m.id = nextMid(&worker.m)
+ worker.m.p = &worker.p
+ worker.p.id = nextPid(&worker.p)
+ worker.p.m = &worker.m
+ setpstatus(&worker.p, _Prunning)
+ }
+
+ mainWorker := &wasmMultiSched.workers[0]
+ setCurrentWasmWorker(mainWorker)
+ bindWasmWorkerG(mainWorker, gp)
+ gp.context.platform.owner = mainWorker
+ wasmMultiSched.started = true
+
+ for i := 1; i < count; i++ {
+ worker := &wasmMultiSched.workers[i]
+ if errno := wasmworkers.Start(wasmworkers.Entry(wasmWorkerStart), unsafe.Pointer(worker), 0); errno != 0 {
+ fatal("runtime: failed to start WebAssembly worker")
+ return
+ }
+ }
+ wasmevent.InstallWake(wakeWasmEventWorker)
+}
+
+//go:linkname wasmMainTask __llgo_wasm_main
+func wasmMainTask(unsafe.Pointer) unsafe.Pointer
+
+func RunWasmMain() {
+ gp := getg()
+ worker := currentWasmWorker()
+ if gp == nil || !gp.isMain || worker == nil || worker.index != 0 {
+ fatal("runtime: invalid WebAssembly main goroutine")
+ return
+ }
+ initWasmFiber(gp, wasmcontext.Entry(wasmMainStart), unsafe.Pointer(gp), 0)
+ initWasmWorkerSystem(worker)
+ releaseWasmWorkerG(worker, gp)
+ casgstatus(gp, _Grunning, _Grunnable)
+ enqueueWasmG(worker, gp)
+ runWasmWorker(worker, true)
+ c.Exit(0)
+}
+
+func wasmMainStart(arg unsafe.Pointer) {
+ gp := (*g)(arg)
+ if gp == nil || getg() != gp {
+ fatal("runtime: invalid WebAssembly main entry")
+ return
+ }
+ wasmMainTask(nil)
+ wasmMultiSched.mainReturned = true
+ finishWasmG(gp)
+}
+
+func wasmWorkerStart(arg unsafe.Pointer) unsafe.Pointer {
+ worker := (*wasmWorker)(arg)
+ if worker == nil || worker.index == 0 {
+ fatal("runtime: invalid WebAssembly worker entry")
+ return nil
+ }
+ // Goroutines interleave on this native worker, so their entry calls need
+ // one long-lived locality owner instead of relying on strict nesting.
+ var localContext LocalContext
+ EnterLocalContext(&localContext)
+ setCurrentWasmWorker(worker)
+ setg(nil)
+ initWasmWorkerSystem(worker)
+ runWasmWorker(worker, false)
+ return nil
+}
+
+func initWasmWorkerSystem(worker *wasmWorker) {
+ if worker.system.Ready() {
+ return
+ }
+ if !worker.system.InitCurrent(AllocRoot) {
+ panic("runtime: failed to allocate WebAssembly system context")
+ }
+}
+
+func runWasmWorker(worker *wasmWorker, stopAtMain bool) {
+ for {
+ gp := waitWasmWorkerRunq(worker)
+ if gp == nil {
+ continue
+ }
+ casgstatus(gp, _Grunnable, _Grunning)
+ bindWasmWorkerG(worker, gp)
+ setg(gp)
+ worker.system.Swap(
+ &gp.context.platform.context,
+ wasmGCRootPointer(&gp.context.platform.gcRoot),
+ )
+ setg(nil)
+ releaseWasmWorkerG(worker, gp)
+
+ if readgstatus(gp) != _Gdead {
+ continue
+ }
+ isMain := gp.isMain
+ releaseWasmContext(gp)
+ if isMain && stopAtMain {
+ if wasmMultiSched.mainReturned {
+ return
+ }
+ if wasmMultiSched.mainGoexit && atomic.Load(&wasmMultiSched.active) == 0 {
+ fatal("no goroutines (main called runtime.Goexit) - deadlock!")
+ return
+ }
+ }
+ }
+}
+
+func bindWasmWorkerG(worker *wasmWorker, gp *g) {
+ worker.m.curg = gp
+ gp.m = &worker.m
+}
+
+func releaseWasmWorkerG(worker *wasmWorker, gp *g) {
+ if gp != nil {
+ gp.m = nil
+ }
+ worker.m.curg = nil
+}
+
+func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) {
+ gp := newproc1(fn, arg, callergp)
+ worker := nextWasmWorker()
+ gp.context.platform.owner = worker
+ initWasmFiber(gp, wasmcontext.Entry(wasmGStart), unsafe.Pointer(gp), stackSize)
+ atomic.Add(&wasmMultiSched.active, uint32(1))
+ enqueueWasmG(worker, gp)
+}
+
+func nextWasmWorker() *wasmWorker {
+ index := atomic.Add(&wasmMultiSched.nextWorker, uint32(1))
+ return &wasmMultiSched.workers[int(index%uint32(wasmMultiSched.count))]
+}
+
+func initWasmFiber(gp *g, entry wasmcontext.Entry, arg unsafe.Pointer, stackSize uintptr) {
+ platform := &gp.context.platform
+ if !platform.context.Init(
+ entry,
+ arg,
+ stackSize,
+ AllocRoot,
+ FreeRoot,
+ ) {
+ panic("runtime: failed to allocate WebAssembly goroutine stack")
+ }
+}
+
+func releaseWasmContext(gp *g) {
+ if gp == nil || gp.context == nil {
+ return
+ }
+ ctx := gp.context
+ ctx.platform.context.Close(FreeRoot)
+ freeRuntimeContext(ctx)
+}
+
+func wasmGStart(arg unsafe.Pointer) {
+ gp := (*g)(arg)
+ if gp == nil || getg() != gp {
+ fatal("runtime: invalid WebAssembly goroutine entry")
+ return
+ }
+ fn, fnarg := gp.startfn, gp.startarg
+ gp.startfn = nil
+ gp.startarg = nil
+ fn(fnarg)
+ finishWasmG(gp)
+}
+
+func finishWasmG(gp *g) {
+ casgstatus(gp, _Grunning, _Gdead)
+ atomic.Add(&wasmMultiSched.active, ^uint32(0))
+ wakeWasmEventWorker()
+ worker := gp.context.platform.owner
+ gp.context.platform.context.Swap(&worker.system, nil)
+ fatal("runtime: resumed dead WebAssembly goroutine")
+}
+
+func goschedBackend() {
+ gp := getg()
+ worker := currentWasmWorker()
+ casgstatus(gp, _Grunning, _Grunnable)
+ enqueueWasmG(worker, gp)
+ gp.context.platform.context.Swap(&worker.system, nil)
+}
+
+func gopark() {
+ gp := getg()
+ parkWasmG(gp)
+}
+
+func parkWasmG(gp *g) {
+ casgstatus(gp, _Grunning, _Gwaiting)
+ atomic.Add(&wasmMultiSched.active, ^uint32(0))
+ wakeWasmEventWorker()
+ worker := gp.context.platform.owner
+ gp.context.platform.context.Swap(&worker.system, nil)
+}
+
+func goready(gp *g) {
+ if gp == nil {
+ fatal("runtime: ready of nil goroutine")
+ return
+ }
+ casgstatus(gp, _Gwaiting, _Grunnable)
+ atomic.Add(&wasmMultiSched.active, uint32(1))
+ enqueueWasmG(gp.context.platform.owner, gp)
+}
+
+func goexitBackend(gp *g) {
+ if gp.isMain {
+ wasmMultiSched.mainGoexit = true
+ }
+ finishWasmG(gp)
+}
+
+func enqueueWasmG(worker *wasmWorker, gp *g) {
+ if worker == nil {
+ fatal("runtime: enqueue on nil WebAssembly worker")
+ return
+ }
+ worker.lock.Lock()
+ ok := worker.runq.Push(gp)
+ worker.lock.Unlock()
+ if !ok {
+ fatal("runtime: invalid WebAssembly run queue insertion")
+ return
+ }
+ wakeWasmWorker(worker)
+}
+
+func popWasmWorkerRunq(worker *wasmWorker) *g {
+ worker.lock.Lock()
+ gp := worker.runq.Pop()
+ worker.lock.Unlock()
+ return gp
+}
+
+func wasmWorkerRunqLen(worker *wasmWorker) uintptr {
+ worker.lock.Lock()
+ size := worker.runq.Len()
+ worker.lock.Unlock()
+ return size
+}
+
+func wakeWasmWorker(worker *wasmWorker) {
+ atomic.Add(&worker.wake, uint32(1))
+ wasmworkers.Wake(&worker.wake)
+}
+
+func wakeWasmEventWorker() {
+ if wasmMultiSched.count != 0 {
+ wakeWasmWorker(&wasmMultiSched.workers[0])
+ }
+}
+
+func waitWasmWorkerRunq(worker *wasmWorker) *g {
+ for {
+ if gp := popWasmWorkerRunq(worker); gp != nil {
+ return gp
+ }
+ timeout := int64(-1)
+ if worker.index == 0 {
+ wasmevent.Poll()
+ if gp := popWasmWorkerRunq(worker); gp != nil {
+ return gp
+ }
+ now := wasmevent.Now()
+ if deadline, ok := wasmevent.NextDeadline(); ok {
+ timeout = deadline - now
+ if timeout < 0 {
+ timeout = 0
+ }
+ } else if atomic.Load(&wasmMultiSched.active) == 0 {
+ if wasmMultiSched.mainGoexit {
+ fatal("no goroutines (main called runtime.Goexit) - deadlock!")
+ } else {
+ fatal("all goroutines are asleep - deadlock!")
+ }
+ return nil
+ }
+ }
+
+ sequence := atomic.Load(&worker.wake)
+ if gp := popWasmWorkerRunq(worker); gp != nil {
+ return gp
+ }
+ wasmworkers.Wait(&worker.wake, sequence, timeout)
+ }
+}
+
+// CurrentGForTesting returns an opaque handle suitable for ReadyForTesting.
+func CurrentGForTesting() unsafe.Pointer {
+ return unsafe.Pointer(getg())
+}
+
+func ParkForTesting() {
+ gopark()
+}
+
+func ReadyForTesting(handle unsafe.Pointer) {
+ goready((*g)(handle))
+}
+
+func SchedulerStateForTesting() (runq uintptr, mid int64, pid int32) {
+ worker := currentWasmWorker()
+ if worker == nil {
+ return
+ }
+ for i := 0; i < wasmMultiSched.count; i++ {
+ runq += wasmWorkerRunqLen(&wasmMultiSched.workers[i])
+ }
+ return runq, worker.m.id, worker.p.id
+}
+
+func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) {
+ gp := getg()
+ worker := currentWasmWorker()
+ if gp == nil || worker == nil || gp.m == nil || gp.m.p == nil {
+ return
+ }
+ mp := gp.m
+ pp := mp.p
+ ctx := gp.context
+ return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp),
+ mp == &worker.m && pp == &worker.p && mp.curg == gp &&
+ pp.m == mp && ctx != nil && &ctx.g == gp &&
+ ctx.platform.owner == worker
+}
diff --git a/runtime/internal/runtime/safepoint_stub.go b/runtime/internal/runtime/safepoint_stub.go
index 3f9e2dc119..4ba143e39f 100644
--- a/runtime/internal/runtime/safepoint_stub.go
+++ b/runtime/internal/runtime/safepoint_stub.go
@@ -1,4 +1,4 @@
-//go:build !llgo || !wasm || !llgo_wasm_gc || (wasip1 && llgo.wasi_threads)
+//go:build !llgo || !wasm || (!llgo_wasm_gc && !llgo.wasm_workers) || (wasip1 && llgo.wasi_threads)
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
@@ -18,6 +18,6 @@
package runtime
-// CooperativeSafepoint is inactive on runtimes without single-worker wasm
-// cooperative scheduling.
+// CooperativeSafepoint is inactive on runtimes without wasm cooperative
+// scheduling.
func CooperativeSafepoint() {}
diff --git a/runtime/internal/runtime/safepoint_wasm.go b/runtime/internal/runtime/safepoint_wasm.go
index 8bdd467a39..8b9defd84d 100644
--- a/runtime/internal/runtime/safepoint_wasm.go
+++ b/runtime/internal/runtime/safepoint_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && wasm && llgo_wasm_gc && !(wasip1 && llgo.wasi_threads)
+//go:build llgo && wasm && llgo_wasm_gc && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
diff --git a/runtime/internal/runtime/safepoint_wasm_workers.go b/runtime/internal/runtime/safepoint_wasm_workers.go
new file mode 100644
index 0000000000..9c3b6db065
--- /dev/null
+++ b/runtime/internal/runtime/safepoint_wasm_workers.go
@@ -0,0 +1,29 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package runtime
+
+import "github.com/goplus/llgo/runtime/internal/wasmevent"
+
+const wasmSafepointQuantum = uint32(1024)
+
+func CooperativeSafepoint() {
+ worker := currentWasmWorker()
+ if worker == nil || !worker.safepointBudget.Poll() {
+ return
+ }
+ cooperativeSafepointSlow()
+}
+
+//go:noinline
+func cooperativeSafepointSlow() {
+ worker := currentWasmWorker()
+ if worker == nil {
+ return
+ }
+ if worker.index == 0 {
+ wasmevent.Poll()
+ }
+ if wasmWorkerRunqLen(worker) != 0 {
+ goschedBackend()
+ }
+}
diff --git a/runtime/internal/runtime/scheduler_events_wasm.go b/runtime/internal/runtime/scheduler_events_wasm.go
index 11944dad7d..abc44c940c 100644
--- a/runtime/internal/runtime/scheduler_events_wasm.go
+++ b/runtime/internal/runtime/scheduler_events_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads)
+//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
diff --git a/runtime/internal/runtime/scheduler_waiter_wasm.go b/runtime/internal/runtime/scheduler_waiter_wasm.go
index 81f22d2434..fe68b8d2ba 100644
--- a/runtime/internal/runtime/scheduler_waiter_wasm.go
+++ b/runtime/internal/runtime/scheduler_waiter_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads)
+//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
package runtime
diff --git a/runtime/internal/runtime/scheduler_waiter_wasm_workers.go b/runtime/internal/runtime/scheduler_waiter_wasm_workers.go
new file mode 100644
index 0000000000..f8ff38c69c
--- /dev/null
+++ b/runtime/internal/runtime/scheduler_waiter_wasm_workers.go
@@ -0,0 +1,71 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package runtime
+
+import "github.com/goplus/llgo/runtime/internal/clite/sync/atomic"
+
+func tryCasgstatus(gp *g, oldval, newval uint32) bool {
+ _, ok := atomic.CompareAndExchange(&gp.atomicstatus, oldval, newval)
+ return ok
+}
+
+// SchedulerWaiter is an opaque one-shot notification owned by a waiting G.
+// The notification closes the Ready-before-Park race between Web workers.
+type SchedulerWaiter struct {
+ gp *g
+ notified uint32
+}
+
+func CurrentSchedulerWaiter() SchedulerWaiter {
+ return SchedulerWaiter{gp: getg()}
+}
+
+func (w *SchedulerWaiter) Park() {
+ gp := w.gp
+ if gp == nil || getg() != gp {
+ fatal("runtime: invalid WebAssembly scheduler waiter")
+ return
+ }
+ if _, ok := atomic.CompareAndExchange(&w.notified, uint32(1), uint32(0)); ok {
+ return
+ }
+
+ casgstatus(gp, _Grunning, _Gwaiting)
+ // Keep the G active until an early notification is either consumed here
+ // or has made the G runnable. Otherwise worker 0 can observe a transient
+ // zero active count and report a false deadlock.
+ if _, ok := atomic.CompareAndExchange(&w.notified, uint32(1), uint32(0)); ok {
+ if tryCasgstatus(gp, _Gwaiting, _Grunning) {
+ return
+ }
+ }
+
+ atomic.Add(&wasmMultiSched.active, ^uint32(0))
+ wakeWasmEventWorker()
+ worker := gp.context.platform.owner
+ gp.context.platform.context.Swap(&worker.system, nil)
+ atomic.Store(&w.notified, uint32(0))
+}
+
+func (w *SchedulerWaiter) Ready() {
+ gp := w.gp
+ if gp == nil {
+ fatal("runtime: ready of invalid WebAssembly scheduler waiter")
+ return
+ }
+ if _, ok := atomic.CompareAndExchange(&w.notified, uint32(0), uint32(1)); !ok {
+ return
+ }
+ if tryCasgstatus(gp, _Gwaiting, _Grunnable) {
+ atomic.Add(&wasmMultiSched.active, uint32(1))
+ enqueueWasmG(gp.context.platform.owner, gp)
+ }
+}
+
+func SchedulerProcID() int {
+ worker := currentWasmWorker()
+ if worker == nil {
+ return 0
+ }
+ return worker.index
+}
diff --git a/runtime/internal/wasmevent/dispatch_wasm_workers.go b/runtime/internal/wasmevent/dispatch_wasm_workers.go
new file mode 100644
index 0000000000..5657e13a52
--- /dev/null
+++ b/runtime/internal/wasmevent/dispatch_wasm_workers.go
@@ -0,0 +1,17 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package wasmevent
+
+var wakeEvent func()
+
+// InstallWake registers the scheduler wakeup used when another worker changes
+// the earliest host-event deadline.
+func InstallWake(wake func()) {
+ wakeEvent = wake
+}
+
+func notifyWake() {
+ if wakeEvent != nil {
+ wakeEvent()
+ }
+}
diff --git a/runtime/internal/wasmevent/runtime_mutex_workers.go b/runtime/internal/wasmevent/runtime_mutex_workers.go
new file mode 100644
index 0000000000..128c6b8e3a
--- /dev/null
+++ b/runtime/internal/wasmevent/runtime_mutex_workers.go
@@ -0,0 +1,25 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package wasmevent
+
+import "github.com/goplus/llgo/runtime/internal/clite/pthread/sync"
+
+type runtimeMutex struct {
+ mutex sync.Mutex
+}
+
+func newRuntimeMutex() runtimeMutex {
+ var result runtimeMutex
+ if result.mutex.Init(nil) != 0 {
+ panic("wasmevent: failed to initialize timer mutex")
+ }
+ return result
+}
+
+func (m *runtimeMutex) Lock() {
+ m.mutex.Lock()
+}
+
+func (m *runtimeMutex) Unlock() {
+ m.mutex.Unlock()
+}
diff --git a/runtime/internal/wasmevent/runtime_wasm.go b/runtime/internal/wasmevent/runtime_wasm.go
index c29532a79f..f3fa8bb433 100644
--- a/runtime/internal/wasmevent/runtime_wasm.go
+++ b/runtime/internal/wasmevent/runtime_wasm.go
@@ -1,4 +1,4 @@
-//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads)
+//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
diff --git a/runtime/internal/wasmevent/runtime_wasm_workers.go b/runtime/internal/wasmevent/runtime_wasm_workers.go
new file mode 100644
index 0000000000..e57f107fa6
--- /dev/null
+++ b/runtime/internal/wasmevent/runtime_wasm_workers.go
@@ -0,0 +1,119 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+package wasmevent
+
+import _ "unsafe"
+
+const LLGoFiles = "_wrap/event_wasm.c"
+
+type dueTimer struct {
+ timer *Timer
+ callback Callback
+ arg any
+ scheduled int64
+}
+
+var runtimeQueue queue
+var runtimeQueueLock = newRuntimeMutex()
+
+func Reset(timer *Timer, when, period int64, callback Callback, arg any) bool {
+ if timer == nil {
+ return false
+ }
+ runtimeQueueLock.Lock()
+ installEventLoop(pollRuntimeQueue, waitRuntimeQueue)
+ active := runtimeQueue.reset(timer, when, period, callback, arg)
+ runtimeQueueLock.Unlock()
+ notifyWake()
+ return active
+}
+
+func Stop(timer *Timer) bool {
+ runtimeQueueLock.Lock()
+ active := runtimeQueue.stop(timer)
+ runtimeQueueLock.Unlock()
+ if active {
+ notifyWake()
+ }
+ return active
+}
+
+func pollRuntimeQueue() int {
+ now := Now()
+ ran := 0
+ for {
+ runtimeQueueLock.Lock()
+ due, ok := popRuntimeDue(now)
+ runtimeQueueLock.Unlock()
+ if !ok {
+ return ran
+ }
+ if due.callback != nil {
+ due.callback(due.arg, due.timer, due.scheduled, now)
+ }
+ runtimeQueueLock.Lock()
+ if !due.timer.active {
+ due.timer.callback = nil
+ due.timer.arg = nil
+ }
+ runtimeQueueLock.Unlock()
+ ran++
+ }
+}
+
+func popRuntimeDue(now int64) (due dueTimer, ok bool) {
+ if len(runtimeQueue.timers) == 0 {
+ return due, false
+ }
+ timer := runtimeQueue.timers[0]
+ if timer.when > now {
+ return due, false
+ }
+ due = dueTimer{
+ timer: timer,
+ callback: timer.callback,
+ arg: timer.arg,
+ scheduled: timer.when,
+ }
+ period := timer.period
+ runtimeQueue.remove(0)
+ if period > 0 {
+ next := nextPeriodicDeadline(due.scheduled, period, now)
+ runtimeQueue.reset(timer, next, period, due.callback, due.arg)
+ }
+ return due, true
+}
+
+func waitRuntimeQueue() bool {
+ for {
+ if pollRuntimeQueue() != 0 {
+ return true
+ }
+ now := Now()
+ deadline, ok := NextDeadline()
+ if !ok {
+ return false
+ }
+ if deadline > now {
+ hostWait(uint64(deadline - now))
+ }
+ }
+}
+
+// NextDeadline reports the earliest active host-event deadline.
+func NextDeadline() (int64, bool) {
+ runtimeQueueLock.Lock()
+ deadline, ok := runtimeQueue.deadline()
+ runtimeQueueLock.Unlock()
+ return deadline, ok
+}
+
+func Now() int64 {
+ return hostNow()
+}
+
+//go:linkname hostNow C.llgo_wasm_event_now
+func hostNow() int64
+
+//go:linkname hostWait C.llgo_wasm_event_wait
+func hostWait(nanoseconds uint64)
diff --git a/runtime/internal/wasmworkers/_wrap/workers.c b/runtime/internal/wasmworkers/_wrap/workers.c
new file mode 100644
index 0000000000..4c2e6a958d
--- /dev/null
+++ b/runtime/internal/wasmworkers/_wrap/workers.c
@@ -0,0 +1,36 @@
+#include
+#include
+#include
+#include
+
+#ifndef LLGO_WASM_WORKERS
+#define LLGO_WASM_WORKERS 1
+#endif
+
+static _Thread_local void *llgo_wasm_current_worker;
+
+int llgo_wasm_worker_count(void) {
+ return LLGO_WASM_WORKERS;
+}
+
+void *llgo_wasm_worker_current(void) {
+ return llgo_wasm_current_worker;
+}
+
+void llgo_wasm_worker_set_current(void *worker) {
+ llgo_wasm_current_worker = worker;
+}
+
+int llgo_wasm_worker_wait(
+ uint32_t *address, uint32_t expected, int64_t timeout_nanoseconds) {
+ double timeout_milliseconds = INFINITY;
+ if (timeout_nanoseconds >= 0) {
+ timeout_milliseconds = (double)timeout_nanoseconds / 1000000.0;
+ }
+ return emscripten_futex_wait(
+ (volatile void *)address, expected, timeout_milliseconds);
+}
+
+int llgo_wasm_worker_wake(uint32_t *address) {
+ return emscripten_futex_wake((volatile void *)address, INT_MAX);
+}
diff --git a/runtime/internal/wasmworkers/workers.go b/runtime/internal/wasmworkers/workers.go
new file mode 100644
index 0000000000..049070fb80
--- /dev/null
+++ b/runtime/internal/wasmworkers/workers.go
@@ -0,0 +1,94 @@
+//go:build llgo && js && wasm && llgo.wasm_workers
+
+/*
+ * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Package wasmworkers contains the Emscripten host boundary for the bounded
+// WebAssembly M/P worker pool.
+package wasmworkers
+
+import (
+ "unsafe"
+
+ c "github.com/goplus/llgo/runtime/internal/clite"
+ "github.com/goplus/llgo/runtime/internal/clite/pthread"
+)
+
+const LLGoFiles = "_wrap/workers.c"
+
+//llgo:type C
+type Entry func(unsafe.Pointer) unsafe.Pointer
+
+func Count() int {
+ return int(workerCount())
+}
+
+func Current() unsafe.Pointer {
+ return workerCurrent()
+}
+
+func SetCurrent(worker unsafe.Pointer) {
+ workerSetCurrent(worker)
+}
+
+func Start(entry Entry, arg unsafe.Pointer, stackSize uintptr) int {
+ var attr pthread.Attr
+ if ret := attr.Init(); ret != 0 {
+ return int(ret)
+ }
+ if ret := attr.SetDetached(pthread.CreateDetached); ret != 0 {
+ _ = attr.Destroy()
+ return int(ret)
+ }
+ if stackSize != 0 {
+ if ret := attr.SetStackSize(stackSize); ret != 0 {
+ _ = attr.Destroy()
+ return int(ret)
+ }
+ }
+ var thread pthread.Thread
+ ret := pthread.Create(
+ &thread,
+ &attr,
+ pthread.RoutineFunc(entry),
+ c.Pointer(arg),
+ )
+ _ = attr.Destroy()
+ return int(ret)
+}
+
+func Wait(addr *uint32, expected uint32, timeoutNanoseconds int64) {
+ workerWait(addr, expected, timeoutNanoseconds)
+}
+
+func Wake(addr *uint32) {
+ workerWake(addr)
+}
+
+//go:linkname workerCount C.llgo_wasm_worker_count
+func workerCount() c.Int
+
+//go:linkname workerCurrent C.llgo_wasm_worker_current
+func workerCurrent() unsafe.Pointer
+
+//go:linkname workerSetCurrent C.llgo_wasm_worker_set_current
+func workerSetCurrent(unsafe.Pointer)
+
+//go:linkname workerWait C.llgo_wasm_worker_wait
+func workerWait(addr *uint32, expected uint32, timeoutNanoseconds int64) c.Int
+
+//go:linkname workerWake C.llgo_wasm_worker_wake
+func workerWake(addr *uint32) c.Int