pprof Lab

The same bounded queue as the backpressure lab, profiled offline and compared two captures at a time.

The finding. Under the blocking policy, Go's block profiler attributes the producer's stall to a named application function — (*Lab).offer, by way of sync.(*Cond).Wait. Under either drop policy that frame is absent, because the producer never waits. The CPU profile of the very same run reports almost nothing: the workers sleep for their service time rather than spinning, so the work is off-CPU by construction. That is not a limitation of the capture — it is what this workload is, and it is the reason the block profile is the instrument here.

Two independent instruments agree. The delay the block profiler attributes to that stall path, and the stall time the simulation counts for itself, are measured by completely unrelated mechanisms. Across the eight captures below they agree to within 0.6%. Every other figure on this page rests on that.

Nothing on this page touches a chain.

These are recorded captures, not a live profile of this server

Nothing here profiles the process serving this page, and the reason is a property of the Go runtime rather than caution. CPU profiling in Go is process-global and one-at-a-time: StartCPUProfile returns an error while another profile is running, and the profile it writes covers the whole process rather than one request. A per-visitor capture on a shared server could therefore neither be isolated from whoever else was mid-request, nor kept from showing them to you.

So every figure below was captured offline by a committed harness, one config per process, and checked in as JSON. The harness is pigfox/pprof-lab at 56de858, and make capture regenerates the whole set in about forty seconds.

Where these numbers came from

Machine
8 logical CPUs, x86-64 Linux
Go
go1.26.5 (linux/amd64)
GOMAXPROCS
8
GOGC
100
Capture length
5 s per config
SetBlockProfileRate
1 — every blocking event, not a sample

The producer has a ceiling, and it matters when you read these. The simulation's producer sleeps between arrivals and cannot sleep for less than 1 ms, so with timer overshoot and scheduler contention it tops out somewhere near three to four hundred messages a second no matter what arrival rate is requested. Ask for 800/s and roughly 459 is what actually gets offered. That is the harness behaving correctly, not failing — and it is why the capture grid makes the worker pool slow rather than the producer fast.

Compare two captures

Loading the two captures…

Block profile — time spent blocked on a synchronization primitive, in seconds. This is not CPU time.

Width is block delay (seconds). Total:

CPU profile — on-CPU time, in seconds, for the same run. Shown because it is nearly empty, which is the result.

Width is on-CPU time (seconds). Total:

Block profile — time spent blocked on a synchronization primitive, in seconds. This is not CPU time.

Width is block delay (seconds). Total:

CPU profile — on-CPU time, in seconds, for the same run. Shown because it is nearly empty, which is the result.

Width is on-CPU time (seconds). Total:

What changed, left to right

Frames are compared by their full call path in the block profile; deltas below are block delay in seconds unless a row says otherwise.

FrameLeftRightDelta
Goroutines at peak
Heap in use (bytes)
Messages shed
Producer stall, simulation counter

Reading these honestly

Why the CPU profile is nearly empty. It is a sampling profiler: it interrupts the process on a timer and records what is on-CPU at that instant. This workload is asleep almost all of the time, so a five-second run yields only a few dozen samples. The limitation is the sample count — you cannot meaningfully diff two trees built from twenty-odd samples — not that application frames are missing from it. They are present; there are simply too few samples for the shape to mean anything.

Most of a block profile is not contention. The largest totals in every capture here are workers sleeping for their service time and goroutines parked for the whole run. Those are deliberate sleeps, not a system fighting itself. The number worth reading is the one on the producer's stall path, which is why the diff table names the path rather than reporting one total.

One observation worth stating narrowly. Across the committed grid rows, all three overload policies completed exactly 392 messages. On these captures the policy decided who paid for the overload rather than how much work got done. That is what these eight runs show — it is not offered as a general property of queueing systems.

Why one process per capture. Go's block and heap profiles accumulate for the life of a process and nothing clears them, so two configs captured in one process give the second the first's records added to its own. That was measured while building this: two policies in one process reported an identical 2.39 s in the first policy's stall frame under both, and the total grew from 4.84 s to 9.57 s instead of being replaced. The second tree was not obviously wrong. It was plausible, and wrong.