В этой статье я расскажу, что делать при обнаружении утечки в 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) Бесконечная запись в глобальные переменные
Приложение может бесконечно писать в какую-нибудь глобальную мапу, в результате чего память будет утекать. Один раз я пытался найти утечку у приложения, которое использовало gorilla context. Особенность этой библиотеки в том, что при обработке http запроса она сохраняет указатель на запрос в глобальную мапу и не удаляет ключ мапы без явного указания в пользовательском коде. Начиная с Go 1.7, разработчики gorilla рекомендуют использовать http.Request.Context()
Эта статья будет сосредоточена на поиске первых двух типов утечек как наиболее распространенных.
3) sync.Pool, Cgo
Нельзя не упомянуть о sync.Pool и Cgo.
Sync.Pool
Согласно официальной документации, "A Pool is a set of temporary objects that may be individually saved and retrieved." Существует интересная issue, при которой большие буферы вечно висят в пуле и со стороны ситуация выглядит как утечка.
При использовании Cgo, необходимо самостоятельно заниматься менеджментом памяти, поэтому использовать его следует с осторожностью.
Исправление утечек
Итак, разберемся с первыми двумя типами утечек – как их исправить?
1) Попытаться найти утечки глазами
Внимательно пройтись по коду помогало мне довольно часто найти источник утечки. У Go есть особенность – все нужно закрывать. При использовании http клиента нужно закрывать response body. При выборке строк из базы данных – закрыть rows. При открытии файла – файл нужно закрыть. Это не всегда очевидно – даже опытные программисты могут забыть про эти шаги, что и приводит к утечкам ресурсов.
Пример кода с утечкой, где не закрыли http reponse body
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))
}
Пример кода с утечкой, где не закрыли rows
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 is a tool for visualization and analysis of profiling data. pprof reads a collection of profiling samples in profile.proto format and generates reports to visualize and help analyze the data. It can generate both text and graphical reports (through the use of the dot visualization package)."
Для использования pprof нужно подключить в import "net/http/pprof". После этого по http порту, который слушает приложение станет доступен url "/debug/pprof/", по которому можно найти профили искомого pprof.
Существует несколько полезных команд для использования pprof:
Запустить web интерфейс с graph, флейм-графом и т.д.
go tool pprof -http=:8081 http://localhost:9090/debug/pprof/goroutineЗапустить интерактивный режим pprof.
go tool pprof http://localhost:9090/debug/pprof/goroutine
Команда web откроет браузер по умолчанию с построенным графомЕсли профилировать нужно прод и нет возможности пройтись по 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))
}
Профиль горутин этой программы после нескольких запросов к эндпоинту
![](https://webcf.waybackmachine.org/web/20230522034007im_/https://habrastorage.org/getpro/habr/upload_files/361/f1b/da3/361f1bda306a6b569f40384c48cd0e7f.png)
Если причина утечки – утечка коннектов, то можно будет увидеть постоянно растущий список файловых дескрипторов ls -la /proc/{pid}/fd.
После этого необходимо посмотреть в профиль heap.
Так выглядят код и профиль программы, которая бесконечно пишет в глобальную переменную.
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 запросов
![](https://webcf.waybackmachine.org/web/20230522034007im_/https://habrastorage.org/getpro/habr/upload_files/e5a/fbe/c7d/e5afbec7d1ba947d725a248ef0e01367.png)
3) runtime.MemStats
Первые два пункта выполнены, но утечки победить не удалось? Тогда есть смысл попробовать поднять приложение локально и использовать runtime.MemStats. Это структура, которая собирает статистику о работе аллокатора памяти. Например, сколько байтов для объектов в хипе аллоцировано, сколько таких объектов находится в хипе, сколько байтов занято активными спанами (единицы в Go для работы с виртуальной памятью, т.е. Go разбивает виртуальное адресное пространство кучи на спаны).
Очень часто странное поведение приложения можно увидеть только на проде при определенных условиях. В этом случае стоит попытаться воссоздать эту ситуацию – поднять приложение локально, локализовать проблему до конкретного обработчика/участка кода, читать статистику MemStats.
Заключение
Здорово, если получилось быстро локализовать утечку, добавив или исправив несколько строк кода, но так бывает далеко не всегда и поиск может оказаться сложным. Иногда наиболее подходящим решением оказывается рефакторинг. Если понимаете, что существующий код неоптимально работает с памятью, использует асинхронную модель, успешное выполнение которой рандомизированно, то лучше отрефакторить его, чем тратить часы на трейсинг и дебаггинг.
Полезные ссылки:
Про pprof
Как читать граф
Profiling & Optimizing in Go / Brad Fitzpatrick
Go Concurrency Patterns: Pipelines and cancellation