Skip to content

Yield the releasing goroutine's P to a blocked acquirer - #47

Closed
mikluko wants to merge 2 commits into
jackc:masterfrom
mikluko:yield-to-waiter
Closed

mikluko wants to merge 2 commits into
jackc:masterfrom
mikluko:yield-to-waiter

Conversation

@mikluko

@mikluko mikluko commented Sep 13, 2026

Copy link
Copy Markdown

Fixes #46.

Release, Destroy and Hijack wake the first blocked acquirer by releasing the semaphore, which closes that waiter's channel. The runtime queues a goroutine woken that way as runnext of the waker's P, where it runs only once the waker blocks or another P steals it. A releaser that keeps the CPU after Release therefore leaves the resource unused for as long as it runs.

The fix is a runtime.Gosched() after the semaphore release, with the mutex already unlocked, taken only while a goroutine is blocked in Acquire; an atomic counter around acquireSem.Acquire says whether one is. The uncontended path is unchanged.

BenchmarkAcquire_ReleaseThenWork (first commit) is a pool of 4 under ~100 goroutines that hold for 1 ms and keep the CPU for 1 ms after releasing, -cpu 4 -count 3:

                          before                     after
linux/arm64, Go 1.26.7    411-425 µs/op             255-256 µs/op
darwin/arm64, Go 1.27.1   411-477 µs/op             254 µs/op

The same effect on the bare x/sync semaphore, and the per-acquire residue it corresponds to, is in the reproduction attached to #46: https://gist.github.com/mikluko/57dbb8a46e2d38b9b8b56cb4d7675f77

CreateResource and the asynchronous construction in initResourceValue release the semaphore too and are left alone: the constructing goroutine exits right after its release, and CreateResource is a warm-up call.

go vet reports pool_test.go:1308: call to (*testing.B).Fatal from a non-test goroutine on master already; not touched here.

The semaphore wakes a waiter by closing its channel, and the runtime queues it as runnext of the releaser's P, where it runs only once the releaser blocks or another P steals it. A releaser that keeps the CPU after Release therefore leaves the resource unused for as long as it runs. A Gosched after the release, taken only when someone is blocked in Acquire, hands the P over at once. On the new benchmark (4 resources, ~100 waiters, 1 ms hold, 1 ms of work after Release, GOMAXPROCS=4) this goes from 411-477 us/op to 254 us/op.
@jackc

jackc commented Sep 17, 2026

Copy link
Copy Markdown
Owner

This change seems to have a catastrophic performance impact when under heavy CPU load.

dev@lima-pgx-dev-vm ~/puddle (master) % go test -vet=off -run '^$' \
  -bench '^BenchmarkAcquire_ReleaseAfterAcquireWithCPULoad$' \
  -cpu 4 -benchtime=20x -count=1
goos: linux
goarch: arm64
pkg: github.com/jackc/puddle/v2
BenchmarkAcquire_ReleaseAfterAcquireWithCPULoad-4   	      20	      1525 ns/op
PASS
ok  	github.com/jackc/puddle/v2	2.103s
dev@lima-pgx-dev-vm ~/puddle (master) % cd ../puddle-pr47
dev@lima-pgx-dev-vm ~/puddle-pr47 (codex/pr-47) % go test -vet=off -run '^$' \
  -bench '^BenchmarkAcquire_ReleaseAfterAcquireWithCPULoad$' \
  -cpu 4 -benchtime=20x -count=1
goos: linux
goarch: arm64
pkg: github.com/jackc/puddle/v2
BenchmarkAcquire_ReleaseAfterAcquireWithCPULoad-4   	      20	 383744378 ns/op
PASS
ok  	github.com/jackc/puddle/v2	9.701s

@mikluko

mikluko commented Sep 17, 2026

Copy link
Copy Markdown
Author

I don't see how to fix it without pulling patched version of semphore into the codebase. Releaser keeping the CPU is not an obvious edge case. Doesn't look like it warrants the scale of required changes. I think I will address it downstream. Sorry for the noise.

@mikluko mikluko closed this Sep 17, 2026
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.

Acquire: a released resource stays unusable for as long as the releasing goroutine keeps running (~1 ms per contended acquire)

2 participants