Профилирование Go кода: находим узкие места для начинающих
Когда ваш Go-проект начинает тормозить, потреблять слишком много памяти или работать нестабильно, возникает закономерный вопрос: "Где узкое место?" Интуитивные догадки редко помогают — нужны точные данные. Профилирование (profiling) — это процесс сбора метрик о работе программы: сколько времени занимает каждая функция, сколько памяти выделяется, как часто вызывается сборщик мусора. В этой статье мы разберем, как с помощью встроенных инструментов Go (pprof и trace) быстро находить и устранять проблемы производительности.
Что такое профилирование и зачем оно нужно?
Профилирование — это как рентген для вашего кода. Вы видите не просто "программа работает медленно", а конкретные цифры: функция parseData() занимает 40% времени, а loadConfig() выделяет 2 ГБ памяти. Для Go существует несколько типов профилей:
- CPU профиль — показывает, какие функции потребляют процессорное время.
- Memory (heap) профиль — показывает, какие функции выделяют память и где живут объекты.
- Goroutine профиль — показывает количество активных горутин и их состояние.
- Block профиль — показывает, где горутины блокируются на мьютексах или каналах.
- Mutex профиль — показывает конкуренцию за мьютексы.
Для начинающих самым полезным будет CPU и Memory профилирование. Они дают 80% ответов на вопрос "почему код тормозит".
Инструмент pprof: встроенный профилировщик Go
Go поставляется со встроенным пакетом net/http/pprof. Он добавляет эндпоинты к вашему HTTP-серверу, с которых можно снимать профили. Рассмотрим базовый пример.
Шаг 1: Подключаем pprof к приложению
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof" // подключаем профилировщик
"time"
)
func main() {
// Запускаем сервер на порту 8080
go func() {
log.Println(http.ListenAndServe("localhost:8080", nil))
}()
// Наша "тяжелая" бизнес-логика
for i := 0; i < 100; i++ {
go heavyTask(i)
}
// Ждем завершения
time.Sleep(10 * time.Second)
fmt.Println("Done")
}
func heavyTask(id int) {
// Симуляция тяжелой работы
for i := 0; i < 1000000; i++ {
_ = i * i
}
fmt.Printf("Task %d finished\n", id)
}
После запуска приложения откройте браузер по адресу http://localhost:8080/debug/pprof/. Вы увидите веб-интерфейс со ссылками на профили.
Шаг 2: Снимаем CPU профиль
Самый простой способ — использовать командную строку. Запустите:
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
Эта команда соберет CPU профиль в течение 30 секунд. После завершения откроется интерактивная консоль pprof. Введите top чтобы увидеть самые "горячие" функции:
(pprof) top
Showing nodes accounting for 1.50s, 100% of 1.50s total
flat flat% sum% cum cum%
1.50s 100% 100% 1.50s 100% main.heavyTask
0 0% 100% 1.50s 100% runtime.main
Здесь flat — время, потраченное непосредственно внутри функции, а cum — время с учетом вызовов дочерних функций. В нашем примере 100% времени уходит на heavyTask.
Шаг 3: Визуализация через веб-интерфейс
Для более наглядного анализа используйте графический режим:
go tool pprof -http=:8081 http://localhost:8080/debug/pprof/profile?seconds=30
Откроется браузер с интерактивным графом вызовов. Чем больше и краснее блок — тем больше ресурсов он потребляет. Это отличный способ быстро найти "жирные" функции.
Профилирование памяти: находим утечки
Утечки памяти в Go — это обычно не забытые освобождения (как в C), а удерживаемые ссылки на объекты, которые сборщик мусора не может собрать. pprof помогает найти такие места.
Снимаем heap профиль
go tool pprof http://localhost:8080/debug/pprof/heap
В консоли pprof введите top чтобы увидеть функции, выделяющие больше всего памяти:
(pprof) top
Showing nodes accounting for 512MB, 100% of 512MB total
flat flat% sum% cum cum%
512MB 100% 100% 512MB 100% main.createLargeSlice
Теперь давайте посмотрим на пример с потенциальной утечкой:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"time"
)
var cache = make(map[string][]byte)
func main() {
go func() {
http.ListenAndServe("localhost:8080", nil)
}()
// Симуляция утечки: добавляем данные в глобальный кэш без удаления
for i := 0; i < 1000; i++ {
go func(n int) {
key := fmt.Sprintf("key-%d", n)
data := make([]byte, 10*1024*1024) // 10 MB
cache[key] = data
time.Sleep(1 * time.Hour) // никогда не удаляем
}(i)
}
time.Sleep(10 * time.Minute)
}
Сняв heap профиль, вы увидите, что main функция удерживает 10 ГБ памяти. В реальном проекте вы бы увидели, что cache растет бесконтрольно. Решение — добавить TTL (time-to-live) или использовать sync.Map с очисткой.
Трассировка: видим горутины и события
Иногда профилирования CPU и памяти недостаточно — нужно понять, как горутины взаимодействуют друг с другом. Для этого используется трассировка (trace). Она показывает временную шкалу: когда стартовали и завершились горутины, как работали каналы и мьютексы, когда