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