Введение

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

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

А что собственно происходит?

Утечка горутин в Go происходит, когда горутина продолжает существовать и потреблять ресурсы, даже если она больше не выполняет полезной работы или не может завершиться. Это может произойти по разным причинам.
Мы рассмотрим 3 примера из которых: 2 будут на каналах, 1 с использованием mutex.

Пример №1 - запись канал

func func1() int64 {
	time.Sleep(2 * time.Second)
	return 1
}

Обычная задача , мы хотим обернуть функцию func1 в декоратор и использовать context для возврата ошибки по истечению времени. Совсем просто сказал я и создал этот код.

func func2WithContext(ctx context.Context) (int64, error) {
	ch := make(chan int64)

	go func() { // goroutine try to write result in channel
		ch <- func1()
	}()

	select {
	case value := <-ch:
		return value, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

Казалось бы, всё работает - мы прокидываем контест и ожидаем либо возврата от func1, либо истечения времени ctx.

Но представим, что ctx завершился раньше. Тогда вслед за ctx.Done() мы выйдем из func2WithContext, а func1 всё еще не закончит своё выполнение.
Спустя некоторое время func1 смогла вернуть результат обозначим его result , тогда анонимная горутина попытается записать result в наш канал, тут нам и хана.

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

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

func func2WithContext(ctx context.Context) (int64, error) {
	ch := make(chan int64, 1)

	go func() { // goroutine try to write result in channel
		ch <- func1()
	}()

	select {
	case value := <-ch:
		return value, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

Пример №2 - чтение из канала

У нас есть следующий код. Легенда: некоторый сервис обрабатывает набор данных и отдает их в канал для дальнейшей работы.

func printData(dataCh chan string) { //goroutine wait info from dataCh
	for info := range dataCh {
		fmt.Println(info)
	}
}

func processData(data []string) {
	dataCh := make(chan string)
	go printData(dataCh)

	for _, info := range data {
		dataCh <- info
	}
}

Тут мы совершили страшный проступок ошибка и ты ошибся.

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


Заметим, что функция printData завершается только в том случае, если канал закрылся.
Функция processData не закрывает канал в который пишет, что в данном случае и приводит к утечке горутины и утечке объекта dataCh.

Решение довольно простое: нужно закрыть канал

func processData(data []string) {
	dataCh := make(chan string)
	go printData(dataCh)

	for _, info := range data {
		dataCh <- info
	}

	close(dataCh)
}

Пример №3 - блокировка блокированного Мьютекса

Вот же лексический повтор.
Вкратце мы имеем следующий код.

func work() {
	m := &sync.Mutex{}
	m.Lock()

	go func() { // try to lock locked mutex
		m.Lock()
	}()
}

Заметим, что код максимально простой , тут мы всё что делаем это блокируем уже заблокированный Мьютекс внутри другой горутины.
Что имеем? Анонимная функция не будет закончена, пока ей не удастся залочить mutex.
Результат: у нас утекла горутина (

Как отладить?

Легко - никак
Проблема на самом деле острая и прийдется покопаться в грязи.
Для отладки есть несколько способов.

1) Линтер и проверка кода глазками.
В описанных выше случаях линтер не помогает, он не находит проблему, всё что мне остается - это искать глазками

2)Метрики и скрины стэк трейса.
Этот служит сужение круга подозреваемых, для дальнейшей отладки ручками. В основном все использует передачу метрик например в Grafana и debug с помощью скринов сервиса. Уже предустановленным пакетом pprof.

Советы - их будет 3

1) Следите за code style программы. Бейте большие участки кода на маленькие, что позволит сузить круг подозреваемых объектов

2) Убедитесь, что использованный объекты вы обработали правильно. Будь, то ctx и отменной Deadline или закрытие Канала

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

PS

Моя первая техническая статья , так -что без буллинга, а то налетят Яндексишкины и накидают -rep, поймите карма не бесконечная. Я попытался максимально кратко обозначить проблему и дать мини ответы для пути её решения. Расписать всё подробно = статья на 30-60 минут, ее тогда никто не будет читать. Главное, что я хочу донести: боец осведомлён, что бывает утечка горутин, а как ее решать подробно, боец и без меня поймет. Иначе, не стать ему генералом.

Всех обнял.

Ссылка на исходники, там более подробно описано решение с помощью вывода стек-трейсов в рантайме.

- гитхаб с исходниками

- канальчик для вкатунов ВЕК
- личный ютуб бложек про ваше IT

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


  1. outlingo
    12.02.2025 22:11

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

    А что до Go, у вас же в нем есть прекрасная конструкция defer - m.Lock() и тут же defer m.Unlock(), c := make(chan ...) и сразу defer Close(c)

    Все давно придумано, надо только использовать.


    1. egorChuyko Автор
      12.02.2025 22:11

      Во всем правы , я постарался упомянуть об этом в статье.

      Но бывают заурядные кейсы с мьютексами. defer m.Unlock() иногда может не использоваться , как например в исходниках go для оптимизация и быстрого разлочивания. Именно в продакшене не встречал , но в личных целях приходилось.

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


      1. qrKot
        12.02.2025 22:11

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

        Вот так вот не надо, не надо сценариев, когда один из писателей закрывает канал. И придумывать такие сценарии тоже не надо, все придумано до нас: sync.WaitGroup - канал должен закрываться тогда и только тогда, когда мы гарантированно не собираемся в него больше писать


  1. evgeniy_kudinov
    12.02.2025 22:11

    1.Наверное КДПВ лучше сменить на что-то нейтральное
    2.Как выше уже указали, есть defer, и я дополню, что лучше, чтобы сразу после выделения ресурса стараться размещать defer (ресурс).Close(). «Глазками» проще увидеть и перестать беспокоиться о закрытии ресурса. 

    r.Open()
    defer r.Close()


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

    r.Open()
    
    if .... {
       .....
      r.Close()
      return
    } else if .... {
       ....
      return
    } else {
      ...
    }
    
    r.Close()