Skip to content

runtime: Asyncify goroutine stacks are scanned whole, so stale words keep garbage alive #5877

Open

Description

@0pcom

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:

before: heapAlloc 367.2 MB
after:  heapAlloc  80.7 MB

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.

Branch on dev: https://github.com/0magnet/tinygo/tree/wasm-live-stack-ranges. I can open a PR.

Activity

  1. 0pcom commented on Oct 13, 2026

    @0pcom
    ContributorAuthor

    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.

  2. 0pcom commented on Oct 14, 2026

    @0pcom
    ContributorAuthor

    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.

  3. dgryski commented on Oct 16, 2026

    @dgryski
    Member

    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.
    
  4. dgryski commented on Oct 16, 2026

    @dgryski
    Member

    @davecheney @jakebailey Any other comments here?

  5. jakebailey commented on Oct 16, 2026

    @jakebailey
    Member

    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.

  6. dgryski commented on Oct 17, 2026

    @dgryski
    Member

    @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.

  7. 0pcom commented on Oct 17, 2026

    @0pcom
    ContributorAuthor

    @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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions