В этой статье я расскажу, что делать при обнаружении утечки в 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))
}

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

Если причина утечки – утечка коннектов, то можно будет увидеть постоянно растущий список файловых дескрипторов 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 запросов

3) runtime.MemStats

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

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

Заключение

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

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

Про pprof
Как читать граф
Profiling & Optimizing in Go / Brad Fitzpatrick
Go Concurrency Patterns: Pipelines and cancellation

Комментарии (4)


  1. stanlyzoolo
    00.00.0000 00:00
    +1

    Спасибо за интересную статью, как раз с товарищем получили задачу на "проверить утекает ли память".
    Будем искать. (с)


  1. DmitriyTitov
    00.00.0000 00:00
    +2

    Можете пояснить, как именно возникает утечка памяти в примере из раздела Пример кода с утечкой, где не закрыли http reponse body?
    Там resp - локальная переменная, в замыканиях не участвует. Почему GC не сможет её собрать после выхода из HTTP-обработчика?

    В комментарии к Body я вижу упоминание про возможное "утекание" TCP-соединений, но где тут утекает память?


    1. Kirooha Автор
      00.00.0000 00:00

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

      Касательно локальной переменной resp. С ней нет никаких проблем. Это локальная переменная, которая "не переживет" свою функцию.

      Проблема в этом примере не в том, что у нас не может завершиться обработчик http запроса func(writer http.ResponseWriter, request *http.Request). А в http.DefaultClient. http.Get под капотом использует именно его. У этого клиента есть пул коннектов, который будет расти при каждом запросе к нашему http обработчику func(writer http.ResponseWriter, request *http.Request).

      Вот что нам говорит официальная документация про закрытие Body - It is the caller's responsibility to close Body. The default HTTP client's Transport may not reuse HTTP/1.x "keep-alive" TCP connections if the Body is not read to completion and closed.

      Если абстрагироваться, то я думаю можно представить себе пул коннектов как глобальный объект, в котором на каждый http вызов создается коннект (запускается горутина). В том момент, когда http клиент получает ответ и не закрывает body, коннект не может вернуться в пул и быть переиспользован (горутина не завершается). В дальнейшем при новом запросе будет создан новый коннект в пуле коннектов (запущена горутина). В результате в профиле мы видим утекающие горутины, что и приводит к утечкам памяти.


      1. DmitriyTitov
        00.00.0000 00:00

        Спасибо, всё понятно.