В этой статье я расскажу, что делать при обнаружении утечки памяти в Go-приложении: что может вызывать утечки и с чего начать искать источник проблемы.
Причины утечек
Для начала перечислим возможные причины утечек памяти:
- Утечки горутин
Когда происходит утечка горутины, утечка происходит и в переменных, находящихся в ее области. Кроме того, стек горутины размещается в куче. Вот пример программы с утечкой горутины:
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 вам необходимо самостоятельно управлять памятью, поэтому использовать его следует с осторожностью.
Устранение утечек
Итак, разберемся с первыми двумя типами утечек — как их устранить?
- Попробуйте найти утечки своими глазами
Внимательное изучение кода довольно часто помогало мне найти источник утечки. В 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:
- Чтобы запустить веб-интерфейс с графом, графом пламени и т. д., запустите: go tool pprof -http=:8081 http://localhost:9090/debug/pprof/goroutine
- Чтобы запустить интерактивный режим pprof, запустите: go tool pprof http://localhost:9090/debug/pprof/goroutine
Веб-команда откроет браузер по умолчанию со встроенным графиком.
Если вам нужно профилировать рабочую среду и нет возможности получить доступ к 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.
Заключение
Отлично, если вам удалось быстро локализовать утечку, добавив или исправив несколько строк кода, но это не всегда так, и поиск может быть затруднен. Иногда наиболее подходящим решением является рефакторинг. Если вы понимаете, что существующий код неоптимально работает с памятью, используя асинхронную модель, успешное выполнение которой рандомизировано, то лучше провести его рефакторинг, чем тратить часы на трассировку и отладку.
Полезные ссылки:
Профилирование и оптимизация в Go / Брэд Фитцпатрик
Шаблоны параллелизма в Go: конвейеры и отмена