Skip to content

internal/task: reuse goroutine stacks with the leaking GC - #5981

Open
joeshaw wants to merge 2 commits into
tinygo-org:devfrom
joeshaw:task-leaking-stack-reuse
Open

joeshaw wants to merge 2 commits into
tinygo-org:devfrom
joeshaw:task-leaking-stack-reuse

Conversation

@joeshaw

@joeshaw joeshaw commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

I'll be very upfront and say: I'm not sure about this PR. But I would like opinions on it.

Beginning in 0.42, testing started a goroutine per subtest. This interacts poorly with -gc=leaking when you have a lot of subtests, because goroutine stacks are discarded and never released. In a production project we have over 100,000 subtests that run, and starting with 0.42 we now run out of memory after running about 4,000.

This pull request, when scheduler.asyncify and gc.leaking are both enabled, puts used stacks onto a free list when the goroutine exits and will reuse them when subsequent goroutines begin. The reason why I'm on the fence about it is because then it's not really "leaking." However, one of the reasons to use the leaking GC mode is because you don't want the overhead of accounting for and processing allocations. This adds accounting but there's no real cost to it.

Stacks are zeroed when reused. The overhead for reusing a 64 KB stack over creating a new one is about 400 ns. (It is substantially lower overhead if the stack is not zeroed.)

The leaking GC never frees memory, so every goroutine that exited lost its
stack.  A program that starts many short lived goroutines ran out of memory,
for example a test binary with many subtests, since testing.T.Run starts a
goroutine for every subtest.

With the leaking GC, keep the stacks of exited goroutines in a list and give
them to new goroutines of the same stack size.  Stacks are zeroed on reuse.
Copilot AI balanced review requested due to automatic review settings October 21, 2026 19:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

No blocking issues remain in the collector-specific stack reuse, exit handling, or regression-test integration.

No open issues 🎉

What changed in this PR

Reuses exited goroutine stacks under asyncify with the leaking GC, reducing memory growth in workloads with many short-lived goroutines.

Changes:

  • Reuses and zeroes stacks of matching sizes.
  • Preserves stack allocation and release behavior for other collectors.
  • Adds a 100,000-goroutine regression test.
File Description
testdata/​goroutines-leaking.txt Records expected test output.
testdata/​goroutines-leaking.go Exercises stack reuse across goroutine batches.
src/​internal/​task/​task_asyncify.go Tracks stack sizes and delegates allocation and release.
src/​internal/​task/​task_asyncify_stack.go Preserves behavior for non-leaking collectors.
src/​internal/​task/​task_asyncify_stack_leaking.go Implements stack reuse and zeroing.
main_test.go Registers the leaking-GC regression test for wasip1.

Review effort: Balanced

@dgryski

dgryski commented Oct 21, 2026

Copy link
Copy Markdown
Member

Two comments from my robot

* Free-list scan. allocStack walks the list for an exact size match. If goroutine sizes are mixed, a non-matching head makes every allocation walk the list. Today the asyncify size is a single default (64 KB on wasip1), so it is O(1). A comment saying so, or a map keyed by size, would help later readers.

* Dead guard. The size < unsafe.Sizeof(freeStack{}) check in releaseStack can't trigger. It's harmless but can go.

I asked it about the dead guard claim,

The check needs size < 8, since freeStack is 8 bytes on wasm32. Asyncify is wasm32-only, so that is the only size that matters.
The only way to get a small size is -stack-size, which feeds Options.StackSize. I built the PR with -gc=leaking -stack-size=N for several values of N.
4B, 7B and 8B: the program traps with unreachable in tinygo_launch. This happens before any goroutine exits. main itself runs as a task on that stack size, so it dies first.
16B: the program traps the same way. A separate run of a plain go func(){ done <- 1 }() program at 16B printed fatal error: stack overflow.
64B and up: the program runs fine, and 64B is already well above the guard.
So any size small enough to hit the guard also kills main at launch. No goroutine exits through releaseStack with such a size.

Every goroutine gets the same stack size with Asyncify, so the search for a
free stack of the right size always stops at the first entry.  Say so where
the list is searched.

releaseStack skipped stacks too small to hold a freeStack.  A stack that
small cannot run main, so no goroutine exits with one and the check never
fires.
@joeshaw

joeshaw commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Added these in f1c2d1f.

@dgryski dgryski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@dgryski

dgryski commented Oct 22, 2026

Copy link
Copy Markdown
Member

I'd say we could consider trying to apply this to other non-asyncify platforms, but I'm guessing if you're running on a pico with -gc=leaking you're probably not spawning a lot of goroutines...

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants