· 15 мин 👁 1.6k Средний

Язык 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) вызывайте до запуска горутины, не внутри неё
  • Канал закрывает отправитель. Закрытие закрытого канала или отправка в закрытый — паника
  • Простая переменная без синхронизации не гарантирует видимость изменений между горутинами