cd /news/developer-tools/the-pool-that-got-slower-the-more-we… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-59357] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

The pool that got slower the more we shared it

A .NET developer discovered that using ArrayPool<byte>.Shared across multiple threads caused a 20% CPU increase in a market-data-gateway service. The contention was hidden inside the framework's own lock in the shared pool's Rent/Return methods, which static analysis could not detect. The dotnet-diagnostics-mcp tool identified the root cause by tracing CPU samples to Monitor.Enter_Slowpath, revealing the lock inside ArrayPool<byte>.Shared.

read10 min views1 publishedJul 14, 2026

How a 15-minute chat with an LLM found a hidden ArrayPool<byte>.Shared contention point that no static analyzer would ever flag β€” and why the fix had nothing to do with adding a lock.

"CPU on

market-data-gateway

climbed ~20% after we added a broadcast fan-out

path last sprint. No new allocations, no GC pressure, nothing obviously

wrong in the diff. Can someone take a look before we scale out more pods?"

Nobody had touched anything that looked like a synchronization primitive.

The diff that shipped the week before only touched a buffer-pooling helper β€”

swapping a bespoke byte-array cache for the "obviously correct" BCL default,

ArrayPool<byte>.Shared

. That refactor was reviewed and merged specifically

because it removed custom code in favor of a well-known, heavily optimized

framework type. If anything, it should have been a free win.

This is the kind of regression static analysis is structurally blind to: no

analyzer rule says "using ArrayPool<T>.Shared

across threads is suspicious."

It's the recommended way to pool buffers in .NET. The problem isn't in the

code you'd read in a diff β€” it's in a runtime behavior that only shows up

under a specific, easy-to-miss usage pattern.

This is the real value proposition of dotnet-diagnostics-mcp:

Step 1 β€” orient.

user> Can you check what's going on with pid 41213? CPU looks high.

assistant> Let me look at the process first.
β†’ inspect_process(view="triage", processId=41213)
{
  "processId": 41213,
  "resolvedProcess": { "name": "market-data-gateway", "runtime": "net10.0" },
  "summary": "CPU 78% (host has 16 cores). No memory growth trend. No recent
               GC  anomalies. Consider a CPU sample to see where time goes.",
  "hints": [
    "collect_sample(kind=\"cpu\", processId=41213, durationSeconds=10)"
  ]
}

Nothing screaming "leak," nothing screaming "GC." CPU is just... busy. Time to

see where.

Step 2 β€” sample.

assistant> Taking a 10-second CPU sample.
β†’ collect_sample(kind="cpu", processId=41213, durationSeconds=10, topN=20)
{
  "processId": 41213,
  "totalSamples": 18545,
  "summary": "Hottest self-time method: System.Threading.Monitor.Enter_Slowpath
              (2933 exclusive, 15.8% of samples). Rank self-time with
              query_snapshot(view=\"top-methods\") or walk the call path with
              view=\"call-tree\".",
  "handle": "A0CKKWH5VVS9Q68AH3GG",
  "hints": [
    "query_snapshot(handle=\"A0CKKWH5VVS9Q68AH3GG\", view=\"caller-callee\", rootMethodFilter=\"Monitor.Enter_Slowpath\")"
  ]
}

That's the first real surprise. 15.8% of all CPU samples land inside Monitor.Enter_Slowpath. Nobody wrote a

lock

statement in this service.grep -r "lock ("

across the whole repo returns nothing relevant. So where isStep 3 β€” trace the lock back to its call site.

assistant> There's no explicit lock in this codebase. Let's see who's calling
into the monitor slow path.
β†’ query_snapshot(handle="A0CKKWH5VVS9Q68AH3GG", view="caller-callee",
                  rootMethodFilter="Monitor.Enter_Slowpath")
{
  "method": "System.Threading.Monitor.Enter_Slowpath(System.Object)",
  "exclusiveSamples": 2933,
  "exclusivePercent": 15.82,
  "callers": [
    {
      "method": "MarketData.BufferPool.Return(byte[])",
      "samples": 1478,
      "percent": 7.97
    },
    {
      "method": "MarketData.Broadcaster+<>c__DisplayClass.<ConsumeLoop>b__0()",
      "samples": 1455,
      "percent": 7.85
    }
  ]
}

Both callers are our code β€” a thin wrapper method calling .Rent()

on one

side, and a per-connection consume loop calling .Return()

on the other.

Not a ThreadPool

internal, not GC. The lock is being taken

inside ArrayPool<byte>.Shared itself, and it's the

Step 4 β€” read the actual lock, not a guess about it.

ArrayPool<byte>.Shared

's internals are in System.Private.CoreLib

β€” no

source, no PDB for the private implementation type. This is exactly what the

companion server, dotnet-assembly-mcp,

assistant> Let's not guess. I'll look up the actual pool implementation
in the runtime binary you're running.
β†’ find_method(mvidOrPath="System.Private.CoreLib.dll", namePattern="^Return$")
{
  "summary": "5 match(es) for /^Return$/.",
  "matches": [
    { "typeFullName": "System.Buffers.SharedArrayPool`1", "methodName": "Return",
      "signature": "void System.Buffers.SharedArrayPool`1.Return(!0[], bool)",
      "handle": "m:e8c78f6b-...:0x060038A7" }
  ]
}
assistant> Decompiling the real Return method for this exact runtime build.
β†’ decompile_method(moduleVersionId="e8c78f6b-...", metadataToken="0x060038A7")
public override void Return(T[] array, bool clearArray = false)
{
    // ...
    SharedArrayPoolThreadLocalArray[] array2 = t_tlsBuckets ?? InitializeTlsBucketsAndTrimming();
    // ... same-thread fast-path slot swap ...
    if (array3 != null)
    {
        flag2 = (_buckets[num] ?? CreatePerCorePartitions(num)).TryPush(array3);
    }
    // ...
}
assistant> And the fallback path that pushes to a per-core partition:
β†’ find_method(mvidOrPath="System.Private.CoreLib.dll", namePattern="^TryPush$")
β†’ decompile_method(moduleVersionId="e8c78f6b-...", metadataToken="0x060038B3")
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryPush(Array array)
{
    bool result = false;
    Monitor.Enter(this);
    // ... push onto a small fixed-size array-backed stack ...
    Monitor.Exit(this);
    return result;
}

There it is β€” ** Monitor.Enter(this)**, in the flesh, in the exact binary

ArrayPool<byte>.Shared

in this runtime build is SharedArrayPool<T>

, andMonitor.Enter_Slowpath

: it's a real, ordinaryReturn

misses the per-threadTo be clear: dotnet/runtime

's source is public on GitHub β€” nothing here was

hidden. The point isn't that the source was unreachable, it's the ergonomics

of the query. Getting the same answer the "normal" way means: know (or guess)

which internal type backs ArrayPool<T>.Shared

today, find the right tag in

dotnet/runtime

that matches the exact SDK/runtime patch this process is

actually running, navigate to the right file, and read enough surrounding

code to trust you're looking at the live behavior and not a since-changed

version. decompile_method(moduleVersionId, metadataToken)

collapses all of

that into one call, scoped to the exact assembly loaded in the exact process

under investigation β€” no version-matching, no guessing a type name, no

context-switch out of the chat.

Checking the application code (only now, after both tools already

pointed at the exact mechanism, not before):

// One dedicated broadcaster thread β€” the ONLY thread that ever Rents.
while (running)
{
    var buffer = ArrayPool<byte>.Shared.Rent(FrameSize);
    FillFromMarketFeed(buffer);
    channel.Writer.TryWrite(buffer);
}

// N per-connection consumer tasks β€” each may Return a buffer it never Rented.
await foreach (var buffer in channel.Reader.ReadAllAsync())
{
    await connection.WriteAsync(buffer);
    ArrayPool<byte>.Shared.Return(buffer); // <-- always a different thread than Rent
}

Exactly the pattern the tool output implied: one thread rents, many other threads return.

ArrayPool<T>.Shared

isn't a naive shared bag β€” its current .NET 10

implementation, SharedArrayPool<T>

, keeps a single-slot cache stored per-thread (

[ThreadStatic]

buckets): if the Monitor

None of this is a bug in ArrayPool<T>

. It's working exactly as designed β€”

optimized for the common case (rent-use-return on one thread, e.g. inside a

single request handler), and gracefully degrading, but not silently free,

for the cross-thread fan-out case. There is no compiler warning, no analyzer

rule, and no code-review heuristic for "you are about to violate an

implicit performance assumption of a BCL type you didn't write." The only way

to know, for certain, what this process, running this runtime build, is

actually doing is to watch where the CPU goes and read the exact binary it's

running.

The fix isn't "add a lock" (there's nothing to protect) β€” it's swap the pool

implementation to one designed for the actual ownership pattern: many threads

returning items nobody-in-particular rented, i.e., a plain lock-free queue.

public sealed class CrossThreadBufferPool
{
    private readonly ConcurrentQueue<byte[]> _pool = new();
    public byte[] Rent(int size) => _pool.TryDequeue(out var buf) ? buf : new byte[size];
    public void Return(byte[] buffer) => _pool.Enqueue(buffer);
}

Isolated cost, before/after (BenchmarkDotNet, 20,000 cross-thread

Return

calls, buffers pre-rented on a background thread outside the timed

region so only Return

itself is measured):

Scenario Mean time (20,000 calls) vs. contended baseline
ArrayPool.Shared.Return , cross-thread (the bug)
~4.3–5.1 ms 1x (baseline)
ConcurrentQueue<byte[]> Return , cross-thread (the fix)
~126–146 Β΅s ~34x faster
ArrayPool.Shared full Rent+Return, same thread β€” theoretical best case
~115–150 Β΅s ~34–40x faster

The last row is the ceiling: the one scenario with genuinely zero

synchronization, because the per-thread cache slot is always hit. Comparing

it to the ConcurrentQueue

fix shows exactly how much overhead our own fix

still carries: ~126 Β΅s vs. ~115 Β΅s β€” roughly 10% above the theoretical best, for a data structure built to be used safely by an arbitrary number

A single cross-thread Return

call on the unfixed pool costs more than an

entire same-thread Rent+Return round trip. That's how disproportionate the

fast-path miss is.

Live re-check after the fix β€” same 2-thread (1 producer / 1 consumer)

topology, same CPU sampler, before touching anything else:

| Metric | ArrayPool.Shared (cross-thread) | Lock-free pool (cross-thread) | Same-thread control | |---|---|---|---| | Throughput | ~3.2M ops/s | ~6.6M ops/s | ~20M ops/s | Monitor.Enter_Slowpath in CPU sample | 15.8% exclusive | 0% β€” gone entirely | n/a (no shared pool contention path) |

assistant> With the lock-free pool under the same load:
β†’ collect_sample(kind="cpu", processId=41213, durationSeconds=8, topN=20)
{
  "summary": "Hottest self-time method: System.Threading.Thread.Sleep(int32)
              (3972 exclusive, 40.6% of samples). No Monitor frames in the
              top 20 hotspots.",
  ...
}

No Monitor.Enter_Slowpath

anywhere in the profile. The contention didn't

move β€” it disappeared, because the pool that produced it is gone.

ArrayPool<T>.Shared

itself exists as an optimization over the naive

baseline β€” just calling new byte[size]

and letting the GC reclaim it. A

fair objection to everything above: if the shared pool is this contended

under cross-thread Return

, maybe the honest fix is to drop pooling

entirely, not swap in a different pool. Mean latency might even look

competitive, since a plain allocation has zero synchronization of any kind β€”

but the expectation is that Gen0/Gen1 GC pressure would show up in the tail

(P99), not the mean.

We tested it β€” same 2-thread producer/consumer topology, only the pool

implementation swapped for one that allocates fresh on every Rent

and does

nothing on Return

:

| Scenario | Throughput | Monitor contention | |---|---|---| ArrayPool.Shared , cross-thread | ~3.2M ops/s | 15.8% (Monitor) | | Lock-free queue pool, cross-thread | ~6.6M ops/s | 0% | No pooling (new byte[size] every call) | ~180K ops/s | 0% |

No pooling isn't a tail-latency-only regression β€” it's ~18x slower on raw throughput, worse than the contended

ArrayPool.Shared

baseline we started

assistant> Let's check what removing the pool entirely does to GC.
β†’ collect_events(kind="gc", processId=41213, durationSeconds=10)
{
  "summary": "134 collection(s), max  69.9ms, total  5118.1ms."
}
assistant> Breaking that down:
β†’ query_snapshot(handle="...", view="Histogram")
{
  "totalCollections": 134,
  "maxTime": "00:00:00.0698743",
  "buckets": [
    { "label": "<1ms",      "count": 0 },
    { "label": "1-10ms",    "count": 0 },
    { "label": "10-100ms",  "count": 134 },
    { "label": "100-1000ms","count": 0 },
    { "label": ">=1s",      "count": 0 }
  ]
}
assistant> And by generation:
β†’ query_snapshot(handle="...", view="byGeneration")
{
  "generations": [
    { "bucket": "gen1", "count": 134, "total": "00:00:05.1180759",
      "mean": "00:00:00.0381945", "max": "00:00:00.0698743" }
  ]
}

134 gen1 collections in a 10-second window β€” 13.4 per second, every single one landing in the 10–100ms bucket β€” for 5.12 seconds of accumulated time inside those 10 seconds. Over half the wall clock is GC , not

So the honest ranking, worst to best, for this specific cross-thread

fan-out pattern is: no pooling (worst, by both mean and tail) β†’ shared pool with cross-thread Return (contended but bounded) β†’ a pool built for the actual ownership shape (best on every axis measured).

ArrayPool<T>.Shared

Static analysis, linters, and code review are excellent at catching shapes

of bugs β€” a missing await

, a lock

around the wrong object, an O(nΒ²)

loop. They cannot catch a case where the code is a textbook-correct call into

a well-designed BCL type, and the only thing wrong is a runtime ownership

pattern that BCL type happens to be sensitive to. You either know this

specific gotcha already, or you find it by watching the process β€” there is no

third option.

dotnet-diagnostics-mcp

exists so that "watching the process" is a normal

step in an LLM-driven investigation, not a specialized skill gated behind

whoever on the team happens to know dotnet-trace

by heart. Every dynamic

step above β€” inspect_process

, collect_sample

, query_snapshot

β€” is a

single MCP tool call an assistant can make on its own, against a real running

.NET process, with no code changes and no redeploy. And when the trail leads

into a BCL type nobody on the team has source for, its companion server,

dotnet-assembly-mcp,

The reproduction and all numbers in this post are in a small, standalone,

open repro you can run yourself β€” no proprietary code involved. The

investigation pattern itself (fix unrelated to this repro) is inspired by a

real production change in B3MarketDataPlatform,

ac2dc9f

dotnet-diagnostics-mcp

is open source: github.com/pedrosakuma/dotnet-diagnostics.

It ships both an MCP server (for your LLM client of choice) and a standalone

CLI (dotnet-diagnostics-cli

) for scripting the same investigations by hand.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @dotnet-diagnostics-mcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/the-pool-that-got-sl…] indexed:0 read:10min 2026-07-14 Β· β€”