Урок 23: Бенчмаркинг и профилирование (pprof)

Урок 23. Бенчмаркинг и профилирование (pprof)

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-bench && cd go-bench
go mod init go-bench

# Устанавливаем зависимости
go get github.com/google/uuid
go mod tidy

# Создаём файлы
mkdir -p cmd/server

💻 Файл: strings_bench_test.go — Бенчмарки

package main

import (
    "fmt"
    "strings"
    "testing"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  БЕНЧМАРКИ (имя начинается с Benchmark)                ║
// ╚══════════════════════════════════════════════════════════╝

// BenchmarkStringConcat — сравнение способов конкатенации строк
func BenchmarkStringConcat(b *testing.B) {
    b.Run("PlusOperator", func(b *testing.B) {
        b.ReportAllocs() // Отслеживаем аллокации
        for i := 0; i < b.N; i++ {
            s := ""
            for j := 0; j < 100; j++ {
                s += "x" // Много аллокаций!
            }
        }
    })

    b.Run("StringsBuilder", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            sb.Grow(100) // Предвыделяем память
            for j := 0; j < 100; j++ {
                sb.WriteString("x")
            }
            _ = sb.String()
        }
    })

    b.Run("StringsBuilderNoGrow", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            for j := 0; j < 100; j++ {
                sb.WriteString("x")
            }
            _ = sb.String()
        }
    })
}

// BenchmarkSprintf — сравнение форматирования
func BenchmarkSprintf(b *testing.B) {
    b.Run("Sprintf", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            _ = fmt.Sprintf("Hello, %s! You are %d years old.", "Gopher", 13)
        }
    })

    b.Run("Concat", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            _ = "Hello, " + "Gopher" + "! You are " + fmt.Sprint(13) + " years old."
        }
    })
}

// BenchmarkSliceAllocation — сравнение способов создания слайсов
func BenchmarkSliceAllocation(b *testing.B) {
    n := 10000

    b.Run("MakeWithCap", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            s := make([]int, 0, n) // Предвыделение
            for j := 0; j < n; j++ {
                s = append(s, j)
            }
        }
    })

    b.Run("MakeWithoutCap", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var s []int // Без capacity — реаллокации
            for j := 0; j < n; j++ {
                s = append(s, j)
            }
        }
    })

    b.Run("PreallocSlice", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            s := make([]int, n) // Сразу нужного размера
            for j := 0; j < n; j++ {
                s[j] = j
            }
        }
    })
}

// BenchmarkMapVsSlice — map vs slice для поиска
func BenchmarkMapVsSlice(b *testing.B) {
    // Подготавливаем данные
    keys := make([]string, 1000)
    m := make(map[string]int, 1000)
    for i := 0; i < 1000; i++ {
        key := fmt.Sprintf("key-%d", i)
        keys[i] = key
        m[key] = i
    }

    b.Run("MapLookup", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = m["key-500"]
        }
    })

    b.Run("SliceLinearSearch", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            for _, k := range keys {
                if k == "key-500" {
                    break
                }
            }
        }
    })
}

💻 Файл: json_bench_test.go — Бенчмарки JSON

package main

import (
    "encoding/json"
    "testing"
)

type LargeStruct struct {
    ID       int      `json:"id"`
    Name     string   `json:"name"`
    Email    string   `json:"email"`
    Tags     []string `json:"tags"`
    Metadata struct {
        Region string `json:"region"`
        Plan   string `json:"plan"`
    } `json:"metadata"`
}

var largeData = LargeStruct{
    ID:    42,
    Name:  "Gopher",
    Email: "gopher@golang.org",
    Tags:  []string{"go", "programming", "backend", "concurrency", "performance"},
    Metadata: struct {
        Region string `json:"region"`
        Plan   string `json:"plan"`
    }{Region: "us-east-1", Plan: "premium"},
}

// BenchmarkJSONEncode — сравнение способов сериализации
func BenchmarkJSONEncode(b *testing.B) {
    b.Run("Marshal", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            data, _ := json.Marshal(largeData)
            _ = data
        }
    })

    b.Run("Encoder", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var buf strings.Builder
            enc := json.NewEncoder(&buf)
            enc.Encode(largeData)
        }
    })
}

// BenchmarkJSONDecode — десериализация
func BenchmarkJSONDecode(b *testing.B) {
    data, _ := json.Marshal(largeData)

    b.Run("Unmarshal", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var v LargeStruct
            json.Unmarshal(data, &v)
        }
    })
}

import "strings"

💻 Файл: cmd/server/main.go — Сервер с pprof

package main

import (
    "fmt"
    "log"
    "net/http"
    // Импорт pprof добавляет обработчики:
    // /debug/pprof/
    // /debug/pprof/heap
    // /debug/pprof/goroutine
    // /debug/pprof/profile?seconds=30
    _ "net/http/pprof"
    "runtime"
    "sync"
    "time"
)

// Утечка горутин (для демонстрации)
var leakedGoroutines sync.WaitGroup

// Утечка памяти (для демонстрации)
var globalCache = make(map[string][]byte)

func main() {
    log.Println("🔍 Сервер с pprof на :8080")

    // Эндпоинты для демонстрации проблем
    http.HandleFunc("/api/leak-goroutines", leakGoroutinesHandler)
    http.HandleFunc("/api/leak-memory", leakMemoryHandler)
    http.HandleFunc("/api/cpu-load", cpuLoadHandler)
    http.HandleFunc("/api/allocations", allocationsHandler)
    http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
    })

    // Логируем статистику каждые 10 секунд
    go func() {
        ticker := time.NewTicker(10 * time.Second)
        for range ticker.C {
            var m runtime.MemStats
            runtime.ReadMemStats(&m)
            log.Printf("Горутин: %d | Heap: %.2f MB | Alloc: %.2f MB",
                runtime.NumGoroutine(),
                float64(m.HeapInuse)/1024/1024,
                float64(m.Alloc)/1024/1024,
            )
        }
    }()

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

// leakGoroutinesHandler — создаёт утекающие горутины
func leakGoroutinesHandler(w http.ResponseWriter, r *http.Request) {
    count := 10
    fmt.Sscanf(r.URL.Query().Get("count"), "%d", &count)

    for i := 0; i < count; i++ {
        leakedGoroutines.Add(1)
        go func(id int) {
            // Горутина никогда не завершится — утечка!
            <-make(chan struct{}) // Блокируется навсегда
            leakedGoroutines.Done()
        }(i)
    }

    fmt.Fprintf(w, "Создано %d утекающих горутин (всего: %d)\n",
        count, runtime.NumGoroutine())
}

// leakMemoryHandler — создаёт утечку памяти
func leakMemoryHandler(w http.ResponseWriter, r *http.Request) {
    // Выделяем 10 MB и сохраняем в глобальную переменную
    data := make([]byte, 10*1024*1024) // 10 MB
    key := fmt.Sprintf("leak-%d", time.Now().UnixNano())
    globalCache[key] = data

    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Fprintf(w, "Утечка 10 MB создана (ключ: %s)\n", key)
    fmt.Fprintf(w, "Всего в кэше: %d записей\n", len(globalCache))
    fmt.Fprintf(w, "Heap: %.2f MB\n", float64(m.HeapInuse)/1024/1024)
}

// cpuLoadHandler — создаёт нагрузку на CPU
func cpuLoadHandler(w http.ResponseWriter, r *http.Request) {
    duration := 5 * time.Second
    fmt.Sscanf(r.URL.Query().Get("duration"), "%v", &duration)

    fmt.Fprintf(w, "Нагружаем CPU на %v...\n", duration)

    // CPU-bound работа
    done := make(chan struct{})
    go func() {
        for {
            select {
            case <-done:
                return
            default:
                // Бесполезные вычисления
                _ = fibonacci(40)
            }
        }
    }()

    time.Sleep(duration)
    close(done)
    fmt.Fprintf(w, "Готово!\n")
}

// allocationsHandler — создаёт много аллокаций
func allocationsHandler(w http.ResponseWriter, r *http.Request) {
    count := 1_000_000
    fmt.Sscanf(r.URL.Query().Get("count"), "%d", &count)

    var data [][]byte
    for i := 0; i < count; i++ {
        // Каждый append создаёт новый слайс — много аллокаций
        data = append(data, []byte(fmt.Sprintf("data-%d", i)))
    }

    fmt.Fprintf(w, "Создано %d слайсов\n", count)
}

// fibonacci — рекурсивный расчёт (неэффективно намеренно)
func fibonacci(n int) int {
    if n <= 1 {
        return n
    }
    return fibonacci(n-1) + fibonacci(n-2)
}

🚀 Запуск и анализ

# ==========================================
# БЕНЧМАРКИ
# ==========================================

# Запуск всех бенчмарков
go test -bench=. -benchmem

# Запуск конкретного бенчмарка
go test -bench=BenchmarkStringConcat -benchmem

# Запуск на 5 секунд каждый
go test -bench=. -benchtime=5s -benchmem

# Сравнение (сохраняем результаты)
go test -bench=. -benchmem -count 5 > old.txt
# ... вносим изменения в код ...
go test -bench=. -benchmem -count 5 > new.txt
# Сравниваем
benchstat old.txt new.txt

# ==========================================
# ПРОФИЛИРОВАНИЕ (pprof)
# ==========================================

# Запускаем сервер
go run ./cmd/server/main.go

# В другом терминале:

# Смотрим доступные профили
curl http://localhost:8080/debug/pprof/

# CPU-профиль (30 секунд)
curl "http://localhost:8080/debug/pprof/profile?seconds=30" > cpu.prof

# Heap-профиль
curl http://localhost:8080/debug/pprof/heap > heap.prof

# Горутины
curl http://localhost:8080/debug/pprof/goroutine > goroutine.prof

# Создаём нагрузку перед профилированием
curl "http://localhost:8080/api/cpu-load?duration=30s" &
curl "http://localhost:8080/api/leak-memory"
curl "http://localhost:8080/api/leak-goroutines?count=100"

# Анализируем профили
go tool pprof cpu.prof
# В интерактивном режиме:
#   top         — топ функций по CPU
#   list Func   — код функции с профилем
#   web         — граф вызовов (нужен graphviz)
#   flame       — flame graph (нужен браузер)

# Веб-интерфейс для анализа
go tool pprof -http=:8081 cpu.prof

# Сравнение двух профилей
go tool pprof -http=:8081 -diff_base=old.prof new.prof

# Профилирование в продакшене (одноразово)
curl "http://production-server:8080/debug/pprof/profile?seconds=30" > prod-cpu.prof
go tool pprof -http=:8081 prod-cpu.prof

📊 Типы профилей pprof

ПрофильЭндпоинтЧто показываетКогда использовать
CPU/debug/pprof/profileГде тратится процессорное времяМедленные запросы
Heap/debug/pprof/heapРаспределение памятиУтечки памяти, большое потребление
Goroutine/debug/pprof/goroutineСтек всех горутинУтечки горутин, дедлоки
Allocs/debug/pprof/allocsВсе аллокации (с момента старта)Избыточные аллокации
Mutex/debug/pprof/mutexБлокировки мьютексовКонкуренция за блокировки
Block/debug/pprof/blockБлокировки на каналахПроблемы с каналами

📊 Флаги бенчмарков

ФлагПримерОписание
-bench-bench=.Запуск бенчмарков (регулярное выражение)
-benchmem-benchmemПоказывать аллокации и байты
-benchtime-benchtime=10sДлительность бенчмарка
-count-count=5Количество повторений
-cpuprofile-cpuprofile=cpu.profСохранить CPU-профиль
-memprofile-memprofile=mem.profСохранить heap-профиль
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →