Перейти к содержимому

pprof: профилирование в проде

Зачем знать на Middle 1. pprof — это «рентген» Go-программы. На джуне вы могли сказать «у нас сервис тормозит». На мидле от вас ждут: подключение net/http/pprof за 1 минуту, снятие CPU/heap/goroutine/block/mutex профилей, чтение flame graph, поиск hot path, сравнение двух heap snapshots для leak hunting, диагностика goroutine leak, понимание trade-off оverhead профилирования. Без pprof вы не сможете осмысленно оптимизировать код — вы будете гадать. С pprof — точно увидите, где сжигается CPU и куда уходит память.


  1. Базовая концепция: что такое pprof и зачем
  2. Под капотом: типы профилей, sampling, форматы
  3. Gotchas: overhead, корректность данных, артефакты
  4. Production-практики: безопасный pprof, continuous profiling, кейсы
  5. Вопросы на собесе (25–30)
  6. Practice
  7. Источники

pprof — это инструмент для сбора и визуализации профилей производительности (CPU, memory, blocking, mutex, goroutine state). Состоит из двух частей:

  • runtime/pprof и net/http/pprof — сбор данных в Go-программе.
  • go tool pprof — анализ собранных профилей.

Зачем нужен:

  • Найти, где сжигается CPU (hot functions).
  • Найти, куда уходит память (allocation sites + live objects).
  • Найти leak горутин (что зависло в _Gwaiting).
  • Найти contention на mutex’ах и channels.
СимптомПрофиль
CPU 100%, не понимаю почемуCPU profile (/debug/pprof/profile)
Память растёт, OOMHeap profile (/debug/pprof/heap)
NumGoroutine тысячи, растётGoroutine profile (/debug/pprof/goroutine)
Латенс высокий, CPU простаиваетBlock profile (/debug/pprof/block)
Lock contention подозреваетсяMutex profile (/debug/pprof/mutex)
Хочу видеть scheduler и all eventsTrace (/debug/pprof/trace) — но это уже не pprof
package main
import (
"log"
"net/http"
_ "net/http/pprof" // !!! важно: blank import регистрирует handlers !!!
)
func main() {
// Отдельный port для админ-handlers (не на main API):
go func() {
log.Println(http.ListenAndServe(":6060", nil))
}()
// ... ваше приложение ...
runApp()
}

_ "net/http/pprof" регистрирует endpoints на http.DefaultServeMux:

  • /debug/pprof/ — index.
  • /debug/pprof/cmdline — command line.
  • /debug/pprof/profile?seconds=30 — CPU.
  • /debug/pprof/heap — heap.
  • /debug/pprof/goroutine — goroutines.
  • /debug/pprof/block — block.
  • /debug/pprof/mutex — mutex.
  • /debug/pprof/threadcreate — thread creation.
  • /debug/pprof/trace?seconds=5 — execution trace.
  • /debug/pprof/symbol — symbol resolution.
┌──────────────────┐ HTTP ┌──────────────┐ file ┌─────────────┐
│ Go application │ ─────────► │ collected │ ─────────► │ go tool │
│ (net/http/pprof)│ │ profile.pb │ │ pprof │
└──────────────────┘ └──────────────┘ └─────────────┘
│ │
│ sampling каждые ~10 ms │ open in
│ (для CPU) │ browser /
│ │ interactive
▼ ▼
┌──────────────────┐ ┌────────────────┐
│ stack traces + │ │ flame graph │
│ счётчики событий │ │ top N │
└──────────────────┘ │ source list │
│ peek callees │
└────────────────┘

2. Под капотом: типы профилей, sampling, форматы

Заголовок раздела «2. Под капотом: типы профилей, sampling, форматы»

runtime.SetCPUProfileRate(100) (или по умолчанию 100 Hz) запускает таймер. Каждые ~10 мс через SIGPROF останавливается текущий поток, runtime снимает stack trace текущей горутины, агрегирует:

Time
──────┬───────┬───────┬───────┬───────┬───────┬───────┬─────►
│ │ │ │ │ │ │
SIGPROF SIGPROF SIGPROF SIGPROF SIGPROF SIGPROF SIGPROF
capture capture capture capture capture capture capture
stack stack stack stack stack stack stack

Каждый sample = stack trace + 1 «попадание». В конце суммарные попадания на каждой функции = доля CPU времени.

Это statistical sampling. Точность ~1% при достаточном количестве samples (1000+).

Окно терминала
# Снять 30-секундный профиль:
curl -o cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Интерактивно:
go tool pprof cpu.pprof
(pprof) top
(pprof) top -cum # сортировка по cumulative
(pprof) list myFunc # построчно
(pprof) web # SVG в браузер
(pprof) peek myFunc # кто вызывает / кого вызывает
(pprof) traces # все samples
# Веб-интерфейс (рекомендуется):
go tool pprof -http=:8080 cpu.pprof
# Откроется http://localhost:8080/ui/
# Меню: top, graph, flame graph, peek, source, disassembly
(pprof) top
Showing nodes accounting for 4.2s, 99.5% of 4.22s total
flat flat% sum% cum cum%
2.5s 59.24% 59.24% 2.5s 59.24% myapp.Hash
1.0s 23.70% 82.94% 3.5s 82.94% myapp.processBatch
0.7s 16.59% 99.53% 0.7s 16.59% runtime.mallocgc
  • flat — время непосредственно в этой функции (без вызываемых).
  • cum — время в этой функции плюс во всём, что она вызывает.

Пример:

  • processBatch flat 1.0s, cum 3.5s → 1.0s «свой» код + 2.5s в вызываемом Hash.

В flame graph горизонтальная ширина = cum time. Самые широкие функции вверху ≈ корни проблемы.

  • Функции с большим flat — сам код функции жжёт CPU.
  • Функции с большим cum, маленьким flat — оркестратор, проверьте вызываемое.
  • runtime.mallocgc много → много аллокаций (см. heap profile).
  • runtime.gcAssistAlloc много → mark assist (см. GC tuning).
  • syscall.Syscall много → возможно, file/network bound.

В отличие от CPU, heap profile НЕ периодический. Runtime включает sampling на каждой N-ой аллокации (default — каждые 512 KB).

runtime.MemProfileRate = 512 * 1024 (можно изменить).

Каждый sample = stack trace + размер allocation. В отличие от CPU, есть 4 разных метрики:

МетрикаЧто показывает
inuse_space (default)Память, которую сейчас держат живые объекты.
inuse_objectsКоличество живых объектов.
alloc_spaceПамять, аллоцированная за всё время (даже если уже освобождена).
alloc_objectsКоличество аллокаций (исторически).
  • Memory leak?inuse_space или inuse_objects (что сейчас живо).
  • Куда GC время уходит?alloc_space или alloc_objects (что аллоцируется в hot path).
  • OOM?inuse_space (что сейчас держит память).
  • Слишком много GC?alloc_objects (частота аллокаций).
Окно терминала
# Просто heap:
curl http://localhost:6060/debug/pprof/heap > heap.pprof
go tool pprof heap.pprof
# По alloc:
go tool pprof -alloc_space heap.pprof
# или внутри:
(pprof) alloc_space
(pprof) top

Это важно. Если bufferpool.Get() создаёт буфер, потом он попадает в connection.buf, который держится 10 минут — heap profile покажет, что аллокация была в bufferpool.Get(). Но «утекает» он через connection.

Поэтому для leak hunting иногда нужен diff snapshots (см. далее).

Окно терминала
# Снять snapshots:
curl http://localhost:6060/debug/pprof/heap > t1.pprof
sleep 600
curl http://localhost:6060/debug/pprof/heap > t2.pprof
# Сравнить (t2 - t1):
go tool pprof -base t1.pprof -http=:8080 t2.pprof

В UI top N покажет, что появилось между snapshots. Это лучший способ найти leak.

/debug/pprof/goroutine — список всех живых горутин с их stack traces. Каждая горутина = 1 sample (не sampling, а полный список).

Окно терминала
curl 'http://localhost:6060/debug/pprof/goroutine?debug=1'
# или debug=2 — ещё подробнее

Пример вывода:

goroutine profile: total 1842
1500 @ 0x103b2c4 0x10453e4 0x140abc8
# 0x140abc7 net/http.(*conn).serve+0x4e7
200 @ 0x103b2c4 0x10453e4 0x130bcd9
# 0x130bcd8 main.worker+0x118
142 @ 0x103b2c4 0x10453e4 0x140abc8
# 0x140abc7 crypto/tls.(*Conn).Read+0x4e7

Если 1500 горутин в одной точке — это либо нормальная нагрузка (web server), либо leak.

goroutine 1 [running]:
goroutine 2 [chan receive]:
goroutine 3 [select]:
goroutine 4 [IO wait]:
goroutine 5 [semacquire]:
goroutine 6 [sync.Cond.Wait]:
goroutine 7 [sleep]:

Чаще всего leak видно в:

  • chan receive без отправителя.
  • chan send без получателя.
  • select (no cases) (бесконечный select?).
  • semacquire (sync.WaitGroup или mutex).

Сколько времени горутины проводят в блокировке на синхронизационных примитивах:

  • chan send / chan receive.
  • sync.Mutex.Lock (но точнее ловит mutex profile).
  • sync.WaitGroup.Wait.
  • sync.Cond.Wait.
  • time.Sleep (да, тоже учитывается).
  • select.

По умолчанию выключен (overhead). Включаем:

runtime.SetBlockProfileRate(rate)
// rate в наносекундах. 1 = каждая блокировка регистрируется.
// 0 = выключено.
// > 0 = sampling: только блокировки длиной > rate ns
// плюс длинные с вероятностью пропорциональной rate.

Типичная prod-настройка:

runtime.SetBlockProfileRate(int(1 * time.Millisecond))
// Игнорируем блокировки < 1 ms.
Окно терминала
curl http://localhost:6060/debug/pprof/block > block.pprof
go tool pprof -http=:8080 block.pprof

Метрика: delay. Сколько времени горутины ждали.

  • API сервис: cpu простаивает, latency высокий → проверьте block profile.
  • «Зависает» при большой нагрузке → contention на channel/mutex.

Узкоспециализированный block profile только для sync.Mutex и sync.RWMutex. Показывает:

  • На каких mutex горутины ждали unlock.
  • В каких функциях держался lock (от unlock-site).
runtime.SetMutexProfileFraction(rate)
// rate=0 — выключено
// rate=1 — каждое contention регистрируется
// rate=N>1 — каждое N-ое (sampling)

Prod: runtime.SetMutexProfileFraction(100) (1% sampling).

Окно терминала
curl http://localhost:6060/debug/pprof/mutex > mutex.pprof
go tool pprof -http=:8080 mutex.pprof

Trace — это не sampling, а запись всех scheduler/GC/syscall событий за период. Открывается отдельным инструментом.

Окно терминала
curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
go tool trace trace.out

Подробно в файле 12-benchmarks-trace.md.

Показывает, где создавались OS-потоки. Используется редко. Полезно, если runtime.NumThread() растёт (CGO-вызовы, LockOSThread без Unlock).

pprof файлы — это protobuf, схема profile.proto (gperftools heritage). Можно открыть на сервере, на лэптопе, в Cloud Profiler — все понимают.

Содержимое:

  • Samples — массив (stack_trace, value).
  • Locations — функция + line.
  • Functions — имя.
  • Mappings — segment info.

В Go 1.21+ профайлы содержат больше debug info, поэтому даже без бинарника можно прочитать функции.


3.1. Heap profile показывает точку аллокации, не «текущего держателя»

Заголовок раздела «3.1. Heap profile показывает точку аллокации, не «текущего держателя»»
func newBuffer() []byte {
return make([]byte, 1<<20) // heap profile увидит ЗДЕСЬ
}
func handle(req *Request) {
req.Buf = newBuffer() // утечка реально здесь, если req не освобождается
}

В heap profile вы увидите newBufferruntime.makeslice, но не handle. Чтобы понять «где это держится» — нужен pprof -base сравнение или ручной анализ кода.

Sampling 100 Hz = 100 samples в секунду. На 5-секундный профиль = 500 samples. Если ваша функция занимает 1% времени → ожидается 5 hits. Это статистически почти ничто.

Хорошая практика:

  • Профилировать ≥ 30 секунд под нагрузкой.
  • Лучше 60 секунд.
  • Если функция важна, проверьте на нескольких профилях.

go tool pprof показывает CPU time (время, когда поток реально работал). Если ваша функция спала в I/O — её не будет в CPU profile. Используйте block profile или trace.

Окно терминала
curl http://prod:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
# 200 MB файла, если у вас миллион горутин.

Используйте debug=1 для краткого вывода, или анализируйте через pprof без debug.

Go inlines маленькие функции (gcflags -l отключает). В pprof они могут показаться как часть caller’а. С -gcflags="-l" можно отключить inlining, но это меняет производительность.

В Go 1.20+ pprof умеет «разворачивать» inlined frames в UI.

import _ "net/http/pprof"
http.ListenAndServe(":8080", nil) // !!! pprof доступен по :8080

Если ваш main API на этом же порту — pprof открыт миру. Через /debug/pprof/profile?seconds=300 атакующий может завесить CPU.

Правильно: отдельный admin port, не доступный из интернета.

go http.ListenAndServe("127.0.0.1:6060", nil) // только localhost

Или через middleware с auth.

Если у вас один P, любая блокировка автоматически отдаёт ему work. Block profile может не показать contention, который в multi-core окружении был бы виден.

Окно терминала
curl http://localhost:6060/debug/pprof/heap?seconds=30
# Что это?

С seconds=30 для heap pprof возвращает diff между snapshot в t=0 и t=30. Это удобно для leak hunting.

Для CPU seconds — обязательно (длительность профилирования).

Для goroutine и block — игнорируется (моментальный снимок).

3.9. -alloc_space включает уже освобождённую память

Заголовок раздела «3.9. -alloc_space включает уже освобождённую память»
Окно терминала
go tool pprof -alloc_space heap.pprof

Вы увидите аллокации, которые произошли за всё время с прошлого reset. Это не «живая» память. Используйте, если вас интересует частота аллокаций (для GC тюнинга), а не утечки.

3.10. Профили с разных бинарников несовместимы

Заголовок раздела «3.10. Профили с разных бинарников несовместимы»

go tool pprof требует, чтобы профиль соответствовал бинарнику (для resolution символов). Если вы сменили версию, символы могут сдвинуться.

В Go 1.20+ профили содержат debug info прямо в файле — резолвить можно без бинарника. Но для disassembly и source listing всё равно нужен бинарник + исходники.


package admin
import (
"context"
"log"
"net/http"
"net/http/pprof"
"time"
)
func StartPProfServer(addr string, authToken string) {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", auth(authToken, pprof.Index))
mux.HandleFunc("/debug/pprof/cmdline", auth(authToken, pprof.Cmdline))
mux.HandleFunc("/debug/pprof/profile", auth(authToken, pprof.Profile))
mux.HandleFunc("/debug/pprof/symbol", auth(authToken, pprof.Symbol))
mux.HandleFunc("/debug/pprof/trace", auth(authToken, pprof.Trace))
server := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 320 * time.Second, // > max trace duration
}
log.Println(server.ListenAndServe())
}
func auth(token string, h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Pprof-Token") != token {
http.Error(w, "unauthorized", 401)
return
}
h(w, r)
}
}

Или просто привязать к localhost и подключаться через kubectl port-forward:

Окно терминала
kubectl port-forward pod/myapp-xxx 6060:6060
curl http://localhost:6060/debug/pprof/heap > heap.pprof

Для долгоживущих сервисов лучше постоянно собирать профили (по 30 сек каждые 5 минут) и хранить, чтобы видеть тренды.

Инструменты:

  • Grafana Pyroscope (open-source, eBPF + pprof, SDK для Go).
  • Datadog Continuous Profiler (managed).
  • Google Cloud Profiler (managed, GCP).
  • AWS CodeGuru Profiler.

Минимальная самописная версия:

package profiler
import (
"bytes"
"context"
"fmt"
"runtime/pprof"
"time"
)
func StartContinuousCPU(ctx context.Context, every time.Duration, dur time.Duration, sink func(name string, data []byte)) {
go func() {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
var buf bytes.Buffer
pprof.StartCPUProfile(&buf)
time.Sleep(dur)
pprof.StopCPUProfile()
name := fmt.Sprintf("cpu-%d.pprof", time.Now().Unix())
sink(name, buf.Bytes())
}
}
}()
}

Куда сохранять:

  • S3/GCS bucket с TTL.
  • Loki/Pyroscope.
  • Локальный диск + cron rotate.
ПрофильOverheadКогда включать
CPU profile1–5% (sampling 100 Hz)По требованию (3-х минутный snapshot)
Heap profile< 1% (sampling per 512 KB alloc)Всегда, дешёво
Goroutine< 1% (только при сборе)По требованию
Block profile0% (rate=0), может расти до 5–10% с rate=1Осторожно, по требованию
Mutex profile< 1% с fraction=100 (1%)Можно постоянно
Trace10–20%!Только на коротких интервалах

Trace дорогой потому, что записывает все scheduler/GC события (миллионы в секунду).

┌──────────────────────────────────────────────────┐
│ main │
├──────────────────────────────────────────────────┤
│ handler │ gc │
├────────────────────────────────┼──────────────────┤
│ parse │ marshal │ gcMark │
├─────────────┼──────────────────┼──────────────────┤
│ Atoi │ encoding/json │ ... │
└─────────────┴──────────────────┴──────────────────┘
width = % of CPU time

Правила чтения:

  1. Ширина = время. Узкая функция = редкая. Широкая = много CPU.
  2. Высота = глубина стека. Низкая = root. Высокая = глубоко вложенная.
  3. Цвет — обычно random, для различения. Не несёт смысла.
  4. Плато — ровная top-функция, занимающая много ширины — это листовой hot path, оптимизируйте её.
  5. Пирамида — высокая башня — это глубокая call chain. Если в основании много времени — оркестратор, ищите выше.

В UI:

  • Клик на функцию = zoom in.
  • Search bar = найти все вхождения функции.

Симптом: один HTTP endpoint жгёт CPU.

Окно терминала
curl http://localhost:6060/debug/pprof/profile?seconds=30 > cpu.pprof
go tool pprof -http=:8080 cpu.pprof

Flame graph показывает: 70% времени в regexp.MatchString. Смотрим source:

func handle(w http.ResponseWriter, r *http.Request) {
if regexp.MustCompile(`^[a-z0-9]+$`).MatchString(r.URL.Path) {
// ...
}
}

Bug: MustCompile в каждом запросе компилирует регулярку!

Fix:

var pathRe = regexp.MustCompile(`^[a-z0-9]+$`) // once
func handle(w http.ResponseWriter, r *http.Request) {
if pathRe.MatchString(r.URL.Path) {
// ...
}
}

После фикса: CPU 100% → 8%. Это самый частый pprof-инсайт.

Сервис ест память, не отдаёт.

Окно терминала
curl http://localhost:6060/debug/pprof/heap > t0.pprof
# подождать 30 минут
curl http://localhost:6060/debug/pprof/heap > t1.pprof
go tool pprof -base t0.pprof -http=:8080 t1.pprof

Top diff:

flat flat% sum% cum cum%
500MB 89.7% 89.7% 500MB 89.7% main.(*Cache).Set

Смотрим код:

func (c *Cache) Set(key string, value []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value // !!! без eviction !!!
}

Решение: LRU или TTL eviction.

Окно терминала
curl 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -20
goroutine profile: total 50321
50000 @ 0x103b2c4 0x10453e4 0x140abc8
# 0x140abc7 main.processRequest+0x4e7 /app/main.go:42

50k горутин в main.processRequest+0x4e7. Смотрим:

func processRequest(req Request) {
ch := make(chan Result)
go fetch(ch, req)
// ! нет select с context.Done() !
result := <-ch // line 42, висим вечно если fetch не вернул
process(result)
}

Если fetch иногда не отвечает и не закрывает chan — leak.

Fix:

func processRequest(ctx context.Context, req Request) error {
ch := make(chan Result, 1) // буфер, чтобы fetch не висел
go fetch(ch, req)
select {
case result := <-ch:
process(result)
return nil
case <-ctx.Done():
return ctx.Err()
}
}

CPU простаивает, p99 latency растёт.

// Включить mutex profile:
runtime.SetMutexProfileFraction(100)
Окно терминала
curl http://localhost:6060/debug/pprof/mutex > mutex.pprof
go tool pprof -http=:8080 mutex.pprof
flat flat% cum cum%
200ms 50.0% 200ms 50.0% main.(*Counter).Add

Counter под global mutex:

type Counter struct {
mu sync.Mutex
n int64
}
func (c *Counter) Add(delta int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.n += delta
}

При 1000 RPS это узкое место.

Fix:

import "sync/atomic"
type Counter struct {
n atomic.Int64
}
func (c *Counter) Add(delta int64) {
c.n.Add(delta)
}

Latency упала с 50 ms до 1 ms.

Полезно для отчётов / postmortem:

package profiler
import (
"fmt"
"os"
"runtime/pprof"
"time"
)
func DumpAll(dir string) error {
ts := time.Now().Format("20060102-150405")
// Heap
f, err := os.Create(fmt.Sprintf("%s/heap-%s.pprof", dir, ts))
if err != nil { return err }
pprof.Lookup("heap").WriteTo(f, 0)
f.Close()
// Goroutine
f, err = os.Create(fmt.Sprintf("%s/goroutine-%s.pprof", dir, ts))
if err != nil { return err }
pprof.Lookup("goroutine").WriteTo(f, 0)
f.Close()
// CPU 30 sec
f, err = os.Create(fmt.Sprintf("%s/cpu-%s.pprof", dir, ts))
if err != nil { return err }
pprof.StartCPUProfile(f)
time.Sleep(30 * time.Second)
pprof.StopCPUProfile()
f.Close()
return nil
}

При SIGTERM это можно автоматически перед выходом.

Деплоите новую версию, хотите убедиться, что она быстрее старой.

Окно терминала
# На v1.0:
curl http://v1:6060/debug/pprof/profile?seconds=60 > v1.pprof
# На v2.0:
curl http://v2:6060/debug/pprof/profile?seconds=60 > v2.pprof
# Diff:
go tool pprof -base v1.pprof -http=:8080 v2.pprof

Покажет, что прибавилось в v2 (положительные diff’ы) и исчезло (отрицательные).


  1. Что такое pprof? Какие виды профилей бывают?
  2. Как подключить pprof к HTTP-серверу?
  3. В чём опасность подключения pprof на public port?
  4. Что такое sampling в CPU profile? Какая частота по умолчанию? (100 Hz.)
  5. Чем отличается inuse_space от alloc_space в heap profile?
  6. Как сравнить два heap profile? (pprof -base.)
  7. Что показывает goroutine profile? Как его получить?
  8. Когда нужен block profile, и как его включить? (runtime.SetBlockProfileRate.)
  9. Когда нужен mutex profile, и как его включить? (runtime.SetMutexProfileFraction.)
  10. Чем отличаются flat и cum в pprof top?
  11. Что такое flame graph и как его читать?
  12. Какой overhead у CPU профилирования? (~1–5%.)
  13. Какой overhead у trace? (10–20%.)
  14. Что показывает heap profile — точку аллокации или текущего держателя?
  15. Почему может расти memory, но heap profile показывает мало? (RSS != HeapAlloc, см. файл по GC.)
  16. Как diagnosticнуть memory leak через pprof?
  17. Как diagnosticнуть goroutine leak?
  18. Какие endpoints у net/http/pprof?
  19. Чем -seconds=30 в URL отличается для CPU vs heap? (CPU duration vs heap diff.)
  20. Какие функции в pprof обычно сигнализируют о много allocations? (runtime.mallocgc.)
  21. Какая функция показывает GC mark assist? (runtime.gcAssistAlloc.)
  22. Что делать, если в pprof много времени в runtime.findrunnable? (Idle workers, normal.)
  23. Как уменьшить overhead в pprof? (Sampling rate, off block profile.)
  24. Как профилировать short-lived процесс? (pprof.StartCPUProfile в main + defer Stop.)
  25. Что такое continuous profiling? Какие инструменты?
  26. Как pprof определяет «hot» функцию — по CPU или wall time? (CPU.)
  27. Inlined function в pprof — что с ней?
  28. Можно ли смотреть pprof локально на лэптопе, если бинарник от другой архитектуры? (Сложно, нужен matching binary.)
  29. Какой output format у pprof? (Protobuf, .pb.gz.)
  30. Чем pprof отличается от trace?

package main
import (
"fmt"
"math/rand"
"net/http"
_ "net/http/pprof"
"time"
)
func compute() int {
s := 0
for i := 0; i < 1e7; i++ {
s += rand.Int() % 100
}
return s
}
func main() {
go http.ListenAndServe(":6060", nil)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
result := compute()
time.Sleep(10 * time.Millisecond)
fmt.Fprintf(w, "%d", result)
})
http.ListenAndServe(":8080", nil)
}
Окно терминала
# Сгенерировать нагрузку:
hey -z 30s -c 100 http://localhost:8080/
# Параллельно профилировать:
curl http://localhost:6060/debug/pprof/profile?seconds=30 > cpu.pprof
go tool pprof -http=:8081 cpu.pprof
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"sync"
"time"
)
type Cache struct {
mu sync.Mutex
data map[string][]byte
}
func (c *Cache) Set(k string, v []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[k] = v
}
var cache = &Cache{data: make(map[string][]byte)}
func main() {
go http.ListenAndServe(":6060", nil)
go func() {
i := 0
for {
cache.Set(fmt.Sprint(i), make([]byte, 1<<10))
i++
time.Sleep(time.Millisecond)
}
}()
select {}
}
Окно терминала
# Через 30 сек:
curl http://localhost:6060/debug/pprof/heap > t1.pprof
# Через 3 минуты:
curl http://localhost:6060/debug/pprof/heap > t2.pprof
go tool pprof -base t1.pprof -http=:8080 t2.pprof
# Увидите, что main.(*Cache).Set вырос.
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"time"
)
func leak() {
ch := make(chan struct{})
<-ch // навсегда
}
func main() {
go http.ListenAndServe(":6060", nil)
for i := 0; i < 10; i++ {
go leak()
}
for {
time.Sleep(time.Second)
fmt.Println("leaks made")
}
}
Окно терминала
curl 'http://localhost:6060/debug/pprof/goroutine?debug=1'
# Увидите N горутин в main.leak.func1.
package main
import (
"net/http"
_ "net/http/pprof"
"runtime"
"sync"
)
var (
mu sync.Mutex
counter int
)
func work() {
for i := 0; i < 1e6; i++ {
mu.Lock()
counter++
mu.Unlock()
}
}
func main() {
runtime.SetMutexProfileFraction(1) // включить mutex profile
go http.ListenAndServe(":6060", nil)
var wg sync.WaitGroup
for i := 0; i < runtime.GOMAXPROCS(0); i++ {
wg.Add(1)
go func() { defer wg.Done(); work() }()
}
wg.Wait()
}
Окно терминала
curl http://localhost:6060/debug/pprof/mutex > mutex.pprof
go tool pprof -http=:8080 mutex.pprof
# Увидите main.work держит main.mu.
package main
import (
"log"
"os"
"runtime/pprof"
)
func cpuWork() {
s := 0
for i := 0; i < 1e9; i++ {
s += i
}
_ = s
}
func main() {
f, err := os.Create("cpu.pprof")
if err != nil { log.Fatal(err) }
defer f.Close()
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal(err)
}
defer pprof.StopCPUProfile()
cpuWork()
}
Окно терминала
go run main.go
go tool pprof -http=:8080 cpu.pprof
package main
import (
"bytes"
"fmt"
"io"
"net/http"
_ "net/http/pprof"
"os"
"runtime/pprof"
"time"
)
func uploadProfile(name string, r io.Reader) {
// sink: S3, GCS, Pyroscope...
// для примера — в файл:
f, _ := os.Create(name)
defer f.Close()
io.Copy(f, r)
}
func continuousCPU(period time.Duration, dur time.Duration) {
for {
var buf bytes.Buffer
pprof.StartCPUProfile(&buf)
time.Sleep(dur)
pprof.StopCPUProfile()
name := fmt.Sprintf("cpu-%d.pprof", time.Now().Unix())
uploadProfile(name, &buf)
time.Sleep(period - dur)
}
}
func main() {
go http.ListenAndServe(":6060", nil)
go continuousCPU(5*time.Minute, 30*time.Second)
select {}
}

  1. net/http/pprof package: https://pkg.go.dev/net/http/pprof
  2. runtime/pprof package: https://pkg.go.dev/runtime/pprof
  3. Profiling Go Programs (official blog): https://go.dev/blog/pprof
  4. pprof tool docs: https://github.com/google/pprof/blob/main/doc/README.md
  5. JBD — Profiling Go applications: https://rakyll.org/profiler-labels/
  6. Brendan Gregg — Flame graphs: https://www.brendangregg.com/flamegraphs.html
  7. Damian Gryski — High Performance Go Workshop: https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html
  8. Grafana Pyroscope (continuous profiling): https://grafana.com/oss/pyroscope/
  9. Datadog Continuous Profiler for Go: https://docs.datadoghq.com/profiler/enabling/go/
  10. GopherCon 2019: 7 common mistakes in Go and when to avoid them (Steve Francia)
  11. Diagnostics guide: https://go.dev/doc/diagnostics
  12. runtime package — SetBlockProfileRate / SetMutexProfileFraction: https://pkg.go.dev/runtime