Олег Марков
Оптимизация производительности в Go - практический разбор
Введение
Оптимизация производительности — это не набор хаотичных трюков, а системный процесс. Вы не просто «ускоряете код», вы измеряете, находите узкие места, проверяете гипотезы и только потом меняете реализацию.
В Go это особенно актуально: язык уже достаточно быстрый, но невнимательное обращение с памятью, горутинами и каналами легко съедает все преимущества. Здесь я покажу вам не только приемы оптимизации, но и сам подход: как искать проблемы, чем измерять, что именно в Go чаще всего тормозит и как это исправлять.
Мы будем опираться на стандартные инструменты Go, без тяжелых внешних систем, чтобы вы могли повторить все на обычном проекте.
Как правильно подходить к оптимизации
Пошаговый процесс
Давайте начнем с базового алгоритма работы с производительностью:
- Сформулировать цель.
- Измерить текущие показатели.
- Найти узкие места.
- Внести минимальные изменения.
- Перепроверить метрики.
- Повторить при необходимости.
Смотрите, я покажу вам, как это выглядит на практике.
1. Формулируем цель
Примеры конкретных целей:
- Время ответа HTTP ручки не более 50 мс при 1000 rps.
- Уменьшить пиковое потребление памяти в два раза.
- Ускорить выполнение вычислительной функции в 3 раза.
Важно: цель должна быть измеримой. Фраза «сделать быстрее» не подходит.
2. Измеряем базовый уровень
В Go у вас есть несколько инструментов:
- бенчмарки в testing
- профилировщик pprof
- трассировка (go test -trace, runtime/trace)
- метрики (Prometheus, OpenTelemetry и др.)
Хорошая практика — добавлять бенчмарки к вычислительно тяжелым функциям. Это поможет вам отслеживать эффект оптимизаций.
Вот простой пример бенчмарка:
package mypkg
import "testing"
// Функция, которую мы хотим оптимизировать
func SumSlice(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func BenchmarkSumSlice(b *testing.B) {
// Здесь мы подготавливаем данные один раз
nums := make([]int, 1000)
for i := range nums {
nums[i] = i
}
// Сбрасываем таймер, чтобы не учитывать подготовку данных
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = SumSlice(nums) // Результат нам не важен
}
}
Теперь вы можете запустить:
go test -bench=. -benchmem ./...
Флаг -benchmem покажет вам не только время, но и количество аллокаций и объем выделенной памяти на одну операцию. По этим значениям удобно отслеживать прогресс.
Профилирование: находим, что именно тормозит
CPU профилирование
CPU профилирование показывает, где процессор проводит больше всего времени. Это основной инструмент поиска узких мест по времени выполнения.
Давайте разберемся на простом сервисе HTTP.
package main
import (
"log"
"net/http"
"os"
"runtime/pprof"
)
// simulateWork имитирует тяжелую работу
func simulateWork() {
sum := 0
for i := 0; i < 1_000_000; i++ {
sum += i
}
_ = sum
}
func handler(w http.ResponseWriter, r *http.Request) {
simulateWork()
_, _ = w.Write([]byte("ok"))
}
func main() {
// Открываем файл для записи CPU профиля
f, err := os.Create("cpu.prof")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Запускаем профилирование
// Смотрите, здесь мы включаем сбор данных о CPU
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal(err)
}
defer pprof.StopCPUProfile()
http.HandleFunc("/", handler)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Запускаете сервер, создаете нагрузку (через curl, ab, wrk, vegeta — как вам удобнее), затем останавливаете сервер. После этого анализируете профиль:
go tool pprof cpu.prof
Внутри pprof вы можете ввести команды:
- top — показать самые «тяжелые» функции
- list simulateWork — показать вклад конкретной функции построчно
- web — сгенерировать граф и открыть его в браузере
Обратите внимание, как pprof быстро показывает, куда уходит время. Без этого инструмента вы бы гадали «на глаз».
Профилирование памяти
Теперь давайте посмотрим, как искать утечки и лишние аллокации.
package main
import (
"log"
"net/http"
"os"
"runtime/pprof"
)
var cache [][]byte
func handler(w http.ResponseWriter, r *http.Request) {
// Здесь мы имитируем утечку памяти
data := make([]byte, 1024*1024) // 1 MB
cache = append(cache, data) // Держим ссылки и не освобождаем
_, _ = w.Write([]byte("stored"))
}
func main() {
http.HandleFunc("/", handler)
// Пишем снимок кучи по сигналу, а не постоянно
http.HandleFunc("/debug/mem", func(w http.ResponseWriter, r *http.Request) {
// Создаем файл с профилем памяти
f, err := os.Create("mem.prof")
if err != nil {
log.Println(err)
return
}
defer f.Close()
// Снимаем профиль кучи
// Это покажет, какие объекты занимают память
if err := pprof.WriteHeapProfile(f); err != nil {
log.Println(err)
}
_, _ = w.Write([]byte("ok"))
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
После некоторой нагрузки заходите на /debug/mem, получаете файл mem.prof, анализируете:
go tool pprof mem.prof
Дальше команды:
- top — какие типы и функции создают больше всего объектов
- web — наглядный граф по памяти
Это поможет вам увидеть, где создаются большие срезы, строки и структуры, которые не освобождаются или создаются слишком часто.
Пакет net/http/pprof
Часто нет смысла вручную писать обработчики для профилей. В продакшене удобно включать стандартный HTTP хендлер pprof:
import (
_ "net/http/pprof"
)
// В main()
go func() {
// Здесь я запускаю отдельный HTTP сервер только для pprof
log.Println("pprof on :6060")
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
После этого вы получаете:
- /debug/pprof/profile — CPU профиль
- /debug/pprof/heap — профиль памяти
- /debug/pprof/goroutine — профиль горутин
- и другие
Обратите внимание: такой сервер обычно вешают на localhost или за VPN, чтобы не светить его наружу.
Оптимизация аллокаций памяти
Почему аллокации так важны
Каждый вызов выделения памяти:
- стоит времени (обращение к аллокатору),
- создает нагрузку на сборщик мусора (GC),
- может приводить к дополнительным копированиям данных.
В Go вы часто видите эффект оптимизации не по времени, а по уменьшению B/op и allocs/op в бенчмарке. Снижение этих значений почти всегда дает выигрыш при реальной нагрузке.
Используем make с запасом
Давайте посмотрим, чем отличается работа со срезами при разной стратегии выделения.
func buildSliceWithoutCapacity(n int) []int {
var res []int
for i := 0; i < n; i++ {
res = append(res, i) // Здесь срез будет многократно пересоздаваться
}
return res
}
func buildSliceWithCapacity(n int) []int {
// Здесь мы заранее выделяем нужную емкость
res := make([]int, 0, n)
for i := 0; i < n; i++ {
res = append(res, i) // Аллокаций будет гораздо меньше
}
return res
}
Покажу вам, как это увидеть в бенчмарке:
func BenchmarkBuildSlice(b *testing.B) {
b.Run("no-cap", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = buildSliceWithoutCapacity(1000)
}
})
b.Run("with-cap", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = buildSliceWithCapacity(1000)
}
})
}
При запуске вы почти наверняка увидите, что версия with-cap делает на порядок меньше аллокаций.
Избегаем ненужных копирований строк
Строки в Go неизменяемы, и любая операция, которая создает новую строку, может аллоцировать память. Пример — конкатенация в цикле.
// Плохой вариант - каждая конкатенация создает новую строку
func concatSlow(parts []string) string {
s := ""
for _, p := range parts {
s += p // Здесь аллокации и копирования на каждом шаге
}
return s
}
Лучше использовать strings.Builder:
import "strings"
func concatFast(parts []string) string {
var b strings.Builder
// Можно заранее указать примерный размер
// Это уменьшит количество аллокаций
totalLen := 0
for _, p := range parts {
totalLen += len(p)
}
b.Grow(totalLen)
for _, p := range parts {
_, _ = b.WriteString(p) // Ошибки здесь не возникают
}
return b.String()
}
Обратите внимание, как явно мы управляем емкостью и избегаем множества промежуточных строк.
Передача по указателю или по значению
Большие структуры лучше передавать по указателю, чтобы избежать копирования. Но слишком активное использование указателей затрудняет понимание кода и может мешать оптимизациям компилятора. Здесь важен баланс.
type BigStruct struct {
Data [1024]byte
}
// Копирует всю структуру при каждом вызове
func processByValue(b BigStruct) {
_ = b.Data[0]
}
// Передает только указатель - копирование минимально
func processByPointer(b *BigStruct) {
_ = b.Data[0]
}
Вы можете проверить разницу через бенчмарки и профилировать аллокации.
Работа со сборщиком мусора (GC)
Как устроен GC в Go в общих чертах
Go использует инкрементальный, параллельный сборщик мусора на основе маркировки и очистки. Важные свойства:
- работает параллельно с вашим кодом,
- периодически приводит к «микропаузам»,
- чем больше «мусора» и живых объектов, тем больше работы должен выполнить GC.
Вы можете увидеть статистику GC с помощью пакета runtime.
package main
import (
"fmt"
"runtime"
)
func main() {
var m runtime.MemStats
// Снимаем статистику о памяти и GC
runtime.ReadMemStats(&m)
// Здесь я вывожу только несколько ключевых полей
fmt.Println("Alloc bytes:", m.Alloc) // Сколько памяти сейчас в использовании
fmt.Println("TotalAlloc bytes:", m.TotalAlloc) // Всего выделено за все время
fmt.Println("NumGC:", m.NumGC) // Сколько раз сработал GC
}
Параметр GOGC
Переменная окружения GOGC управляет частотой запуска GC. Значение — проценты от текущего объема используемой памяти. Например:
- GOGC=100 — по умолчанию, GC срабатывает при росте памяти примерно в 2 раза;
- GOGC=50 — более частый GC (меньше память, но выше нагрузка на CPU);
- GOGC=200 — более редкий GC (больше память, но меньше работа GC).
Запуск:
GOGC=200 go run main.go
Обратите внимание, как увеличение GOGC может уменьшить паузы и время работы GC, но увеличит потребление памяти. Это настройка баланса.
Снижение давления на GC
Главный способ помочь GC — меньше аллоцировать и быстрее отпускать ненужные объекты.
Типичные приемы:
- использовать sync.Pool для временных объектов;
- не держать ссылки на давно ненужные структуры;
- переиспользовать буферы (bytes.Buffer, байтовые срезы).
Пример с sync.Pool:
import (
"sync"
)
var bufPool = sync.Pool{
// New вызывается, если в пуле нет готовых объектов
New: func() any {
// Здесь мы создаем новый буфер
b := make([]byte, 0, 4096)
return &b
},
}
func handle() {
// Берем буфер из пула
b := bufPool.Get().(*[]byte)
// Очищаем срез, сохраняем емкость
*b = (*b)[:0]
// Используем буфер
*b = append(*b, []byte("data")...)
// Возвращаем в пул, чтобы его могли переиспользовать
bufPool.Put(b)
}
Давайте посмотрим, что мы сделали:
- сильно сократили количество аллокаций буферов;
- помогли GC, так как объекты из пула живут долго, но переиспользуются;
- избежали лишних копирований при создании срезов.
Важно: sync.Pool подходит только для временных, одноразовых объектов, которые не уходят за пределы короткоживущих операций.
Эффективная работа с горутинами и каналами
Избегаем взрывного роста горутин
Горутины легкие, но не бесплатные. Ошибка «горутину запускаем всегда, а ждем результата не всегда» быстро приводит к утечкам.
Пример опасного кода:
func doWork(ch chan int) {
for x := range ch {
_ = x // Обрабатываем данные
}
}
func start() {
ch := make(chan int)
// Запускаем воркер
go doWork(ch)
// Здесь мы забыли закрыть канал
// Горутина будет висеть в ожидании новых данных
}
Лучше четко управлять жизненным циклом:
func startSafe() {
ch := make(chan int)
go func() {
defer close(ch) // Гарантируем, что канал будет закрыт
for i := 0; i < 10; i++ {
ch <- i
}
}()
for v := range ch {
_ = v // Обрабатываем значения
}
}
Здесь вы видите, как закрытие канала помогает корректно завершить потребителя.
Пулы воркеров
Частая задача — обрабатывать много задач параллельно, но ограничить максимальное количество одновременных горутин. Здесь удобно использовать пул воркеров.
type Task func()
// runWorker запускает одного воркера, который читает задачи из канала
func runWorker(id int, tasks <-chan Task, wg *sync.WaitGroup) {
defer wg.Done()
for t := range tasks {
t() // Выполняем задачу
}
}
func runWithWorkers(nWorkers int, jobs []Task) {
tasks := make(chan Task)
var wg sync.WaitGroup
// Запускаем ограниченное число воркеров
for i := 0; i < nWorkers; i++ {
wg.Add(1)
go runWorker(i, tasks, &wg)
}
// Отправляем задачи в канал
for _, job := range jobs {
tasks <- job
}
// Закрываем канал, чтобы воркеры корректно завершились
close(tasks)
// Ждем, пока все воркеры закончат
wg.Wait()
}
Покажу вам, как это помогает:
- вы ограничиваете максимальную параллельность;
- избегаете бесконтрольного роста горутин;
- получаете предсказуемую нагрузку на CPU и память.
Выбор алгоритмов и структур данных
Иногда лучший гейн — сменить алгоритм
Иногда код написан «красиво», но медленно, потому что используется не тот алгоритм. Например, при поиске в отсортированном массиве линейный обход значительно хуже бинарного поиска.
Пример сравнения:
// Линейный поиск - O(n)
func linearSearch(nums []int, target int) int {
for i, v := range nums {
if v == target {
return i
}
}
return -1
}
// Бинарный поиск - O(log n)
// Предполагаем, что nums отсортирован
func binarySearch(nums []int, target int) int {
low, high := 0, len(nums)-1
for low <= high {
mid := (low + high) / 2
if nums[mid] == target {
return mid
}
if nums[mid] < target {
low = mid + 1
} else {
high = mid - 1
}
}
return -1
}
Если вы часто ищете по большим данным, правильный выбор алгоритма даст выигрыш на порядки больше, чем любые микрооптимизации.
Карты против срезов
В Go карта (map) удобна, но имеет оверхед. В небольших структурах данных иногда дешевле использовать срез и линейный поиск, чем карту.
Руководствуйтесь такими правилами:
- если количество элементов маленькое (десятки) и вы редко ищете по ключу — срез может быть быстрее;
- если количество элементов растет и поиск по ключу частый — карта будет выигрышнее.
Параллелизация и использование всех ядер
GOMAXPROCS
По умолчанию Go использует количество логических ядер, равное числу CPU. Но иногда вы хотите управлять этим явно.
import "runtime"
func main() {
// Здесь мы ограничиваем количество потоков ОС, которые могут выполнять Go-код
runtime.GOMAXPROCS(4)
// Дальше идет ваш код
}
В реальности чаще достаточно оставить поведение по умолчанию и просто писать корректный параллельный код. Но иногда полезно уменьшить GOMAXPROCS, если приложение сильно мешает соседям на том же сервере.
Когда параллелизация не помогает
Важный момент: не каждая задача ускоряется от распараллеливания. Есть накладные расходы:
- создание горутин;
- планирование и переключение контекста;
- синхронизация (мьютексы, каналы).
Если каждая задача очень маленькая (например, операция над двумя числами), то накладные расходы легко «съедят» всю пользу.
Практический чек-лист оптимизации
Давайте соберем все в удобный список, которым вы можете пользоваться в реальных проектах.
Шаг 1: Определяем метрику
- Время ответа (p95, p99).
- Пропускная способность (rps).
- Память (максимум, среднее).
- Задержки GC.
- Количество горутин.
Шаг 2: Снимаем профили
- CPU профиль при типичной нагрузке.
- Профиль памяти, особенно перед пиками.
- Профиль горутин, если кажется, что они «текут».
Шаг 3: Ищем самые крупные узкие места
- top в pprof для CPU и памяти.
- Список функций, дающих 80% нагрузки.
- Подозрительные места — большие аллокации, часто вызываемые функции.
Шаг 4: Пробуем локальные оптимизации
- Уменьшаем аллокации: заранее задаем емкость, используем пулами.
- Оптимизируем работу со строками и JSON (напрямую, без лишних преобразований).
- Убираем лишние конвертации типов и интерфейсов.
Шаг 5: Проверяем системную архитектуру
- Балансируем нагрузку между сервисами.
- Уменьшаем количество сетевых запросов в критических участках.
- Сокращаем количество обращений к базе.
Шаг 6: Перезапускаем цикл
- Снова снимаем профили.
- Убеждаемся, что новые проблемы не появились.
- Фиксируем результаты (например, в документации, чтобы коллеги понимали, почему код написан именно так).
Заключение
Оптимизация производительности в Go — это комбинация грамотного профилирования, понимания основных механизмов языка (аллокации, GC, горутины) и аккуратной работы с алгоритмами и структурами данных.
Если вы держите в голове простой процесс: измерить, найти, изменить, проверить — вероятность «сделать хуже» минимальна. Опирайтесь на pprof, бенчмарки и реальные метрики приложения. Система, в которой регулярно измеряют производительность, оптимизируется гораздо проще, чем та, где изменения вносятся «по ощущениям».
Частозадаваемые технические вопросы по теме и ответы
Как быстро понять, какие строки создают больше всего мусора в кучи
- Снимите профиль памяти при типичной нагрузке.
go tool pprof -http=:8081 heap.prof - В интерфейсе pprof выберите Allocation objects или Allocation space.
- В списке сверху смотрите функции, связанные со строками (конкатенации, форматирование, JSON).
- Оптимизируйте их через strings.Builder, prealloc срезов, отказ от fmt.Sprintf в горячих местах.
Как измерить влияние конкретного изменения на GC
- Добавьте в код периодический вывод runtime.ReadMemStats.
- Снимите метрики NumGC, PauseTotalNs, Alloc до изменений.
- Внесите оптимизацию (например, sync.Pool или уменьшение аллокаций).
- Повторите тест под той же нагрузкой и сравните NumGC и суммарные паузы.
Как профилировать только один бенчмарк а не все подряд
- Используйте флаг -run=^$ чтобы не запускать тесты.
- Запускайте конкретный бенчмарк с профилем, например
go test -run=^$ -bench=BenchmarkSumSlice -cpuprofile=cpu.out - Анализируйте профиль через go tool pprof cpu.out.
Почему иногда map медленнее чем срез при небольших объемах данных
- Map имеет постоянные накладные расходы на хеширование и индексацию.
- Для небольших массивов (десятки элементов) линейный проход по срезу может быть быстрее.
- Решение — написать бенчмарки для обоих вариантов и выбрать лучший по результатам.
Как диагностировать утечку горутин
- Включите net/http/pprof и снимите профиль горутин по адресу /debug/pprof/goroutine.
- В pprof выполните web и посмотрите стеки горутин.
- Найдите те, которые висят в ожидании на канале или мьютексе.
- Исправьте логику завершения — корректное закрытие каналов, использование контекстов с таймаутом и проверка ошибок.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев