Skip to content

prometheus: do not hold MetricVec lock while Collect sends metrics - #2157

Open
lif0 wants to merge 3 commits into
prometheus:mainfrom
lif0:issue2147
Open

lif0 wants to merge 3 commits into
prometheus:mainfrom
lif0:issue2147

Conversation

@lif0

@lif0 lif0 commented Oct 6, 2026 •

Copy link
Copy Markdown

Summary

This PR changes Collect. It copies the children into a slice under the read lock, releases the lock, and only then sends them. This is the solution proposed in the issue.

Regression test

TestMetricVecCollectWithBlockedGather creates a MetricVec with 2000 children. This is more than the Gather channel can buffer. The Metric.Write of these children blocks until the test releases it. The test starts Gather and waits until it is inside Write. Then it checks that creating a new child and reading an existing child both return. Without the fix the test fails with this error.

registry_test.go:983: GetMetricWithLabelValues("new") blocked while Gather was stuck in Metric.Write --- FAIL: TestMetricVecCollectWithBlockedGather (5.01s)

Benchmarks

Run with go test -count=6 -benchmem on linux/arm64.

BEFORE:

goos: linux
goarch: arm64
pkg: github.com/prometheus/client_golang/prometheus
BenchmarkHandler-3                	    3033	    378080 ns/op	  764520 B/op	    3008 allocs/op
BenchmarkHandler-3                	    3158	    387699 ns/op	  764540 B/op	    3008 allocs/op
BenchmarkHandler-3                	    3294	    375245 ns/op	  764554 B/op	    3009 allocs/op
BenchmarkHandler-3                	    3280	    368884 ns/op	  764513 B/op	    3009 allocs/op
BenchmarkHandler-3                	    3324	    363328 ns/op	  764589 B/op	    3008 allocs/op
BenchmarkHandler-3                	    3399	    362271 ns/op	  764448 B/op	    3008 allocs/op
BenchmarkGatherCounterVec10-3     	  114874	     10691 ns/op	   36200 B/op	      65 allocs/op
BenchmarkGatherCounterVec10-3     	  110187	     11009 ns/op	   36200 B/op	      65 allocs/op
BenchmarkGatherCounterVec10-3     	  109779	     10989 ns/op	   36200 B/op	      65 allocs/op
BenchmarkGatherCounterVec10-3     	  108180	     10904 ns/op	   36200 B/op	      65 allocs/op
BenchmarkGatherCounterVec10-3     	  112704	     10774 ns/op	   36200 B/op	      65 allocs/op
BenchmarkGatherCounterVec10-3     	   87367	     11610 ns/op	   36200 B/op	      65 allocs/op
BenchmarkGatherCounterVec2000-3   	    1059	   1334339 ns/op	  645955 B/op	    8059 allocs/op
BenchmarkGatherCounterVec2000-3   	     928	   1162128 ns/op	  645954 B/op	    8059 allocs/op
BenchmarkGatherCounterVec2000-3   	    1041	   1139636 ns/op	  645962 B/op	    8059 allocs/op
BenchmarkGatherCounterVec2000-3   	     860	   1268678 ns/op	  645955 B/op	    8059 allocs/op
BenchmarkGatherCounterVec2000-3   	    1057	   1124279 ns/op	  645960 B/op	    8059 allocs/op
BenchmarkGatherCounterVec2000-3   	    1069	   1264656 ns/op	  645953 B/op	    8059 allocs/op
PASS
ok  	github.com/prometheus/client_golang/prometheus	21.644s


AFTER:

goos: linux
goarch: arm64
pkg: github.com/prometheus/client_golang/prometheus
BenchmarkHandler-3                	    2887	    372492 ns/op	  764189 B/op	    3009 allocs/op
BenchmarkHandler-3                	    3444	    363621 ns/op	  764233 B/op	    3008 allocs/op
BenchmarkHandler-3                	    3276	    370764 ns/op	  764263 B/op	    3008 allocs/op
BenchmarkHandler-3                	    3444	    360053 ns/op	  764227 B/op	    3009 allocs/op
BenchmarkHandler-3                	    3388	    358896 ns/op	  764310 B/op	    3009 allocs/op
BenchmarkHandler-3                	    3326	    364922 ns/op	  764281 B/op	    3009 allocs/op
BenchmarkGatherCounterVec10-3     	  101595	     11281 ns/op	   36360 B/op	      66 allocs/op
BenchmarkGatherCounterVec10-3     	  111944	     11529 ns/op	   36360 B/op	      66 allocs/op
BenchmarkGatherCounterVec10-3     	  108570	     11303 ns/op	   36360 B/op	      66 allocs/op
BenchmarkGatherCounterVec10-3     	  104690	     11257 ns/op	   36360 B/op	      66 allocs/op
BenchmarkGatherCounterVec10-3     	  112418	     10959 ns/op	   36360 B/op	      66 allocs/op
BenchmarkGatherCounterVec10-3     	  109681	     10914 ns/op	   36360 B/op	      66 allocs/op
BenchmarkGatherCounterVec2000-3   	    1047	   1115556 ns/op	  678727 B/op	    8060 allocs/op
BenchmarkGatherCounterVec2000-3   	    1092	   1114680 ns/op	  678731 B/op	    8060 allocs/op
BenchmarkGatherCounterVec2000-3   	    1077	   1111124 ns/op	  678724 B/op	    8060 allocs/op
BenchmarkGatherCounterVec2000-3   	    1087	   1119619 ns/op	  678719 B/op	    8060 allocs/op
BenchmarkGatherCounterVec2000-3   	    1072	   1111159 ns/op	  678718 B/op	    8060 allocs/op
BenchmarkGatherCounterVec2000-3   	    1063	   1133080 ns/op	  678736 B/op	    8060 allocs/op
PASS
ok  	github.com/prometheus/client_golang/prometheus	21.693s

The fix adds exactly one allocation per Collect (65 -> 66 and 8059 -> 8060 allocs/op) and 16 bytes per child (+32 KiB per Gather for 2000 children).

metricMap.Collect held the vector's read lock while sending its children
to the Gather channel. The only consumer of that channel is the Gather
goroutine, which also calls Metric.Write for every metric it receives.
When one Write was slow or stuck, the channel filled up, Collect blocked
on the send with the read lock held, the first WithLabelValues call with
a new label set queued on the write lock and, because sync.RWMutex
prefers waiting writers, every WithLabelValues call on the vector
blocked from then on.

Collect now snapshots the children under the read lock and sends them
after releasing it. The cost is one temporary slice per Collect. A child
deleted after the snapshot can still be sent once in that Collect, and
Delete and Reset no longer wait for a slow consumer.

Fixes prometheus#2147

Signed-off-by: lif0 <22912194+lif0@users.noreply.github.com>
@lif0

lif0 commented Oct 6, 2026

Copy link
Copy Markdown
Author

fixed conflicts

@lif0 lif0 changed the title prometheus: release vector lock before sending metrics prometheus: fix blocked Metric.Write Oct 6, 2026
@lif0 lif0 changed the title prometheus: fix blocked Metric.Write prometheus: release vector lock before sending metrics Oct 6, 2026
@lif0 lif0 changed the title prometheus: release vector lock before sending metrics prometheus: do not hold MetricVec lock while Collect sends metrics Oct 6, 2026
Comment thread prometheus/vec.go
defer m.mtx.RUnlock()

kadi := 0
for _, metrics := range m.metrics {

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.

Wouldn't this buffer your 33k series (or I assume part of it from the large vector?) into memory, which on constantly blocked ch <- in your case would result in the memory leak?

@lif0 lif0 Oct 7, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No, the snapshot does not copy the series. It is a []Metric, so 16 bytes per child. Each entry points to a metric that already lives in the vector map. The metric data itself is not copied.

I measured it on this branch. The largest vector from the issue has 2084 children and the snapshot takes 40 KiB. If every vector on the 33k target is blocked at the same time, the total is about 528 KiB per Gather. The slice is freed when Collect returns. If Write never returns, that Gather is already leaked with or without this change, and in our heap profile one stuck Gather held about 3.4 MB of partial response (3483 MB / 1032 stuck scrapes). The snapshot adds at most 15% to that.

I agree that #2162 fixes the root cause of this case. This change works together with it. Any slow or stuck Write even one from a custom Collector, no longer blocks WithLabelValues in the application.

@lif0 lif0 Oct 7, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

max latency of GetMetricWithLabelValues with a new label during a normal Gather, Write is never stuck. Details and test code in #2147

case before after
5000 children, real gauge Write 2.5 ms 30 µs
2000 children, write takes about 1 ms per call 1.14 s 160 µs
900 children, write takes about 1 ms per call 50 µs 160 µs

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.

Cool. Can we add a quick microbenchmark to confirm memory assumptions for those gather cases? (and allow next developers to reuse when we change this code).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added BenchmarkGatherMetricVec in registry_test.go. Gauge is the worst case for this change, its Write is almost free, so the cost of the snapshot is easy to see. Histogram with the same sizes shows the change next to a realistic Write.

after vs before
allocations per Gather +1 per vector in all cases
memory per Gather +16 B per child, rounded up to the size class, so +160 B at 10, +16 KiB at 900, +32 KiB at 2000, +80 KiB at 5000 children. For gauge this is +5.5%, for histogram +0.9%, the extra bytes are the same, but the whole Gather is bigger
time, gauge, Apple M2, 8 cores +2.9% at 10, within noise at 900, +9.7% at 2000, +10.7% at 5000
time, gauge, Linux, 3 cores +5.5% at 10, +8.2% at 900, +7.3% at 2000, +7.5% at 5000
time, histogram, Apple M2, 8 cores within noise at 10, 900 and 5000, +7.7% at 2000
time, histogram, Linux, 3 cores +5.3% at 10, +4.3% at 900, within noise at 2000 and 5000

Memory matches the assumption. The time cost is the Gather goroutine waiting for the snapshot before it gets the first metric, the old code started sending during the first map iteration. It is a few percent of Gather. The benefit is that WithLabelValues no longer waits for the scrape, see the latency table above.

It would be good if you could run it on your machine too:

go test -run xxx -bench BenchmarkGatherMetricVec -benchmem -count=6 ./prometheus/
macOS, Apple M2, 8 cores, darwin/arm64
                                          │  before.txt  │             after.txt              │
                                          │    sec/op    │   sec/op     vs base               │
GatherMetricVec/gauge/children=10-8         8.565µ ±  4%   8.815µ ± 7%   +2.92% (p=0.026 n=6)
GatherMetricVec/gauge/children=900-8        380.1µ ± 11%   394.7µ ± 3%        ~ (p=0.065 n=6)
GatherMetricVec/gauge/children=2000-8       1.061m ±  1%   1.163m ± 1%   +9.71% (p=0.002 n=6)
GatherMetricVec/gauge/children=5000-8       2.932m ±  8%   3.245m ± 6%  +10.67% (p=0.002 n=6)
GatherMetricVec/histogram/children=10-8     13.31µ ±  4%   13.65µ ± 2%        ~ (p=0.240 n=6)
GatherMetricVec/histogram/children=900-8    941.2µ ±  8%   984.4µ ± 6%        ~ (p=0.132 n=6)
GatherMetricVec/histogram/children=2000-8   2.258m ±  3%   2.432m ± 3%   +7.69% (p=0.002 n=6)
GatherMetricVec/histogram/children=5000-8   6.732m ± 14%   7.025m ± 2%        ~ (p=0.065 n=6)
geomean                                     457.7µ         484.1µ        +5.76%

                                          │  before.txt  │             after.txt              │
                                          │     B/op     │     B/op      vs base              │
GatherMetricVec/gauge/children=10-8         35.20Ki ± 0%   35.35Ki ± 0%  +0.44% (p=0.002 n=6)
GatherMetricVec/gauge/children=900-8        291.2Ki ± 0%   307.2Ki ± 0%  +5.50% (p=0.002 n=6)
GatherMetricVec/gauge/children=2000-8       599.6Ki ± 0%   631.6Ki ± 0%  +5.34% (p=0.002 n=6)
GatherMetricVec/gauge/children=5000-8       1.377Mi ± 0%   1.456Mi ± 0%  +5.67% (p=0.002 n=6)
GatherMetricVec/histogram/children=10-8     49.80Ki ± 0%   49.96Ki ± 0%  +0.31% (p=0.002 n=6)
GatherMetricVec/histogram/children=900-8    1.568Mi ± 0%   1.584Mi ± 0%  +1.00% (p=0.002 n=6)
GatherMetricVec/histogram/children=2000-8   3.439Mi ± 0%   3.470Mi ± 0%  +0.91% (p=0.002 n=6)
GatherMetricVec/histogram/children=5000-8   8.511Mi ± 0%   8.589Mi ± 0%  +0.92% (p=0.002 n=6)
geomean                                     618.0Ki        633.3Ki       +2.48%

                                          │ before.txt  │             after.txt             │
                                          │  allocs/op  │  allocs/op   vs base              │
GatherMetricVec/gauge/children=10-8          65.00 ± 0%    66.00 ± 0%  +1.54% (p=0.002 n=6)
GatherMetricVec/gauge/children=900-8        3.648k ± 0%   3.649k ± 0%  +0.03% (p=0.002 n=6)
GatherMetricVec/gauge/children=2000-8       8.059k ± 0%   8.060k ± 0%  +0.01% (p=0.002 n=6)
GatherMetricVec/gauge/children=5000-8       20.08k ± 0%   20.08k ± 0%  +0.00% (p=0.002 n=6)
GatherMetricVec/histogram/children=10-8      305.0 ± 0%    306.0 ± 0%  +0.33% (p=0.002 n=6)
GatherMetricVec/histogram/children=900-8    25.25k ± 0%   25.25k ± 0%  +0.00% (p=0.002 n=6)
GatherMetricVec/histogram/children=2000-8   56.06k ± 0%   56.06k ± 0%       ~ (p=0.061 n=6)
GatherMetricVec/histogram/children=5000-8   140.1k ± 0%   140.1k ± 0%  +0.00% (p=0.032 n=6)
geomean                                     6.247k        6.262k       +0.24%
Linux, 3 cores, linux/arm64, go1.26.0
                                          │ before.txt  │             after.txt              │
                                          │   sec/op    │    sec/op     vs base              │
GatherMetricVec/gauge/children=10-3          10.66µ ± 6%   11.24µ ±  5%  +5.51% (p=0.041 n=6)
GatherMetricVec/gauge/children=900-3         407.5µ ± 8%   440.7µ ±  3%  +8.16% (p=0.015 n=6)
GatherMetricVec/gauge/children=2000-3        1.064m ± 5%   1.142m ±  3%  +7.32% (p=0.004 n=6)
GatherMetricVec/gauge/children=5000-3        2.976m ± 2%   3.198m ±  4%  +7.47% (p=0.002 n=6)
GatherMetricVec/histogram/children=10-3      17.29µ ± 2%   18.20µ ±  5%  +5.26% (p=0.002 n=6)
GatherMetricVec/histogram/children=900-3     1.215m ± 2%   1.267m ± 10%  +4.27% (p=0.004 n=6)
GatherMetricVec/histogram/children=2000-3    2.839m ± 2%   2.864m ±  8%       ~ (p=0.485 n=6)
GatherMetricVec/histogram/children=5000-3    7.060m ± 6%   7.179m ±  5%       ~ (p=0.310 n=6)
geomean                                      525.2µ        551.7µ        +5.04%

                                          │  before.txt  │             after.txt              │
                                          │     B/op     │     B/op      vs base              │
GatherMetricVec/gauge/children=10-3          35.20Ki ± 0%   35.35Ki ± 0%  +0.44% (p=0.002 n=6)
GatherMetricVec/gauge/children=900-3         291.1Ki ± 0%   307.1Ki ± 0%  +5.50% (p=0.002 n=6)
GatherMetricVec/gauge/children=2000-3        599.6Ki ± 0%   631.6Ki ± 0%  +5.34% (p=0.002 n=6)
GatherMetricVec/gauge/children=5000-3        1.377Mi ± 0%   1.455Mi ± 0%  +5.67% (p=0.002 n=6)
GatherMetricVec/histogram/children=10-3      49.80Ki ± 0%   49.96Ki ± 0%  +0.31% (p=0.002 n=6)
GatherMetricVec/histogram/children=900-3     1.568Mi ± 0%   1.584Mi ± 0%  +1.00% (p=0.002 n=6)
GatherMetricVec/histogram/children=2000-3    3.439Mi ± 0%   3.470Mi ± 0%  +0.91% (p=0.002 n=6)
GatherMetricVec/histogram/children=5000-3    8.511Mi ± 0%   8.589Mi ± 0%  +0.92% (p=0.002 n=6)
geomean                                      618.0Ki        633.3Ki       +2.48%

                                          │ before.txt  │             after.txt             │
                                          │  allocs/op  │  allocs/op   vs base              │
GatherMetricVec/gauge/children=10-3            65.00 ± 0%    66.00 ± 0%  +1.54% (p=0.002 n=6)
GatherMetricVec/gauge/children=900-3          3.648k ± 0%   3.649k ± 0%  +0.03% (p=0.002 n=6)
GatherMetricVec/gauge/children=2000-3         8.059k ± 0%   8.060k ± 0%  +0.01% (p=0.002 n=6)
GatherMetricVec/gauge/children=5000-3         20.08k ± 0%   20.08k ± 0%  +0.00% (p=0.002 n=6)
GatherMetricVec/histogram/children=10-3        305.0 ± 0%    306.0 ± 0%  +0.33% (p=0.002 n=6)
GatherMetricVec/histogram/children=900-3      25.25k ± 0%   25.25k ± 0%  +0.00% (p=0.002 n=6)
GatherMetricVec/histogram/children=2000-3     56.06k ± 0%   56.06k ± 0%  +0.00% (p=0.002 n=6)
GatherMetricVec/histogram/children=5000-3     140.1k ± 0%   140.1k ± 0%  +0.00% (p=0.002 n=6)
geomean                                       6.247k        6.262k       +0.24%

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Gather on a copy of our production /metrics page (426 vectors, 17 957 children, 33k series), linux/arm64, 4 cores, go1.26, -count=6:

main PR #2157 delta
Gather time 11.86 ms 12.17 ms +0.3 ms, +2.6% (noise is ±10%)
B/op 6.82 MiB 7.11 MiB +300 KiB, +4.3%
allocs/op 104 470 104 633 +163

single-vector benchmark is the worst case because Gather idles while the only vector builds its snapshot. On a real page Gather consumes metrics from other collectors meanwhile, so the time cost drops to a few percent.

Allocations are +163 and not +426. I think this is because since Go 1.25 a variable-size make of up to 32 bytes is stack-allocated, so vectors with one or two children do not allocate at all.

lif0 added 2 commits October 8, 2026 14:44
Signed-off-by: lif0 <22912194+lif0@users.noreply.github.com>
@lif0

lif0 commented Oct 8, 2026

Copy link
Copy Markdown
Author

merged main into my branch

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.

Registry.Gather: a blocked Metric.Write keeps vec RLock and blocks all WithLabelValues calls

2 participants