Skip to content
advanced Phase 15 · CLI Tools, Logging, and Profiling

CPU and Runtime Profiling with pprof

Capture CPU, heap, and goroutine profiles and read them with go tool pprof.

50m
0 problems
Topic Progress 0%

The Four-Second Request Nobody Could Explain

Latency alerts point at one endpoint. The handler looks trivial: decode JSON, call three services, encode. Adding time.Now() around each call says the work takes 12 ms — yet users see four seconds. The gap between measured-in-code and measured-in-production is where profiling starts: you are not looking for code you suspected, you are looking for code the process is actually spending time in.

Go samples CPU while it runs — the profiler interrupts the program 100 times per second and records where the program counter was. Attach to a running service through net/http/pprof:

import _ "net/http/pprof"

That blank import registers /debug/pprof/ on http.DefaultServeMux, which is fine only if you serve nothing else on it. The explicit version, which avoids the footgun of accidentally publishing handlers you did not intend to:

func profileMux() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/debug/pprof/", pprof.Index)
	mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
	mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
	mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
	mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
	return mux
}

With the service under load, capture the profile and open it:

curl -o cpu.out 'http://localhost:6060/debug/pprof/profile?seconds=30'
go tool pprof cpu.out
(pprof) top -cum
(pprof) list decodeOrder
(pprof) web

top with -cum sorts by cumulative time — the total time inside a function including everything it calls — which is what you want when hunting a slow request path; without -cum you see flat time, the time spent in the function's own instructions. list projects samples onto source lines, so the hottest line of your own code shows its exact cost. web renders the call graph, where edge thickness is sample count: the classic find is a thick edge you never suspected — a fmt.Sprintf in a loop, a json.Marshal of a huge struct, a DNS lookup inside the critical section.

Two properties of CPU profiles determine how to read them. Sampling means rare-but-critical paths (a once-a-minute cron inside the window) may not appear at all, and profiles taken without load show nothing but idle. Capture during the symptom, for long enough to average — 30 seconds is the usual floor.

The four-second mystery in our scenario turned out to be a mutex held during a synchronous call, visible as a thick edge from the handler into sync.Mutex.Lock. The code timing said 12 ms because it measured the fast path; the profile said four seconds because it measured where the thread actually waited — which is the skill CPU and Runtime Profiling with pprof exists to build: reading what the runtime reports rather than what the author assumed.

Symptom → Profile: A Field Guide to the Six Endpoints

CPU profile is the wrong instrument for half of all performance bugs. A request that is slow but not CPU-bound will barely register there; a service that leaks memory will not appear in it at all. The /debug/pprof/ index lists the profiles, each answering a different question.

Symptom Profile What you look at
High CPU, hot request profile (CPU) top -cum, list on your functions
Memory grows / RSS climbs heap inuse_space for what is live, alloc_space for churn
Goroutines climb and never fall goroutine stacks that repeat; ?debug=2 for full stacks
Requests block on locks/channels block needs runtime.SetBlockProfileRate first
Contention on a sync.Mutex mutex needs runtime.SetMutexProfileFraction
Slow startup or unexpected delay trace (cmdline, trace) timeline of goroutines, syscalls, GC

The heap profile deserves the most care because it has two views that answer opposite questions. By default it shows inuse_space — what is live right now, sampled at one entry per 512 KB of allocation (runtime.MemProfileRate). Switch to allocation volume with -sample_index=alloc_space:

go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top 10

A service with steady RSS but enormous allocation rate is churning: nothing leaks, but the garbage collector runs constantly and CPU goes to GC. inuse_space would show a flat, healthy graph; alloc_space exposes the churn. Reading both is the difference between diagnosing a leak and mislabeling a garbage-collector bill.

The goroutine profile is the leak detector. When a shutdown path stops draining, the count plateaus instead of falling, and the profile shows the same stack repeated:

curl 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -40

Look for your own functions in the blocked frames — chan send, select, net.(*pollDesc).waitRead — repeated dozens of times. Twenty goroutines parked in workerLoop waiting for a channel nobody closes is a diagnosis, not a hint; the underlying patterns are the ones Goroutine Leaks walks through. Full goroutine dumps (including the blocking profile) are best captured with debug=2, which prints every stack without collapsing duplicates.

A practical capture workflow: reproduce the symptom, grab curl -o out '.../profile?seconds=30' plus heap plus goroutine in one script, and analyze offline with go tool pprof out. Profiles are files — keep them with the incident ticket, because a profile without load conditions is unreproducible evidence.

Profiles in Production: Cost, Cadence, and Containment

The /debug/pprof/ handler serves runtime internals to anyone who can reach the port. The famous disclosure pattern is a cloud instance with :6060 open to the world: an attacker reads /debug/pprof/cmdline (the full command line, including flags that may hold credentials), maps the code layout from profiles and — on services that also expose expvar or admin endpoints — reads internal state. Containment is not optional: bind profiling to localhost, put it behind auth, or expose it only on a management listener.

Cost is the second constraint. A CPU profile costs roughly 2% of throughput while active plus the memory of storing samples; the endpoints are cheap enough to leave registered and expensive enough never to leave reachable. That combination — cheap to capture, dangerous to expose — suggests the modern default: leave profiling compiled in, gated, and enable it on demand or on a schedule.

On-demand capture is the standard incident response:

# three artifacts, one script, attached to the incident
curl -o cpu.out  "http://svc:6060/debug/pprof/profile?seconds=30"
curl -o heap.out "http://svc:6060/debug/pprof/heap"
curl -o gor.out  "http://svc:6060/debug/pprof/goroutine?debug=2"

Short-lived programs never reach an HTTP port, so they start and stop the profiler around the interesting region with runtime/pprof:

func main() {
	flag.Parse()
	f, err := os.Create("reindex.out")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	if err := pprof.StartCPUProfile(f); err != nil {
		log.Fatal(err)
	}
	reindex(flag.Args())          // the work being investigated
	pprof.StopCPUProfile()        // flushes samples now; the deferred Close runs after return
}

StopCPUProfile must run before the file closes — deferring it after f.Close() produces an empty profile — and it is the only profile function safe to defer like that. Heap snapshots for a CLI use pprof.WriteHeapProfile after the work finishes.

Cadence for long-running services: some teams take a profile every few minutes into object storage and compare week over week; that is continuous profiling, usually done with a collector rather than home-grown cron, and it converts "performance regressed somewhere last Tuesday" into a diffable answer. Whether you schedule it or curl it during an incident, the discipline is the same: capture under load, save the file, and confirm the fix by capturing again — a profile is evidence, and evidence needs a before and an after. Benchmarks give you the repeatable local version of that loop (Coverage and Benchmarks); pprof gives you the production one.

Quiz

1. A request path shows handler time of 12 ms in code-level timings but users report 4 seconds. You take a CPU profile with `top` (no flags) and see almost nothing in your handler. What should you do first?

Question 1 options

2. A service has flat RSS but allocates 40 GB/hour. Which pprof view reveals the problem?

Question 2 options

3. After a deploy, the goroutine count climbs to 50,000 and never falls. What does the /debug/pprof/goroutine output tell you, and what are you looking for?

Question 3 options

4. A CLI runs `f, _ := os.Create("cpu.out"); defer f.Close()` before work, starts the profile with pprof.StartCPUProfile(f), runs the work, and returns without ever calling StopCPUProfile. The file is empty. Why?

Question 4 options

Flashcards

Question

What does a Go CPU profile actually measure, and what does it miss?

Answer

It samples the program counter 100 times per second while threads execute, attributing time to functions and their callers. It misses everything that waits — locks, channel receives, I/O — and may miss rare paths that fall between samples, which is why latency bugs with low CPU need block, mutex, or goroutine profiles instead.

Question

When do you use `top -cum` versus plain `top` in go tool pprof?

Answer

Plain top sorts by flat time — instructions executed by the function itself — which finds intrinsically expensive code. `-cum` sorts by cumulative time including callees, which is what you want when tracing where a slow request's time went: the hot chain shows up even when each frame is individually cheap.

Question

What is the difference between the default heap profile and one taken with -sample_index=alloc_space?

Answer

The default reports inuse_space: bytes of live objects right now, which identifies who is holding memory. alloc_space reports cumulative allocation volume, which identifies churn — code that allocates heavily and returns it, keeping the GC busy even though RSS stays flat.

Question

Why must /debug/pprof/ never be reachable from the public internet?

Answer

The endpoints expose runtime internals: cmdline prints the full command line (credentials and all), and profiles reveal code layout and internal state to anyone who can connect. Bind profiling to localhost or a management listener, gate it with auth, and treat the port as administrative surface.

Question

How do you profile a short-lived CLI that never opens a port?

Answer

runtime/pprof.StartCPUProfile writes to an io.Writer: create the file, start the profiler, run the work, then call StopCPUProfile explicitly before returning — samples only reach the writer inside Stop, so a program that exits without stopping it produces an empty file. Heap snapshots use pprof.WriteHeapProfile after the work completes.

Revision Notes

Key Takeaways

  • 1. CPU profiles show running time only; waiting shows up in block, mutex, or goroutine profiles
  • 2. flat (own cost) versus cumulative (chain cost) answer different questions — hunt slow paths with -cum
  • 3. heap has two questions: inuse_space asks who holds memory, alloc_space asks who churns it
  • 4. The goroutine profile is a stack dump; repeated identical stacks are the leak signature
  • 5. Profiling endpoints are administrative surface — keep them off the public net and capture during the symptom

Interview Tips

  • • "A Go service got slow after a deploy. Walk me through your diagnosis." — reproduce under load, curl a 30s CPU profile, go tool pprof, top -cum then list; fall back to heap/goroutine/block when CPU looks clean
  • • "How would you find a memory leak?" — heap inuse_space to see what's live, goroutine profile to see what's parked; mention the 512 KB sampling rate when numbers look coarse
  • • "What's the overhead of profiling in production?" — ~2% during an active CPU profile, endpoints cheap to leave registered but must be gated; frame it as on-demand capture
  • • "Explain flat versus cumulative time" — flat is the function's own instructions, cumulative includes callees; give the fmt.Sprintf-in-a-loop example

Cheat Sheet

CPU and Runtime Profiling with pprof

Symptom → profile:

Symptom Capture Read with
High CPU / slow request /debug/pprof/profile?seconds=30 top -cum, list <fn>, web
Memory live vs churn /debug/pprof/heap default = inuse; -sample_index=alloc_space
Goroutine leak /debug/pprof/goroutine?debug=2 repeated own-function stacks
Lock/channel waiting block or mutex profile enable via SetBlockProfileRate / SetMutexProfileFraction
Timeline (GC, syscalls) /debug/pprof/trace?seconds=5 go tool trace

Capture: curl -o cpu.out '.../profile?seconds=30' during the symptom, then go tool pprof cpu.out. Profiles are evidence: save before and after a fix.

CLI pattern:

f, _ := os.Create("cpu.out")
defer f.Close()
pprof.StartCPUProfile(f)
work()
pprof.StopCPUProfile() // explicit, before close

Production: expose on localhost or an authed management port only — /debug/pprof/cmdline leaks argv. CPU profiling ≈ 2% overhead while active.

Sampling facts: 100 Hz CPU samples; heap sampled per 512 KB allocated (MemProfileRate); rare paths may not appear — capture under load for 30s+.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading