В этой статье я расскажу, что делать при обнаружении утечки памяти в Go-приложении: что может вызывать утечки и с чего начать искать источник проблемы.

Причины утечек

Для начала перечислим возможные причины утечек памяти:

  1. Утечки горутин

Когда происходит утечка горутины, утечка происходит и в переменных, находящихся в ее области. Кроме того, стек горутины размещается в куче. Вот пример программы с утечкой горутины:

package main


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


func process(ch chan string) {
  for v := range ch {
     log.Println(v)
  }
}


func main() {
  http.HandleFunc("/foo/bar", func(writer http.ResponseWriter, request *http.Request) {
     ch := make(chan string)
     // We forgot to close the channel. As the result process never finishes
     // defer close(ch)


     go process(ch)


     for i := 0; i < 5; i++ {
        ch <- fmt.Sprintf("text %d", i)
     }


     writer.Write([]byte("for bar response"))
  })


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

2. Бесконечная запись в глобальные переменные

Приложение может бесконечно записывать в какую-то глобальную карту, что приводит к утечкам памяти. Однажды я пытался найти утечку в приложении, которое использовало контекст гориллы. Особенность этой библиотеки в том, что при обработке HTTP-запроса она сохраняет указатель на запрос в глобальной карте и не удаляет ключ карты без явного указания в коде пользователя. Начиная с Go 1.7, разработчики горилл рекомендуют использовать http.Request.Context().

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

3. sync.Pool, Cgo

Нельзя не упомянуть sync.Pool и Cgo.

Согласно официальной документации, Пул — это набор временных объектов, которые можно сохранять и извлекать по отдельности. Есть интересная проблема, при которой в пуле постоянно висят большие буфера и со стороны ситуация выглядит как утечка.

При использовании Cgo вам необходимо самостоятельно управлять памятью, поэтому использовать его следует с осторожностью.

Устранение утечек

Итак, разберемся с первыми двумя типами утечек — как их устранить?

  1. Попробуйте найти утечки своими глазами

Внимательное изучение кода довольно часто помогало мне найти источник утечки. В Go есть особенность — все нужно закрывать. При использовании HTTP-клиента вам необходимо закрыть тело ответа. При выборе строк из базы данных — закрывать строки. При открытии файла — закрыть файл. Это не всегда очевидно — даже опытные программисты могут забыть об этих шагах, что приводит к утечке ресурсов.

Вот пример кода с утечкой, когда тело ответа HTTP не было закрыто:

package main


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


func main() {
  log.Printf("pid: %d\n", os.Getpid())


  http.HandleFunc("/foo/bar", func(writer http.ResponseWriter, request *http.Request) {
     // we need not empty response from the server to receive a leak
     resp, err := http.Get("http://localhost:9091/foo/baz")
     if err != nil {
        writer.WriteHeader(http.StatusInternalServerError)
        return
     }
     // !!!WE FORGOT TO CLOSE resp.Body!!!
     // defer resp.Body.Close()


     ret := fmt.Sprintf("external service returned %d", resp.StatusCode)
     writer.Write([]byte(ret))
  })


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

Пример кода с утечкой памяти, где строки не закрыты:

package main


import (
  "database/sql"
  "encoding/json"
  "log"
  "net/http"
  _ "net/http/pprof"
  "os"


  "github.com/google/uuid"
  _ "github.com/lib/pq"
)


type User struct {
  ID   uuid.UUID `db:"id" json:"id"`
  Name string    `db:"name" json:"name"`
}


func main() {
  log.Printf("pid: %d\n", os.Getpid())


  db, err := sql.Open("postgres", "postgres://127.0.0.1:5432/test_db?sslmode=disable")
  if err != nil {
     log.Fatal(err)
  }
  if err := db.Ping(); err != nil {
     log.Fatal(err)
  }


  http.HandleFunc("/foo/bar", func(writer http.ResponseWriter, request *http.Request) {
     var (
        users []User
        user  User
     )


     query := "select id, name from users limit 5"
     rows, err := db.Query(query)
     if err != nil {
        writer.WriteHeader(http.StatusInternalServerError)
        return
     }
     // !!!WE FORGOT TO CLOSE ROWS!!!
     // defer rows.Close()


     // We need more than one row in the table to receive a leak
     for rows.Next() {
        if err := rows.Scan(&user.ID, &user.Name); err != nil {
           writer.WriteHeader(http.StatusInternalServerError)
           return
        }
        users = append(users, user)
        break
     }
     if rows.Err() != nil {
        writer.WriteHeader(http.StatusInternalServerError)
        return
     }


     encoder := json.NewEncoder(writer)
     if err := encoder.Encode(users); err != nil {
        writer.WriteHeader(http.StatusInternalServerError)
        return
     }
  })


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

2. Использование профилировщика

Если детальное изучение кода (см. пункт 1) не помогло выявить источник утечки, переходим к использованию профилировщика pprof. Согласно официальной документации, «pprof — это инструмент для визуализации и анализа данных профилирования. pprof считывает набор образцов профилирования в формате profile.proto и создает отчеты для визуализации и анализа данных. Он может генерировать как текстовые, так и графические отчеты (благодаря использованию пакета точечной визуализации)».

Чтобы использовать pprof, вам нужно импортировать «net/http/pprof». После этого URL «/debug/pprof/» будет доступен через порт HTTP, который слушает приложение, где можно найти профили нужного pprof.

Есть несколько полезных команд для использования pprof:

Веб-команда откроет браузер по умолчанию со встроенным графиком.

Если вам нужно профилировать рабочую среду и нет возможности получить доступ к URL-адресу «/debug/pprof», вы можете скомпилировать приложение локально, попросить кого-то, у кого есть доступ к рабочей среде, загрузить профиль и запустить команду «go tool pprof test pprof_goroutine» или «go tool pprof -http=:8081 test pprof_goroutine». Здесь «test» — это скомпилированный файл программы, а «pprof_goroutine» — загруженный профиль из производственной среды.

Ранее я упоминал, что утечка памяти в горутинах является частой причиной утечек, исходя из моего опыта. Поэтому рекомендую сначала проверить его.

Код с утечкой горутин, о котором упоминалось ранее, выглядит следующим образом:

package main


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


func main() {
  log.Printf("pid: %d\n", os.Getpid())


  http.HandleFunc("/foo/bar", func(writer http.ResponseWriter, request *http.Request) {
     // we need not empty response from the server to receive a leak
     resp, err := http.Get("http://localhost:9091/foo/baz")
     if err != nil {
        writer.WriteHeader(http.StatusInternalServerError)
        return
     }
     // !!!WE FORGOT TO CLOSE resp.Body!!!
     // defer resp.Body.Close()


     ret := fmt.Sprintf("external service returned %d", resp.StatusCode)
     writer.Write([]byte(ret))
  })


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

Профиль горутины этой программы после нескольких запросов к конечной точке выглядит следующим образом:

Если причиной утечки является утечка соединений, то вы сможете увидеть постоянно растущий список файловых дескрипторов с помощью команды ls -la /proc/{pid}/fd. После этого нужно посмотреть профиль кучи. Вот пример кода программы и профиля кучи, который бесконечно записывает в глобальную переменную.

package main


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


var someMap = make(map[*http.Request][]int)


func main() {
  log.Printf("pid: %d\n", os.Getpid())


  http.HandleFunc("/foo/bar", func(writer http.ResponseWriter, request *http.Request) {
     someMap[request] = make([]int, 10000)
     //delete(someMap, request)


     writer.Write([]byte("foo bar response"))
  })


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

Профиль кучи после 2000 запросов.

3. runtime.MemStats

Первые два шага выполнены, но утечка не устранена? Тогда имеет смысл попробовать запустить приложение локально и использовать runtime.MemStats. Это структура, которая собирает статистику о производительности распределителя памяти. Например, он показывает, сколько байт выделено под объекты в куче, сколько таких объектов в куче и сколько байтов занимают активные спаны (единицы Go для работы с виртуальной памятью, т.е. виртуальное адресное пространство на интервалы).

Очень часто странное поведение приложения можно наблюдать в продакшене только при определенных условиях. В этом случае следует попытаться воспроизвести эту ситуацию — запустить приложение локально, локализовать проблему в конкретном обработчике/участке кода и прочитать статистику MemStats.

Заключение

Отлично, если вам удалось быстро локализовать утечку, добавив или исправив несколько строк кода, но это не всегда так, и поиск может быть затруднен. Иногда наиболее подходящим решением является рефакторинг. Если вы понимаете, что существующий код неоптимально работает с памятью, используя асинхронную модель, успешное выполнение которой рандомизировано, то лучше провести его рефакторинг, чем тратить часы на трассировку и отладку.

Полезные ссылки:

О pprof
Как читать график

Профилирование и оптимизация в Go / Брэд Фитцпатрик
Шаблоны параллелизма в Go: конвейеры и отмена

https://habr.com/ru/post/724402/