You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
runtime: Asyncify goroutine stacks are scanned whole, so stale words keep garbage alive #5877
Under the Asyncify scheduler each goroutine stack is one heap object with the conservative layout, so the GC scans all of it. The stack keeps the save area at the bottom and the C stack at the top. The gap between them still holds words from earlier, deeper calls, and any of them that looks like a pointer keeps that object alive for as long as the goroutine lives.
A probe with 400 goroutines that call 12 levels deep, holding a 64 KiB slice per level, and then park shallow:
finalizerlarge.go and gc-boehm.go fail on wasm for the same reason. Both pass with the change below.
In a large program in the browser, about 500 goroutines with -stack-size=256KB, the heap still in use after GC at 30 minutes went from 366 MB to 239 MB. That run is less controlled than the probe, since the network load differed.
The change allocates stacks without pointers and keeps a list of tasks with heap stacks. markStack then scans [stack base, asyncifysp) and [csp, top) of each one. It uses the current stack pointer for the running task, and the saved one for a task that resumed another from a JS callback. The other TinyGo testdata programs give the same output on wasm before and after.
Update: the linked branch is not ready. On dev it makes finalizer.go, finalizerbits.go and finalizerlarge.go fail on wasm. It passes them in my fork, which runs a different optimization pipeline for wasm, so the live ranges probably miss a root in some code shapes. I'm working out which one before proposing it again. The problem in the description still stands.
The earlier failure was not a missing root. My first version grew Resume's frame on the system stack, so savedStackPointer moved down by 32 bytes. The new lowest slot was never written and still held a heap address from init, and the 8 byte object in finalizer.go was then allocated at that address and stayed pinned. Resume now only sets a flag, and stacks leave the list in exit on the goroutine's own stack, so the frame is unchanged.
The finalizer tests depend on what is left in that slot, so a change elsewhere could trip them the same way.
My bot seems to be happy with this change. It benchmarks significantly faster in the extreme case; overhead is fairly minimal.
Here are the benchmarks by bot decided to run (in case anybody wants to replicate them). They were targeted to wasip1 and benchmarked with hyperfine+wasmtime.
GC mark cost with many parked goroutines. This is where the patch should win. Start 500 goroutines with -stack-size=256KB, each recursing about 12 levels and then parking. Then allocate garbage in a loop that forces about 200 GC cycles. Report total wall time and NumGC. Before the patch, every cycle scans all 500 stacks whole, which is about 128 MB. After, it scans only the live ranges.
Goroutine churn. This measures the overhead the patch adds. Spawn and exit 200k short goroutines, 100 at a time, using a WaitGroup or channel. The new work is linkStack and unlinkStack on every start and exit, plus the running flag in Resume. It should be negligible, but this is the benchmark that would show it. Report ns per goroutine.
Channel ping-pong. Two goroutines exchange 1M messages. This exercises Resume and Pause heavily, so it catches any cost in the hot scheduler path.
GC with pointer-free heap. This only matters if we make the conservative GC honor NoPtrs. Keep about 200 MB live as large []byte buffers and strings, plus a small pointer graph, then force 50 GCs. Report total GC time. It should improve, since those objects stop being scanned.
I think this leaks a stack on every wasm export call. wasmExportExit pauses without going through task.exit, so these tasks seem to stay linked. I tried 1,000 no-op exports followed by GC and got about 66 MB retained, versus 131 KB without the patch.
@jakebailey thanks, confirmed. The task stayed on the new stack list because wasmExportExit never reaches task.exit, so nothing else unlinked it. The branch now takes the task off the list in wasmExportExit before the last pause: 0magnet@3199bf5e
With 1,000 no-op exports from a wasip1 reactor followed by runtime.GC(), HeapInuse is 65776 bytes before and after on the fixed branch, the same as dev. It was 65795440 before the fix. TestBuild/WebAssembly, TestWasmExport*, TestWasmFuncOf, TestWasmExit and TestWASIPanicTraceback pass. I tested on LLVM 23 with #5782 merged.
@dgryski I'll open the PR as soon as one of my nine open PRs frees a slot.
Activity
0pcom commentedon Oct 13, 2026
Update: the linked branch is not ready. On dev it makes finalizer.go, finalizerbits.go and finalizerlarge.go fail on wasm. It passes them in my fork, which runs a different optimization pipeline for wasm, so the live ranges probably miss a root in some code shapes. I'm working out which one before proposing it again. The problem in the description still stands.
runtime, internal/task: scan only the live part of asyncify stacks
runtime, internal/task: scan only the live part of asyncify stacks
runtime, internal/task: scan only the live part of asyncify stacks
0pcom commentedon Oct 14, 2026
The branch is fixed: https://github.com/0magnet/tinygo/tree/wasm-live-stack-ranges. On dev plus #5782 it passes TestBuild/WebAssembly, including the finalizer tests, and TestWASIPanicTraceback.
The earlier failure was not a missing root. My first version grew Resume's frame on the system stack, so savedStackPointer moved down by 32 bytes. The new lowest slot was never written and still held a heap address from init, and the 8 byte object in finalizer.go was then allocated at that address and stayed pinned. Resume now only sets a flag, and stacks leave the list in exit on the goroutine's own stack, so the frame is unchanged.
The finalizer tests depend on what is left in that slot, so a change elsewhere could trip them the same way.
dgryski commentedon Oct 16, 2026
My bot seems to be happy with this change. It benchmarks significantly faster in the extreme case; overhead is fairly minimal.
Here are the benchmarks by bot decided to run (in case anybody wants to replicate them). They were targeted to wasip1 and benchmarked with hyperfine+wasmtime.
dgryski commentedon Oct 16, 2026
@davecheney @jakebailey Any other comments here?
jakebailey commentedon Oct 16, 2026
I think this leaks a stack on every wasm export call.
wasmExportExitpauses without going throughtask.exit, so these tasks seem to stay linked. I tried 1,000 no-op exports followed by GC and got about 66 MB retained, versus 131 KB without the patch.dgryski commentedon Oct 17, 2026
@jakebailey My bot agrees and happily came up with a fix.
@0pcom Let me know if you need help resolving this once you have a PR open.
runtime, internal/task: scan only the live part of asyncify stacks
0pcom commentedon Oct 17, 2026
@jakebailey thanks, confirmed. The task stayed on the new stack list because
wasmExportExitnever reachestask.exit, so nothing else unlinked it. The branch now takes the task off the list inwasmExportExitbefore the last pause: 0magnet@3199bf5eWith 1,000 no-op exports from a wasip1 reactor followed by
runtime.GC(), HeapInuse is 65776 bytes before and after on the fixed branch, the same as dev. It was 65795440 before the fix. TestBuild/WebAssembly, TestWasmExport*, TestWasmFuncOf, TestWasmExit and TestWASIPanicTraceback pass. I tested on LLVM 23 with #5782 merged.@dgryski I'll open the PR as soon as one of my nine open PRs frees a slot.
internal/task: detach a wasmexport call's stack before its last pause