Команда Go for Devs подготовила перевод статьи о том, как GoLand помогает разработчикам вовремя находить и устранять утечки ресурсов. Файлы, соединения, HTTP-ответы, SQL-строки — всё это может незаметно накапливаться и ломать сервис под нагрузкой. В статье на реальных примерах показано, как одна пропущенная Close() приводит к сбоям, и почему встроенный анализ утечек становится незаменимым инструментом для стабильного продакшена.


Любое Go-приложение работает с ресурсами: файлами, сетевыми подключениями, HTTP-ответами и результатами запросов к базе данных. Если ресурс оставить незакрытым, возникает утечка, которая расходует память, выжигает системные лимиты, приводит к скрытым ошибкам и в итоге может уронить даже самый надежный сервис. Недавно мы добавили в GoLand анализ утечек ресурсов, чтобы помогать находить незакрытые объекты до того, как они приведут к проблемам в проде.

Что такое утечка ресурса?

Ресурсом считается любая сущность, которая держит внешний системный дескриптор или состояние и должна быть явно закрыта, когда больше не нужна. В Go такие типы обычно реализуют интерфейс io.Closer, в котором определен единственный метод Close() для очистки связанных ресурсов.

Вот несколько распространенных реализаций io.Closer:

  • os.File: открытый файловый дескриптор, полученный через функции вроде os.Open или os.Create.

  • net.Conn: сетевое подключение (TCP, UDP и так далее), созданное через net.Dial, net.Listen.Accept или аналогичные функции.

  • http.Response: объект ответа, возвращаемый http.Get, http.Post и подобными функциями. Его поле Body является ресурсом, поскольку реализует io.ReadCloser.

  • sql.Rows и sql.Conn: результаты запросов к базе данных и подключения.

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

Знать бы раньше…

Разве сборщик мусора Go не должен справляться с этим? В конце концов, он автоматически освобождает неиспользуемую память, так почему бы не делать то же самое с ресурсами?

Сборщик мусора (GC) в Go предназначен исключительно для освобождения памяти. Он не отвечает за управление внешними ресурсами вроде открытых файлов или сетевых подключений. В редких случаях он может косвенно помочь. Например, в стандартной библиотеке можно назначить финалайзер, который вызовет Close(), когда файл становится недостижимым. Но этот прием используется только как крайняя мера, чтобы хоть как-то защитить разработчиков от утечек, и полагаться на него полностью нельзя.

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

Советы по предотвращению утечек ресурсов

Что можно сделать, чтобы избежать утечек ресурсов в Go-приложениях? Несколько простых и последовательных привычек помогут значительно снизить их вероятность.

Совет 1. Используйте defer для закрытия ресурсов

Оператор defer гарантирует выполнение очистки даже в тех случаях, когда функция завершается раньше времени или падает в panic. В примере ниже мы закрываем созданный ресурс сразу после успешного открытия с помощью defer f.Close(), что является одним из самых простых и эффективных способов избежать утечек.

f, err := os.Open("data.txt")
if err != nil {
    return err
}
defer f.Close()

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

Совет 2. Тестируйте приложение под нагрузкой

Большинство утечек ресурсов проявляются только при высокой нагрузке, поэтому имеет смысл запускать нагрузочные и стресс-тесты, чтобы понять, как приложение ведет себя в таких условиях. Например, если вы разрабатываете backend-сервис, можно использовать инструменты нагрузочного тестирования вроде k6, wrk, Vegeta или других. Такие тесты помогают выявить не только потенциальные утечки, но и узкие места производительности и проблемы масштабируемости до того, как они скажутся на пользователях.

Совет 3. Используйте инструменты статического анализа

Инструменты статического анализа могут автоматически находить незакрытые ресурсы в коде. Например, golangci-lint включает линтеры bodyclose и sqlclosecheck, которые отслеживают тела HTTP-ответов и SQL-ресурсы, чтобы убедиться, что вы не забыли их закрыть.

Анализ утечек ресурсов в GoLand 2025.3 идет еще дальше. Он проверяет ваш код прямо в процессе написания, гарантирует, что ресурсы корректно закрываются по всем путям выполнения, и подсвечивает потенциальные проблемы в реальном времени. Получая такую обратную связь прямо в IDE, вы можете ловить утечки на раннем этапе. Кроме того, анализ работает с любыми типами, реализующими io.Closer, включая ваши собственные реализации ресурсов.

Когда один пропущенный Close() рушит все

Насколько критичны утечки ресурсов на самом деле? Рассмотрим два типичных случая и увидим, как всего один пропущенный вызов Close() способен вызвать серьезные проблемы или со временем полностью вывести приложение из строя.

Случай 1. Утечка тела HTTP-ответа

Отправка HTTP-запросов в Go используется постоянно, например, чтобы получить данные от внешних сервисов. Допустим, у нас есть небольшая функция, которая пингует HTTP-сервер:

func ping(url string) (string, error) {
    resp, err := http.Get(url)
    if err != nil {
        return "", err
    }
    return resp.Status, nil
}

Когда вы пишете такой код, GoLand предупреждает о возможной утечке ресурса из-за незакрытого тела ответа. Но почему? Мы же даже не используем тело ответа в этом примере!

Попробуем запустить этот код и посмотрим, что произойдет. Чтобы сымитировать высокую нагрузку, можно вызывать нашу функцию в цикле вот так:

var total int
for {
   _, err := ping("http://127.0.0.1:8080/health")
   if err != nil {
      slog.Error(err.Error())
      continue
   }

   total++
   if total%500 == 0 {
      slog.Info("500 requests processed", "total", total)
   }
   time.Sleep(10 * time.Millisecond)
}

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

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

Почему так происходит? Когда вы делаете HTTP-запрос в Go, клиент устанавливает TCP-соединение и обрабатывает ответ сервера. Заголовки считываются автоматически, но тело ответа остается открытым, пока вы явно его не закроете.

Даже если вы не читаете и не используете тело, HTTP-клиент Go держит соединение открытым, ожидая сигнала, что вы с ним закончили. Если вы не закрываете тело, TCP-соединение остается открытым и не может быть переиспользовано. Со временем, по мере роста количества запросов, эти незакрытые соединения накапливаются, приводя к утечкам ресурсов и в итоге к исчерпанию доступных системных ресурсов.

Именно это и происходит в нашем примере. Каждая итерация ping() оставляет после себя открытое тело ответа, использование памяти стабильно растет, и спустя какое-то время клиент уже не может открывать новые соединения, что и приводит к ошибке can’t assign requested address.

Исправим ошибку и запустим код снова:

func ping(url string) (string, error) {
    resp, err := http.Get(url)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close() // Important: close the response body

    return resp.Status, nil
}

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

Стоит упомянуть, что в примере выше для простоты используется http.Client по умолчанию, что в продакшене обычно не рекомендуется. По умолчанию у него нет тайм-аута запросов, поэтому медленный или неотвечающий сервер может привести к тому, что запросы будут висеть бесконечно, вызывая зависание горутин и перерасход ресурсов. Гораздо правильнее создать собственный http.Client с разумными тайм-аутами, чтобы приложение оставалось отзывчивым и устойчивым даже при плохих сетевых условиях.

Случай 2. Забыли закрыть SQL-строки

Еще один частый и очень неприятный источник утечек в Go-приложениях связан с ресурсами базы данных, особенно при работе со стандартным пакетом database/sql. Рассмотрим простую функцию, которая получает имена пользователей по стране:

func (s *Store) GetUserNamesByCountry(country string) ([]string, error) {
    rows, err := s.db.Query(`SELECT name FROM users WHERE country = $1`, country)
    if err != nil {
        return nil, err
    }

    var names []string
    for rows.Next() {
        var name string
        if err := rows.Scan(&name); err != nil {
            return nil, err
        }
        names = append(names, name)
    }

    rows.Close()

    return names, rows.Err()
}

Хотя мы явно вызываем rows.Close(), GoLand все равно предупреждает о возможной утечке. Почему так?

Предположим, наша таблица выглядит примерно так:

Замечаете проблему? Верно, у одного пользователя нет имени. Точнее, вместо строки там хранится NULL, и функция GetUserNamesByCountry не умеет корректно обрабатывать такие случаи. Когда rows.Scan пытается присвоить NULL строковой переменной в Go, он возвращает ошибку. Подобные ситуации могут случиться у любого: этот «безымянный» пользователь вполне мог появиться в таблице по ошибке. Кажется, что из-за такого мелкого дефекта ничего страшного произойти не должно. В конце концов, мы как раз и старались аккуратно обрабатывать ошибки, верно?

Смоделируем боевые условия и будем вызывать функцию в цикле с разными параметрами:

var total int
for {
    for _, country := range countries {
        _, err := s.GetUserNamesByCountry(country)
        if err != nil {
            slog.Error(err.Error())
            continue
        }

        total++
        if total%100 == 0 {
            slog.Info("100 queries processed", "total", total)
        }
        time.Sleep(10 * time.Millisecond)
    }
}

При запуске программы все выглядит нормально: она работает, время от времени появляются ожидаемые ошибки.

Но после некоторого времени, когда выполнение и обработка SQL-запросов продолжаются, приложение полностью «разваливается», а логи забиваются ошибками примерно такого вида:

Мы исчерпали лимит клиентских подключений, и приложение больше не может получать данные из базы!

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

Удивительно, но всего одна некорректная строка в таблице способна положить все приложение только потому, что мы забыли корректно закрыть ресурс.

Лучший способ избежать такой ошибки — использовать defer. Как мы уже обсуждали, это должен быть ваш инструмент по умолчанию для управления ресурсами, когда это возможно:

func (s *Store) GetUserNamesByCountry(country string) ([]string, error) {
    rows, err := s.db.Query(`SELECT name FROM users WHERE country = $1`, country)
    if err != nil {
        return nil, err
    }
    defer rows.Close() // Important: close the rows using 'defer'

    var names []string
    for rows.Next() {
        var name string
        if err := rows.Scan(&name); err != nil {
            return nil, err
        }
        names = append(names, name)
    }

    return names, rows.Err()
}

Делаем невидимое видимым

Утечки ресурсов могут проникнуть в программу десятками разных способов, и приведенные выше примеры — лишь верхушка айсберга. Такие ошибки легко допустить и тяжело отследить. Код компилируется, тесты проходят, все вроде работает, пока сервис не начнет замедляться или падать под нагрузкой. Поиск корневой причины может занять много времени, особенно в больших кодовых базах.

Анализ утечек ресурсов в GoLand делает проблему заметной прямо в месте ее возникновения, пока вы пишете код. Инструмент отслеживает использование ресурсов по всем путям выполнения и предупреждает, если какой-то из них может остаться незакрытым. Такая ранняя обратная связь помогает вам быстрее находить ресурсы в коде и оперативно устранять потенциальные утечки.

Функция особенно полезна для новичков, которые еще изучают язык и могут не знать, какие ресурсы требуют явного закрытия. Но и опытные разработчики выигрывают: анализ экономит время при работе с незнакомыми участками кода и пользовательскими типами, реализующими io.Closer.

По сути, анализ утечек превращает тонкую, трудноуловимую проблему в то, что можно обнаружить мгновенно, делая Go-код более надежным и удобным в сопровождении.

Поддерживайте Go-приложения без утечек

Утечки ресурсов — один из самых коварных и разрушительных видов ошибок в Go-приложениях. Они редко приводят к мгновенным сбоям, но со временем тихо ухудшают производительность, вызывают нестабильность и даже могут «уронить» продакшен.

Последовательное использование defer, тестирование под реалистичной нагрузкой и новый анализ утечек ресурсов в GoLand позволяют выявлять такие проблемы заранее и поддерживать стабильность приложений. Попробуйте эту новую возможность в последнем релизе GoLand и расскажите нам, что думаете!

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

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


  1. RekGRpth
    11.12.2025 14:29

    Утечку нашёл, а как исправить - не понятно :(


  1. nickolaym
    11.12.2025 14:29

    RAII это слишком сложно, говорили они. Давайте сделаем помесь бейсика с явой, любой сможет говнокодить, говорили они.

    В результате каждый должен теперь бойлерплейтить с этим defer blablabla.Close().