From 86eaaf2d94653d5d84e5dde15e2d7a14c7c6bf42 Mon Sep 17 00:00:00 2001 From: davidteather Date: Sun, 16 Aug 2026 21:37:19 -0400 Subject: [PATCH] context: skip the Done receive in Err when the channel is closedchan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Err receives from the Done channel so that a non-nil error implies the channel is closed. A blocking receive takes hchan.lock even when the channel is already closed, and Done is lazy. Until something calls it, cancel stores the package-level closedchan. Every canceled context in the program shares one lock. BenchmarkErrCanceledParallelDistinct gives each goroutine its own context and is just as slow as the shared-context case, which is what identifies the shared global rather than any per-context state. closedchan is closed at init and cannot reopen, so observing it needs no synchronization. Skip the receive in that case. Done still supplies the channel, so a cancel in flight blocks on c.mu as before. goos: darwin goarch: arm64 pkg: context cpu: Apple M2 Pro │ old │ new │ │ sec/op │ sec/op vs base │ ErrOK-10 2.017n ± 2% 1.979n ± 2% ~ (p=0.427 n=25) ErrCanceled-10 14.000n ± 2% 3.973n ± 2% -71.62% (p=0.000 n=25) ErrOKParallel-10 0.3072n ± 7% 0.3191n ± 3% ~ (p=0.184 n=25) ErrCanceledParallel-10 36.1900n ± 1% 0.7030n ± 8% -98.06% (p=0.000 n=25) ErrCanceledParallelDistinct-10 36.8500n ± 2% 0.6554n ± 9% -98.22% (p=0.000 n=25) geomean 6.496n 1.029n -84.15% B/op and allocs/op are unchanged. ErrOK and ErrOKParallel return before the changed branch. Fixes #80914 --- src/context/benchmark_test.go | 39 +++++++++++++++++++++++++++++++++++ src/context/context.go | 6 +++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/context/benchmark_test.go b/src/context/benchmark_test.go index d10950d258970f..e4b37f11518592 100644 --- a/src/context/benchmark_test.go +++ b/src/context/benchmark_test.go @@ -208,3 +208,42 @@ func BenchmarkErrCanceled(b *testing.B) { } } } + +func BenchmarkErrOKParallel(b *testing.B) { + ctx, cancel := WithCancel(Background()) + defer cancel() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := ctx.Err(); err != nil { + b.Fatalf("ctx.Err() = %v", err) + } + } + }) +} + +func BenchmarkErrCanceledParallel(b *testing.B) { + ctx, cancel := WithCancel(Background()) + cancel() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := ctx.Err(); err == nil { + b.Fatalf("ctx.Err() = %v", err) + } + } + }) +} + +// BenchmarkErrCanceledParallelDistinct gives each goroutine its own canceled +// context, so the contexts share nothing with each other. Any contention here +// is contention on state shared by every canceled context in the program. +func BenchmarkErrCanceledParallelDistinct(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + ctx, cancel := WithCancel(Background()) + cancel() + for pb.Next() { + if err := ctx.Err(); err == nil { + b.Fatalf("ctx.Err() = %v", err) + } + } + }) +} diff --git a/src/context/context.go b/src/context/context.go index 5c3fe8dec6967a..da596d3c6c1d4e 100644 --- a/src/context/context.go +++ b/src/context/context.go @@ -465,7 +465,11 @@ func (c *cancelCtx) Err() error { // An atomic load is ~5x faster than a mutex, which can matter in tight loops. if err := c.err.Load(); err != nil { // Ensure the done channel has been closed before returning a non-nil error. - <-c.Done() + // closedchan is closed at init and shared by every canceled context, so + // receiving from it would take one process-global lock for nothing. + if done := c.Done(); done != closedchan { + <-done + } return err.(error) } return nil