← All posts

pprof Lab and the performance story CPU profiles miss

CPU profiles show where code spends cycles. Block profiles show where goroutines lose time waiting. pprof Lab demonstrates why that distinction matters and how to verify contention before you tune the wrong path.

Performance work often starts with a CPU profile. That is useful, but incomplete. If your service feels slow while CPU usage stays modest, the bottleneck often sits elsewhere. Threads wait on locks. Goroutines stall on channel sends. I/O backpressure spreads through the process. A CPU profile will not show time spent blocked.

pprof Lab focuses on one of the easiest mistakes in profiling, treating CPU time as the whole story. It shows what a block profile sees that a CPU profile cannot. For you as a practitioner, this matters because many latency problems come from contention and waiting, not raw compute. If you only inspect samples taken while the program runs on CPU, you miss the queues forming behind shared resources.

The broader lesson is simple. Profiling tools answer different questions. You need to match the profile type to the failure mode you suspect. When you do, the output becomes easier to trust and easier to act on.

CPU time and wait time are different signals

A CPU profile samples where execution spends time while running on the processor. It is strong at finding hot loops, expensive parsing, heavy allocation paths, compression work, serialization overhead, and similar compute-bound costs. If one function burns cycles, a CPU profile tends to point at it.

A block profile measures time spent waiting on synchronization events. In Go, this often includes channel operations, mutex contention, select cases waiting to proceed, and other points where a goroutine stops making forward progress because another part of the system holds the path open. That is a different class of signal.

This distinction matters in production systems:

  • A handler pool looks idle at the CPU level, yet requests queue because workers wait on a shared lock.
  • A background writer throttles the foreground path because sends into a channel block under load.
  • A cache lowers CPU cost per request, yet raises tail latency because access serializes around one structure.
  • A dependency slows down, and your code spends more time waiting around coordination points than computing.

If you looked only at CPU samples in these cases, you might conclude there is no issue in the process. Or worse, you might optimize code outside the bottleneck. The block profile changes the question from “what burns cycles” to “what stops progress.”

What to inspect in a block profile

When you read a block profile, start with cumulative blocked time and the call stacks attached to it. You are looking for places where many goroutines converge on a shared path. The top frame alone is often less useful than the stack shape beneath it. A blocked channel send means one thing if the receiver is slow. It means another if the receiver is itself waiting on a lock or an external dependency.

Good inspection habits include:

  • Compare hot stacks in the CPU profile and the block profile. Overlap is useful, but separation is more useful. Separation tells you waiting, not compute, dominates the symptom.
  • Look for fan-in points. Queues, worker pools, caches, and shared maps wrapped in locks often appear here.
  • Check whether blocked time clusters around one endpoint, one pipeline stage, or one resource manager.
  • Distinguish brief, frequent blocking from long, rare stalls. Both hurt, but they point to different fixes.

A practical read of the signal often looks like this:

  1. CPU profile shows no severe hotspot.
  2. Block profile shows heavy waiting in one coordination path.
  3. The stack reveals a narrow shared resource.
  4. You inspect code around ownership, buffer sizing, lock scope, or worker concurrency.

That sequence is the core design lesson pprof Lab demonstrates. Do not ask one profile type to answer every performance question.

How you would verify the claim

The claim is narrow and testable. A block profile exposes waiting time a CPU profile does not. You do not need a large system to verify this. A small Go program with controlled contention is enough.

One clean setup uses two variants of a workload:

  • Variant A performs CPU-heavy work with little coordination.
  • Variant B performs modest work but forces many goroutines through a contended lock or blocked channel.

In Variant A, the CPU profile should reveal clear hotspots. The block profile should carry little signal beyond routine coordination. In Variant B, the CPU profile should look less dramatic, while the block profile should show large blocked time at the contention point.

If you are verifying this in your own service, keep the method disciplined:

  • Reproduce one symptom under a steady load pattern.
  • Capture CPU and block profiles over the same window.
  • Hold configuration steady between captures.
  • Compare top stacks, cumulative time, and where concurrency converges.
  • Change one design choice, then capture both profiles again.

The important part is correlation. If blocked time falls after narrowing lock scope, adding buffering, removing a serial section, or splitting a shared resource, and user-visible latency improves, you have stronger evidence than a CPU profile alone would provide.

You should also check for false comfort. Reduced CPU usage does not mean the system got faster. A process waiting on locks can look cheap in CPU terms while delivering worse throughput and higher latency.

Where this class of system goes wrong

Teams often collect the profile they know how to read, then force the problem into that lens. That is how waiting bugs survive. Several failure patterns show up again and again.

Treating low CPU as proof of health

Low CPU often gets read as spare capacity. Sometimes it means your goroutines are parked and your requests are waiting. Without a block profile, those states look calmer than they are.

Fixing the visible worker, not the shared choke point

If one goroutine appears at the top of many traces, the temptation is to optimize its code path. But the root cause is often a lock held too long, one channel with too little capacity, or one owner goroutine doing serialized work on behalf of many callers.

Ignoring profile configuration

Block profiling is not magic. If you do not enable or tune the relevant runtime settings in Go, you may collect weak or misleading data. Sampling choices affect what you see. Verification means checking how the profile was captured, not only reading the graph afterward.

Reading stacks without system context

A blocked stack tells you where waiting surfaced, not always where it started. For example, a blocked sender may be downstream of a slow consumer, and that consumer may be slowed by storage, logging, or another lock. You need the request path, queue structure, and ownership model in your head while reading the trace.

Over-correcting contention with blind parallelism

When you find blocking, the first fix is often “add more workers.” Sometimes that helps. Sometimes it deepens contention on the same shared object and worsens tail behavior. The stronger fix is often structural, reduce shared state, shrink critical sections, partition ownership, or make backpressure explicit.

Signals worth reading in your own code

If you suspect waiting rather than compute, there are concrete places to look before you change anything:

  • Mutexes guarding large data structures with mixed read and write traffic.
  • Channels used as both queue and flow-control boundary.
  • Single writer patterns where one goroutine serializes many requests.
  • Batch flushers or loggers shared across request paths.
  • Caches with one global lock or expensive refill behavior.
  • Connection pools where waiting cascades into application-level blocking.

For each one, ask simple engineering questions:

  • How many callers converge here under peak load.
  • How long does ownership stay exclusive.
  • Whether work inside the critical section includes I/O or allocation.
  • Whether backpressure is explicit and measurable.
  • Whether one resource serves unrelated traffic classes.

Those questions matter more than profile screenshots alone. The profile points. Your design review explains why.

What to watch next

The next step after seeing a block profile is not broad optimization. It is tighter measurement. Capture CPU, block, and if needed mutex or trace data around one reproduced symptom. Compare them across one design change. Keep the hypothesis narrow.

pprof Lab demonstrates a useful engineering habit. Pick the profile type that matches the kind of time your system is losing. When your code spends time waiting, a CPU profile stays quiet. The block profile is where the missing story starts.