Skip to content

runtime: GC stack shrinking is O(n²) in the number of goroutines blocked on one sync.Mutex since Go 1.26 #81957

Open

Description

@mdogan

Go version

1.26.8 and 1.27.1 on both linux/arm64 & darwin/arm64

Output of go env in your module/workspace:

GOARCH='arm64'
GOOS='linux'
GOVERSION='go1.26.8'
GOEXPERIMENT=''
GODEBUG=''
GOARM64='v8.0'
CGO_ENABLED='1'

What did you do?

We run an HTTP reverse proxy built with Go 1.26. Under a traffic spike, thousands of goroutines blocked in sync.Mutex.Lock inside net/http.(*Transport), and the process spent half its CPU in GC. In a 30s CPU profile from the Linux production host (113.8s of samples):

  • runtime.adjustsudogs was 48.9s, 43% of all CPU, through markroot → scanstack → shrinkstack → copystack → adjustsudogs → adjustpointer.
  • Without it, GC would have been about 7s. The walk was about 87% of all GC work.
  • 25.9s of the 49.3s in shrinkstack ran inside gcAssistAlloc, so request goroutines paid for it directly.

Reproducer: n goroutines grow their stacks, return, and block on one sync.Mutex. Automatic GC is off, and the program calls 4 times runtime.GC(). It measures process CPU time (getrusage).

Code: https://go.dev/play/p/mflVhUvT3uO

GOTOOLCHAIN=go1.25.14 go run . -n 40000
GOTOOLCHAIN=go1.26.8  go run . -n 40000

What did you see happen?

All numbers are process CPU time for the 4 timed GCs (sum), on linux/arm64 unless noted.

goroutines blocked on 1 mutex go1.25.14 go1.26.8
5,000 111 ms 233 ms
10,000 203 ms 698 ms
20,000 391 ms 2,412 ms
40,000 751 ms 9,755 ms
80,000 1,541 ms 45,107 ms

When the waiter count doubles, go1.25 grows about 2×. go1.26 grows 3.0×, 3.5×, 4.0×, then 4.6×.

Controls at 40,000 waiters (3 alternating runs where a range is given):

build total GC CPU
go1.25.14 770–782 ms
go1.26.8 9,555–9,885 ms
go1.26.8, GODEBUG=gcshrinkstackoff=1 625 ms
go1.27.1 9,670–10,877 ms
go1.27.1, GODEBUG=gcshrinkstackoff=1 595 ms

darwin/arm64 shows the same pattern at 40,000 waiters: go1.25.14 0.79–0.96s, go1.26.8 7.1–7.2s.

What did you expect to see?

GC stack-shrink cost that grows linearly with the number of blocked goroutines, like in Go 1.25, not growing exponentially.

It looks like this is caused by the commit 8c68a1c1ab (CL 688335, goroutine leak detection, #74609) added this line to semaRoot.queue (sema.go:309 at go1.26.8):

	// Storing this pointer so that we can trace the semaphore address
	// from the blocked goroutine when checking for goroutine leaks.
	s.g.waiting = s

Removing that one line brings go1.26.8 back to go1.25 levels.

build total GC CPU
go1.25.14 770–782 ms
go1.26.8 9,555–9,885 ms
go1.26.8 with only the s.g.waiting = s line in semaRoot.queue removed 786–796 ms

Activity

  1. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Oct 2, 2026
  2. thepudds commented on Oct 2, 2026

    @thepudds
    Member

    CC @VladSaiocUber (for goroutine leak detection) @golang/runtime

  3. gopherbot commented on Oct 3, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/844405 mentions this issue: runtime: avoid scanning semaphore queues during stack copying

  4. self-assigned this
    on Oct 6, 2026
  5. added and removed
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    compiler/runtimeIssues related to the Go compiler and/or runtime.
    on Oct 6, 2026
  6. prattmic commented on Oct 6, 2026

    @prattmic
    Member

    Thanks for the report, I can reproduce this, it definitely needs a fix.

  7. added
    compiler/runtimeIssues related to the Go compiler and/or runtime.
    NeedsFixThe path to resolution is known, but the work has not been done.
    and removed
    NeedsFixThe path to resolution is known, but the work has not been done.
    on Oct 6, 2026
  8. 1 remaining item

  9. added this to the Go1.28 milestone on Oct 6, 2026
  10. mknyszek commented on Oct 6, 2026

    @mknyszek
    Contributor

    Oof, nice catch.

    However, I'm not confident in the correctness and maintainability of the fix in https://go.dev/cl/844405: that seems very subtle at face value. I think we might want to just look for another way to make goroutine leak detection able to discover the semaphore address than via gp.waiting.

  11. added
    NeedsFixThe path to resolution is known, but the work has not been done.
    on Oct 6, 2026
  12. prattmic commented on Oct 6, 2026

    @prattmic
    Member

    I tend to agree, though I don't yet understand why we don't see the same quadratic behavior for goroutines blocked on channels. Those look almost perfectly linear in my empirical testing.

  13. prattmic commented on Oct 6, 2026

    @prattmic
    Member

    Ah, I see.

    For channels, sudog.waitlist is the list of channels that the goroutine is waiting on. Each sudog is for the same goroutine, but different channel.

    For semaphores, sudog.waitlist is the list of goroutines that are waiting on the semaphore. Each sudog is for the same semaphore, but different goroutines.

    So if every goroutine is waiting on the same semaphore, each gp.waiting we scan discovers every goroutine.

    It also seems like this is unsafe? If a semaphore unblocks while we are in adjustsudog, semaRoot.dequeue will concurrently modify the sudog waitlist while we are walking over it. This isn't possible in the channel case because the goroutine can't unblock from a channel while it is in copystack. I don't think this race matters though, since the semaphore address will never be on the stack.

  14. prattmic commented on Oct 7, 2026

    @prattmic
    Member

    Semaphores can't be on the stack, so they don't need adjustment at all (e.g., runtime_SemacquireMutex leaks Mutex.sema to the heap).

    I see two obvious directions here:

    One, adjustsudogs detects the sudog type and skips everything except channels (basically what https://go.dev/cl/844405 does, but could be cleaner).

    Alternatively, gp.waiting only stores channel sudogs and semaphores are stored elsewhere (gp.waitingChan / gp.waitingSema). This latter approach requires more space in the G, but I do think it is more understandable and thus maintainable, so I lean towards that.

    In fact, I think sudog is a mess. It is used for channels, semaphores, and sync.Cond, and all three uses are almost completely disjoint, using different subsets of fields with different semantics. This is how we ended up with waitlist having completely different semantics between channels and semaphores. I am strongly considering splitting sudog into three distinct types for each of the different uses.

  15. gopherbot commented on Oct 8, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/847405 mentions this issue: runtime: throw if unswept spans remain in finishsweep_m

  16. gopherbot commented on Oct 8, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/847406 mentions this issue: runtime: only adjust sudogs for channels

  17. prattmic commented on Oct 13, 2026

    @prattmic
    Member

    @gopherbot Please backport https://go.dev/cl/847406 to Go 1.26 and 1.27. This is a performance regression making stack shrinking O(n^2) on the number of goroutines blocked on the same sync.Mutex. It can make the GC use extremely high CPU.

    This regression was introduced in https://go.dev/cl/688335, which introduced GOEXPERIMENT=goroutineleakprofile. This GOEXPERIMENT was disabled by default in 1.26, but the regression is present whether the experiment is enabled or disabled, so 1.26 is affected as well.

  18. gopherbot commented on Oct 13, 2026

    @gopherbot
    Contributor

    Backport issue(s) opened: #82189 (for 1.26), #82190 (for 1.27).

    Remember to create the cherry-pick CL(s) as soon as the patch is submitted to master, according to https://go.dev/wiki/MinorReleases.

  19. gopherbot commented on Oct 13, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/849145 mentions this issue: Revert "runtime: throw if unswept spans remain in finishsweep_m"

  20. gopherbot commented on Oct 13, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/849146 mentions this issue: runtime: update reason for unswept spans remaining in finishsweep_m

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

Metadata

Metadata

Assignees

Labels

NeedsFixThe path to resolution is known, but the work has not been done.Performancecompiler/runtimeIssues related to the Go compiler and/or runtime.

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions