JavaRush /Курсы /Go SELF /Утечки goroutine — типовые причины и «правило завершения»...

Утечки goroutine — типовые причины и «правило завершения»

Go SELF
67 уровень , 4 лекция
Открыта

1. Типовые причины утечек goroutine

Когда впервые слышишь «утечка goroutine», мозг рисует что-то вроде «утечка памяти», только с маленькими зелёными гоферами, которые сбегают из программы. Почти так и есть: goroutine остаётся жить, потому что она ждёт событие, которое уже никогда не произойдёт, и никто её не «уберёт». Go — язык вежливый: если вы запустили goroutine, он не будет её насильно убивать, пока жив процесс.

Говоря строго, утечка goroutine — это ситуация, когда goroutine зависла в ожидании (receive/send/range по каналу/ожидание таймера/ожидание чего-то ещё) и не имеет гарантированного условия завершения. Это опасно даже если «ничего не падает»: такие goroutine занимают память (стек, структуры рантайма), иногда держат ресурсы (например, ожидание на канале может удерживать ссылки на данные), а иногда создают эффект «программа вроде работает, но становится всё тяжелее».

Самый неприятный момент: утечки goroutine часто не проявляются сразу. В тестах «на один прогон» всё ок, а в долгоживущем приложении счётчик goroutine растёт, задержки увеличиваются, а вы начинаете подозревать, что компьютер просто устал от вашей архитектуры. Он не устал — он просто честно выполняет то, что вы написали.

Блокирующая отправка в канал без читателя

Очень человеческая история: вы сделали канал, запустили goroutine, отправили значение — и всё. Но у unbuffered-канала отправка (ch <- v) блокируется, пока кто-то не сделает receive (<-ch). Если читателя нет (или он «ушёл» по таймауту), отправитель зависает навсегда.

Мини-пример «как сделать больно»:

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 1 // зависнет: никто не читает
		fmt.Println("unreachable") // unreachable
	}()

	fmt.Println("main ends") // main ends
}

Здесь есть ещё один нюанс: main завершится — процесс завершится — и вы можете даже не заметить, что goroutine «утекла». Но если это не main, а обработчик запроса или фоновая часть сервиса, то зависшая goroutine будет копиться.

Есть три базовых лекарства, и у каждого — свой контракт.

  • Первое лекарство: гарантировать читателя (то есть построить протокол так, чтобы receive точно случился). Это звучит банально, но на практике означает: «тот, кто создаёт канал, отвечает за то, чтобы кто-то его читал».
  • Второе лекарство: буфер. Буфер позволяет отправителю «положить и уйти», если буфер не заполнен.
  • Третье лекарство: неблокирующая отправка через select { case ch <- v: default: }, если потеря значения допустима по контракту (это важно!). Паттерн с default делает отправку попыткой: «если прямо сейчас можно — отправь, иначе — не жди».

Блокирующее чтение из канала, где никто не пишет и не закрывает

Вторая классика жанра: вы пишете consumer, который читает из канала. А producer где-то «передумал» и перестал писать. Если producer при этом не закрывает канал, consumer может ждать бесконечно.

Вот такой код выглядит логичным, но очень хрупким:

package main

func reader(in <-chan int) {
	v := <-in // если никто не пришлёт — зависнем
	_ = v
}

func main() {}

Правильная мысль здесь такая: если вы ожидаете «конец потока», то вы должны уметь его распознать. А в каналах «конец потока» выражается закрытием канала и чтением вида v, ok := <-ch.

Мини-кусок, который умеет завершаться:

package main

func reader(in <-chan int) {
	v, ok := <-in
	if !ok {
		return // канал закрыт — корректно выходим
	}
	_ = v
}

func main() {}

for range ch, но канал никто не закрывает

for range ch — очень удобный паттерн: «читай, пока канал не закрыт». Но в этом удобстве есть встроенный договор: кто-то обязан закрыть канал. Если никто не закрывает — цикл вечный.

Плохой сценарий обычно выглядит так: «я где-то читаю range, а где-то пишу, и когда-нибудь оно закончится». Нет, не закончится. Каналы не умеют угадывать ваши намерения.

Хороший сценарий выглядит как «producer закрывает канал»:

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		for i := 0; i < 3; i++ {
			ch <- i
		}
		close(ch) // важный контракт!
	}()

	for v := range ch {
		fmt.Println(v) // 0, потом 1, потом 2
	}
}

Этот маленький close(ch) — как фраза «всё, я закончил, можешь расходиться». Без неё consumer будет ждать «продолжения банкета».

Таймаут сработал, а остальные участники схемы не остановились

Таймаут в select — это способ прекратить ждать в текущем месте. Но таймаут не убивает другие goroutine автоматически. Если вы запустили goroutine, которая пытается отправить вам результат, а вы ушли по таймауту и больше не читаете — вы легко получаете утечку через блокирующую отправку.

Эта мысль напрямую следует из базового паттерна «timeouts + select»: мы «бросаем попытку чтения» при таймауте, но отправителю от этого ни тепло, ни холодно.

Схема того, что происходит в голове программы, часто такая:

flowchart TD
    A[main ждёт результат] --> B{select}
    B -->|case v := <-results| C[успех: получили v]
    B -->|case <-time.After| D[таймаут: ушли дальше]
    E[worker goroutine] --> F[results <- v]
    F -->|нет читателя| G[worker завис навсегда]

Если после таймаута вы не отменяете работу воркеров (через ctx.Done() или другой сигнал) и/или не обеспечиваете им неблокирующую отправку, то воркеры могут «застрять» на отправке результата.

select { default: ... } в цикле и busy loop

Иногда люди узнают про default и думают: «О! Я могу сделать цикл, который постоянно проверяет канал, но не блокируется». Можете. Это будет работать. И это будет жечь CPU как маленький реактивный двигатель.

Busy loop — это не всегда «утечка goroutine» в смысле «она ждёт вечно». Но это очень близкий родственник: goroutine не завершится и при этом будет постоянно крутиться, забивая одно ядро. В долгоживущем приложении эффект похож: ресурс утекает, только не память, а процессорное время.

Мини-антипример:

package main

func main() {
	ch := make(chan int)

	for {
		select {
		case <-ch:
			// что-то получили
		default:
			// ничего не готово — но мы не ждём, а крутимся
		}
	}
}

Если вам нужен «ждать события» — используйте блокирующие case без default. Если вам нужна «редкая проверка» — обычно добавляют блокирующий case (например, таймер) или пересматривают дизайн. В рамках сегодняшнего дня достаточно просто запомнить: default внутри быстрого for — красный флаг.

3. Правило завершения goroutine

Здесь мы фиксируем главный инструмент мышления на весь дальнейший конкурентный Go-код. Это не магическая техника, а дисциплина: прежде чем запускать goroutine, вы должны словами ответить на вопрос «когда она закончится?». Если ответа нет — вы уже на тропинке к утечке.

Само «правило завершения» удобно формулировать так:

У каждой запущенной goroutine должно быть хотя бы одно гарантированное условие выхода:
закрытие входного канала, отмена через ctx.Done(), или завершение работы без риска заблокироваться навсегда.

Чтобы было проще применять это не в теории, а в коде, держите в голове маленькую «табличку протоколов»:

Что делает goroutine На чём может заблокироваться Как она узнаёт «пора выходить» Кто обеспечивает сигнал
Consumer читает из in receive <-in in закрыт (ok == false) Producer (закрывает in)
Worker ждёт команды receive <-jobs jobs закрыт или ctx.Done() Координатор/владелец jobs или тот, кто отменяет ctx
Producer отправляет в out send out <- v читатель читает, или send неблокирующий, или ctx.Done() Consumer/буфер канала/контекст
Forwarder в fan-in читает+пишет receive и send закрытие входа или отмена контекста Producer входа или cancel

И да, это «контрактная» дисциплина. Конкурентный код вообще на 80% состоит из контрактов, и только на 20% — из синтаксиса.

4. Мини-кейс: «первый ответ выигрывает» без утечек

Чтобы примеры не висели в воздухе, будем считать, что у нас есть простое учебное приложение в стиле «менеджер задач»: у нас есть несколько источников задач, и мы хотим найти задачу по id как можно быстрее. Мы запускаем несколько goroutine, и нам нужен первый найденный результат.

Плохая версия: возвращаем первый результат, а остальные могут зависнуть

Сделаем упрощённый «поиск» через задержку:

package main

import (
	"time"
)

func findSlow(id int) int {
	time.Sleep(50 * time.Millisecond)
	return id
}

Теперь плохая схема «первый ответ выигрывает» (обратите внимание на out := make(chan int) без буфера):

package main

import "time"

func firstResult(id int) int {
	out := make(chan int) // unbuffered: риск зависаний

	go func() { out <- findSlow(id) }()
	go func() { out <- findSlow(id) }()

	select {
	case v := <-out:
		return v
	case <-time.After(10 * time.Millisecond):
		return -1
	}
}

Что не так: мы ушли по таймауту, перестали читать out, а отправители всё ещё попытаются сделать out <- ... и могут зависнуть. Ровно этот класс проблем и обсуждается в классических заметках про таймауты и «move on»: если вы прекращаете ждать, вы должны подумать о судьбе тех, кто ещё пытается вам что-то отправить.

Исправление: буфер на 1 и неблокирующая отправка

Самая компактная и практичная версия для «первый ответ выигрывает» — out := make(chan int, 1) и отправка через select с default. Этот подход в таком виде (неблокирующий send) часто приводят как эталонный.

package main

func firstResultSafe(id int) int {
	out := make(chan int, 1) // место ровно под “победителя”

	go func() {
		v := findSlow(id)
		select {
		case out <- v:
		default:
			// кто-то уже победил
		}
	}()

	go func() {
		v := findSlow(id)
		select {
		case out <- v:
		default:
		}
	}()

	return <-out
}

Здесь важный контракт: мы сознательно «теряем» результаты проигравших. Это нормально, потому что бизнес-идея функции — получить первый.

Исправление: отмена через context

Иногда «проигравшим» воркерам нельзя просто «молча закончить вычисление». Допустим, они делают тяжёлую работу или лезут во внешний ресурс. Тогда после того, как победитель найден, мы хотим сказать остальным: «стоп, не надо».

Это и есть смысл отмены через ctx.Done(): один сигнал на всех участников. В документации и статьях про context отдельно подчёркивают, что контекст важен, чтобы операции уважали отмену и дедлайны — иначе система копит работу и ресурсы.

Упростим: воркер будет «ждать» через таймер, но сможет выйти по отмене.

package main

import (
	"context"
	"time"
)

func findWithCancel(ctx context.Context, id int) (int, bool) {
	select {
	case <-time.After(50 * time.Millisecond):
		return id, true
	case <-ctx.Done():
		return 0, false
	}
}

Теперь «первый ответ выигрывает» с отменой:

package main

import "context"

func firstResultWithCancel(id int) int {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	out := make(chan int, 1)

	go func() {
		if v, ok := findWithCancel(ctx, id); ok {
			select { case out <- v: default: }
		}
	}()

	go func() {
		if v, ok := findWithCancel(ctx, id); ok {
			select { case out <- v: default: }
		}
	}()

	v := <-out
	cancel() // говорим остальным “заканчивайтесь”
	return v
}

Здесь у goroutine есть два пути выхода: «успех» и ctx.Done(). Это и есть реализация «правила завершения» на практике.

5. Как заподозрить утечку goroutine

Когда вы только учитесь, у вас не всегда есть удобная диагностика, а иногда вы просто пишете небольшую утилиту. Поэтому полезно иметь «детектор здравого смысла».

Если программа «висит» на завершении, и вы видите, что WaitGroup.Wait() не возвращается, это почти всегда означает: какая-то goroutine не дошла до Done(). Причина обычно в том, что она зависла на send/receive, либо ждёт range по каналу, который не закрывается.

Если программа со временем начинает «тормозить» или «пухнуть», особенно в цикле (например, вы много раз запускаете одну и ту же операцию), и вы подозреваете, что с каждым циклом остаётся что-то фоновое, то первая мысль должна быть: «а я точно всем goroutine даю завершиться?». Да, это звучит скучно. Конкурентность вообще дисциплина: веселье начинается позже, когда оно работает.

6. Типичные ошибки при работе с goroutine и каналами

Ошибка №1: «Я отправлю в канал, а там как-нибудь прочитают».
Это самая частая причина зависших goroutine: unbuffered send требует читателя прямо сейчас. Если читатель ушёл по таймауту, по ошибке, по return, по отмене контекста — отправитель повис. Лечится не «добавим буфер на 100500», а явным контрактом: либо гарантированный читатель, либо буфер достаточного размера, либо неблокирующая отправка (если потеря допустима), либо отмена через ctx.Done().

Ошибка №2: for range ch без чёткого «кто закрывает канал».
range по каналу — это договор «я читаю до закрытия». Если вы не можете быстро ответить, кто и где делает close(ch), то у вас нет договора, а есть надежда. Надежда — плохая стратегия синхронизации. Обычно закрывает producer, и закрывает ровно один раз, когда точно закончил писать.

Ошибка №3: Таймаут сделали, а отмену — нет.
Таймаут через time.After или timer.C прекращает ожидание в одном месте, но остальные участники схемы остаются жить. Если они после этого пытаются отправить результат в канал, который никто больше не читает, вы получаете утечку. Хороший паттерн: «ушли по таймауту → отменили контекст → все, кто слушает ctx.Done(), корректно вышли».

Ошибка №4: default в select внутри быстрого цикла, а затем удивление «почему грузит CPU».
default — это не «подожду чуть-чуть». Это «не ждать вообще». В цикле это превращается в постоянное кручение. Если вы действительно хотите ждать события — убирайте default и позволяйте select блокироваться. Если вам нужно периодически просыпаться — добавляйте блокирующий case (например, таймер) и чётко формулируйте, зачем.

Ошибка №5: Закрывают канал «не тем» участником или закрывают несколько раз.
Закрытие канала — ответственность владельца потока данных (обычно producer). Если consumer закрывает канал «чтобы всё завершилось», он почти наверняка однажды поймает панику «close of closed channel» или «send on closed channel» (когда producer ещё пишет). В схеме fan-in канал out закрывает координатор после того, как все forwarder’ы завершились, а не сами forwarder’ы — иначе закрытие будет гонкой.

1
Задача
Go SELF, 67 уровень, 4 лекция
Недоступна
Сумма без зависания
Сумма без зависания
1
Задача
Go SELF, 67 уровень, 4 лекция
Недоступна
Таймаут без утечки
Таймаут без утечки
1
Задача
Go SELF, 67 уровень, 4 лекция
Недоступна
Отправка без ожидания
Отправка без ожидания
1
Задача
Go SELF, 67 уровень, 4 лекция
Недоступна
Победитель и уборка
Победитель и уборка
1
Опрос
Конкурентность 3: select, 67 уровень, 4 лекция
Недоступен
Конкурентность 3: select
Конкурентность 3: select
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ