Профилирование Go кода: находим узкие места на практике

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему профилирование Go — это маст-хев для разработчика

Go славится своей производительностью, но даже самый быстрый код может тормозить из-за неэффективных алгоритмов, утечек памяти или неправильной работы с горутинами. Профилирование — это единственный способ точно узнать, где ваше приложение тратит ресурсы. В этой статье мы разберем практические методы профилирования Go кода с помощью встроенных инструментов: pprof, trace и benchstat. Вы научитесь находить узкие места и устранять их.

1. CPU профилирование с pprof: ищем горячие точки

CPU профилирование показывает, какие функции потребляют больше всего процессорного времени. Встроенный пакет runtime/pprof позволяет легко собирать профили.

1.1. Добавляем профилирование в код

Рассмотрим пример простого HTTP-сервера, который выполняет тяжелые вычисления:

package main

import (
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"
    "os"
    "runtime/pprof"
)

func heavyTask() {
    sum := 0
    for i := 0; i < 1_000_000; i++ {
        sum += i * i
    }
    _ = sum
}

func handler(w http.ResponseWriter, r *http.Request) {
    heavyTask()
    fmt.Fprintf(w, "Done")
}

func main() {
    f, _ := os.Create("cpu.prof")
    pprof.StartCPUProfile(f)
    defer pprof.StopCPUProfile()

    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

После запуска сервера и нескольких запросов, у вас появится файл cpu.prof. Анализируем его командой:

go tool pprof -http=:8081 cpu.prof

В браузере откроется интерактивный граф. Обратите внимание на красные узлы — они указывают на самые "горячие" функции. В нашем случае это heavyTask.

1.2. Интерпретация результатов

В веб-интерфейсе pprof вы увидите:

  • Graph — граф вызовов с размерами узлов, пропорциональными времени выполнения.
  • Flame Graph — "огненный" график, где длинные полосы — самые медленные участки.
  • Top — таблица функций, отсортированных по времени CPU.

Если вы видите, что heavyTask занимает 90% времени, значит, нужно оптимизировать именно её (например, заменить цикл на формулу суммы квадратов).

2. Профилирование памяти: ищем утечки и аллокации

Утечки памяти в Go — редкость, но избыточные аллокации могут сильно замедлить приложение. Используем runtime/pprof для heap-профиля.

2.1. Сбор heap профиля

Добавим в код сборку профиля памяти по сигналу SIGUSR1:

import (
    "os"
    "os/signal"
    "runtime/pprof"
    "syscall"
)

func main() {
    // ... ваш код ...
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGUSR1)
    go func() {
        <-sigs
        f, _ := os.Create("mem.prof")
        pprof.WriteHeapProfile(f)
        f.Close()
        os.Exit(0)
    }()
    // ...
}

После отправки сигнала kill -SIGUSR1 <PID> получите файл mem.prof. Анализируем:

go tool pprof -http=:8082 mem.prof

2.2. Типичные проблемы с памятью

  • Большое количество мелких аллокаций — например, создание строк через + вместо strings.Builder.
  • Удержание ссылок на большие объекты — после использования нужно обнулять указатели.
  • Горутины, которые не завершаются — каждая горутина потребляет минимум 4 КБ стека.

Пример неоптимального кода:

func badConcat(n int) string {
    s := ""
    for i := 0; i < n; i++ {
        s += "x"  // каждая итерация создает новую строку
    }
    return s
}

Оптимизация:

func goodConcat(n int) string {
    var b strings.Builder
    b.Grow(n)
    for i := 0; i < n; i++ {
        b.WriteByte('x')
    }
    return b.String()
}

Профиль покажет, что badConcat создает тысячи аллокаций, а goodConcat — всего одну.

3. Трассировка горутин и блокировок с go tool trace

Если приложение "зависает" или работает медленно из-за блокировок, используйте трассировку. Она показывает, когда горутины спят, ожидают ввода-вывода или блокируются на мьютексах.

3.1. Включаем трассировку

import (
    "os"
    "runtime/trace"
)

func main() {
    f, _ := os.Create("trace.out")
    trace.Start(f)
    defer trace.Stop()

    // ваш код
}

После выполнения программы анализируем:

go tool trace trace.out

Откроется веб-интерфейс с временной шкалой.

3.2. Чтение трассировки

Основные элементы:

  • Goroutines — цветные полосы, показывающие состояние каждой горутины (зеленый — выполняется, желтый — ожидание, красный — блокировка).
  • Network/Syscall — когда горутина ждет ввода-вывода.
  • Mutex — если горутина ждет освобождения мьютекса.

Если вы видите длинные красные участки, значит, есть проблема с блокировками. Например, если 10 горутин ждут один мьютекс, стоит подумать о разделении данных или использовании sync.Map.

4. Бенчмарки и benchstat: сравниваем производительность

Бенчмарки — это не профилирование, но они помогают измерить влияние изменений. Используйте testing

Блоги

Рекомендаций по книгам