Язык Go — горутины: опасные паттерны и вопросы с собеседований
Утечки горутин, гонки данных, ловушки замыканий, панике в горутине, порядок выполнения — коварные случаи и типичные вопросы на Go-интервью.
Содержание
Запустить горутину — одно слово. Сделать это правильно — совсем другое. Здесь собраны паттерны, которые выглядят невинно, но ломают программу в production, и вопросы, которые регулярно встречаются на собеседованиях.
Что выведет эта программа?
Начнём с разминки. Классический вопрос на интервью:
func main() {
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Second)
}
Интуитивный ответ: 0 1 2. Реальный ответ: скорее всего 3 3 3, иногда 2 3 3 или другая комбинация. Гарантий нет вообще.
Почему? Все три горутины захватывают одну переменную i по ссылке. К моменту когда они начинают выполняться, цикл уже закончился — i равно 3.
Исправление:
for i := 0; i < 3; i++ {
i := i /* новая переменная i на каждой итерации,
объявлена как shadowing (новая переменная объявляется с
тем же названием, что и переменная во внешней области видимости) */
go func() {
fmt.Println(i) // захватывает свою локальную копию
}()
}
Или через аргумент:
for i := 0; i < 3; i++ {
go func(n int) {
fmt.Println(n)
}(i)
}
В Go 1.22+ семантика переменной цикла изменилась — на каждой итерации создаётся новая переменная, первый вариант работает правильно. Но на собеседовании это нужно проговорить явно.
Утечка горутин
Утечка горутины — горутина запущена, но никогда не завершится. Память и ресурсы заняты навсегда.
Случай 1: горутина заблокирована на чтении из канала, который никто не закроет
func leak() {
ch := make(chan int)
go func() {
val := <-ch // ждёт значения... вечно
fmt.Println(val)
}()
// ch никто не закрывает и не пишет в него
// горутина висит до конца жизни программы
}
Случай 2: горутина заблокирована на записи в канал, который никто не читает
func leak() {
ch := make(chan int) // небуферизованный канал
go func() {
ch <- 42 // блокируется, потому что никто не читает
}()
// возвращаемся из функции, не читая канал
// горутина висит навсегда
}
Случай 3: бесконечный цикл без возможности выхода
func leak(ctx context.Context) {
go func() {
for {
doWork()
time.Sleep(time.Second)
// нет проверки ctx.Done() — горутина не остановится
// даже после отмены контекста
}
}()
}
// правильно: проверяем контекст
func noLeak(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return // выходим когда контекст отменён
default:
doWork()
time.Sleep(time.Second)
}
}
}()
}
Для обнаружения утечек — пакет runtime:
before := runtime.NumGoroutine()
leak()
time.Sleep(100 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("горутин до: %d, после: %d\n", before, after)
// если after > before — утечка
В тестах удобно использовать goleak от Uber: goleak.VerifyNone(t).
Паника в горутине убивает всю программу
recover() перехватывает панику только в той же горутине. Если паника случилась в горутине — recover() в main её не поймает:
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("поймали в main:", r) // НЕ сработает
}
}()
go func() {
panic("паника в горутине") // убивает всю программу
}()
time.Sleep(time.Second)
}
Программа упадёт с panic: паника в горутине, несмотря на recover в main.
Правило: если горутина может запаниковать — recover должен быть внутри неё:
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("горутина упала: %v", r) // перехватываем здесь
}
}()
riskyWork()
}()
Гонка данных — самая коварная ошибка
Гонка данных (data race) — когда две горутины одновременно обращаются к одной переменной и хотя бы одна пишет. Результат непредсказуем.
var counter int
func main() {
for i := 0; i < 1000; i++ {
go func() {
counter++ // не атомарная операция: читай → прибавь → запиши
}()
}
time.Sleep(time.Second)
fmt.Println(counter) // почти никогда не будет 1000
}
counter++ выглядит как одна операция, но это три: прочитать значение, прибавить 1, записать обратно. Две горутины могут прочитать одно и то же значение и оба записать +1 — одно прибавление потеряется.
Запустить детектор гонок:
go run -race main.go
// или
go test -race ./...
Детектор найдёт гонку и покажет стектрейс. Всегда запускайте тесты с -race.
Три способа исправить:
// 1. мьютекс
var mu sync.Mutex
var counter int
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
// 2. атомарная операция — быстрее мьютекса для простых значений
var counter atomic.Int64
go func() {
counter.Add(1)
}()
// 3. канал — передаём право на изменение
ch := make(chan int, 1)
ch <- 0 // начальное значение
go func() {
val := <-ch
ch <- val + 1
}()
Порядок выполнения горутин не гарантирован
Вопрос с собеседования: что выведет программа?
func main() {
go fmt.Println("один")
go fmt.Println("два")
go fmt.Println("три")
time.Sleep(time.Second)
}
Правильный ответ: неизвестно. Порядок зависит от планировщика Go и загрузки системы. Может быть один два три, может три один два, может что угодно. Каждый запуск потенциально даёт разный результат.
Если порядок важен — нужна явная синхронизация через каналы или sync.WaitGroup.
WaitGroup: правильный способ ждать горутины
time.Sleep — плохой способ ждать горутины. sync.WaitGroup — правильный:
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // добавляем ДО запуска горутины, не внутри
go func(n int) {
defer wg.Done() // вызовется при выходе из горутины
fmt.Println(n)
}(i)
}
wg.Wait() // блокируемся пока счётчик не станет 0
fmt.Println("все горутины завершились")
Типичная ошибка — wg.Add(1) внутри горутины:
// НЕПРАВИЛЬНО: Add может вызваться после Wait
for i := 0; i < 5; i++ {
go func(n int) {
wg.Add(1) // может не успеть до wg.Wait()
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait() // может выйти раньше чем все горутины запустятся
Закрытие канала: кто должен закрывать
Правило: канал закрывает отправитель, не получатель. Закрытие канала со стороны получателя или закрытие уже закрытого канала — паника:
ch := make(chan int)
close(ch)
close(ch) // panic: close of closed channel
ch <- 1 // panic: send on closed channel
Паттерн «один отправитель — закрывает сам»:
func producer(ch chan<- int) {
defer close(ch) // гарантируем закрытие при выходе
for i := 0; i < 5; i++ {
ch <- i
}
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch { // range завершится когда канал закрыт
fmt.Println(v)
}
}
Паттерн «несколько отправителей — используем sync.WaitGroup»:
func main() {
ch := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
ch <- n
}(i)
}
// отдельная горутина закрывает канал когда все отправители завершились
go func() {
wg.Wait()
close(ch) // безопасно: все отправители уже закончили
}()
for v := range ch {
fmt.Println(v)
}
}
Горутина не видит изменений без синхронизации
Вопрос с собеседования: завершится ли эта программа?
var done bool
func main() {
go func() {
time.Sleep(time.Millisecond)
done = true
}()
for !done {
// крутимся в ожидании
}
fmt.Println("готово")
}
Ответ: не обязательно. Компилятор и CPU могут кешировать done в регистре — горутина в for никогда не увидит обновление. Это гонка данных и неопределённое поведение.
Правильное решение — атомарная операция или канал:
// через атомик
var done atomic.Bool
go func() {
time.Sleep(time.Millisecond)
done.Store(true)
}()
for !done.Load() {}
fmt.Println("готово")
// через канал — идиоматично в Go
done := make(chan struct{})
go func() {
time.Sleep(time.Millisecond)
close(done) // закрытие как сигнал
}()
<-done // блокируемся до закрытия
fmt.Println("готово")
select с default: неблокирующий приём
Вопрос: что выведет код?
ch := make(chan int, 1)
ch <- 42
select {
case v := <-ch:
fmt.Println("получили:", v)
case v := <-ch:
fmt.Println("второй case:", v) // достижим?
default:
fmt.Println("ничего нет")
}
Ответ: получили: 42. Если несколько case готовы одновременно — select выбирает случайный. Но default выполняется только если ни один case не готов. Второй case с тем же каналом — это не ошибка, но бессмысленно: после первого чтения канал пуст.
Итоги
- Замыкание в цикле захватывает переменную по ссылке — к моменту выполнения горутины значение уже изменилось. Передавайте как аргумент или делайте
i := i - Утечка горутины — горутина заблокирована на канале или в бесконечном цикле без выхода. Проверяйте через
runtime.NumGoroutine()илиgoleak recover()работает только в той же горутине где произошла паника. Оборачивайте рискованный код внутри каждой горутины- Гонка данных — самая коварная ошибка конкурентности. Запускайте тесты с флагом
-race - Порядок выполнения горутин не гарантирован. Нужна явная синхронизация
wg.Add(1)вызывайте до запуска горутины, не внутри неё- Канал закрывает отправитель. Закрытие закрытого канала или отправка в закрытый — паника
- Простая переменная без синхронизации не гарантирует видимость изменений между горутинами