· 12 мин 👁 1.5k Начинающий

Язык Go — конкурентность: горутины

Горутины в Go — что это, как запустить, замыкания в цикле и почему без каналов не обойтись.

горутиныконкурентностьканалыgoCSP
Содержание

Что такое горутина

Горутина — это функция, которая запускается и работает параллельно с остальным кодом. Не вместо, а вместе.

Запустить горутину просто — пишешь go перед вызовом функции:

func main() {
    go fmt.Println("я работаю параллельно")
    fmt.Println("я работаю в main")
}

go говорит: «запусти это, но не жди — иди дальше». Как отправить SMS и не ждать ответа, а заняться другим.

Горутины дешевле потоков

В других языках параллельный код запускают в потоках (threads). Поток — тяжёлая штука: операционная система выделяет ему ~2 МБ памяти на стек. Создать тысячу потоков — уже проблема.

Горутина начинается с ~4 КБ. Это в 500 раз меньше. Стек растёт сам по мере необходимости. Можно запустить сто тысяч горутин — программа не упадёт.

// запускаем 100 000 горутин — это нормально в Go
for i := 0; i < 100_000; i++ {
    go doWork(i)
}

Рантайм Go сам решает, на каком потоке OS запустить каждую горутину. Если одна горутина ждёт ответа от сервера — рантайм не простаивает, а переключается на другую.

Проблема: main не ждёт горутины

Вот ловушка, в которую попадают все новички:

func main() {
    go fmt.Println("привет из горутины")
    // main заканчивается раньше, чем горутина успевает запуститься
    // программа завершается — горутина никогда ничего не напечатает
}

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

Временное (плохое) решение — усыпить main:

func main() {
    go fmt.Println("привет из горутины")
    time.Sleep(time.Second) // ждём секунду — горутина успеет отработать
}
// привет из горутины

Это работает, но в реальном коде так не делают — никогда не знаешь, сколько ждать. Правильное решение — каналы. Но сначала разберём ещё пару важных моментов.

Горутина с анонимной функцией

Горутину можно запустить прямо с анонимной функцией — это удобно когда нужно запустить небольшой кусок кода:

func Announce(message string, delay time.Duration) {
    go func() {          // анонимная функция
        time.Sleep(delay)
        fmt.Println(message)
    }()                  // скобки в конце обязательны — функцию нужно вызвать
}

message и delay здесь захвачены из внешней функции — анонимная функция их «видит» и запоминает. Это называется замыкание.

Ловушка с замыканием в цикле

Это одна из самых частых ошибок в Go. Смотри внимательно:

// НЕПРАВИЛЬНО
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // какой i? тот, что снаружи!
    }()
}
// скорее всего напечатает: 5 5 5 5 5

Почему 5 пять раз? Горутины запускаются, но не сразу выполняются. К тому моменту как они добираются до fmt.Println(i) — цикл уже закончился и i стало равно 5. Все пять горутин смотрят на одну и ту же переменную i.

Исправление — передать i как аргумент. Тогда каждая горутина получает свою копию значения:

// ПРАВИЛЬНО
for i := 0; i < 5; i++ {
    go func(n int) { // n — это копия i на момент запуска
        fmt.Println(n)
    }(i) // передаём i как аргумент прямо сейчас
}
// напечатает: 0 1 2 3 4 (в каком-то порядке)

В Go 1.22+ переменная цикла создаётся заново на каждой итерации — ловушки нет:

// Go 1.22+: i своя на каждой итерации, ловушки нет
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)
    }()
}

Главный принцип Go: не делись памятью — передавай данные

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

Go предлагает другую идею: не давай двум горутинам трогать одни данные. Вместо этого — передавай данные от одной горутины к другой через каналы. Только одна горутина держит данные в каждый момент.

Как в эстафете: пока ты держишь палочку — она твоя. Передал — она уже не твоя.

Не общайся через общую память.
Общайся через передачу данных.

Для этого в Go есть каналы — следующая тема.

Итоги

  • Горутина — функция, которая работает параллельно. Запускается через go f()
  • Горутины в 500 раз легче потоков OS — можно создавать тысячи без проблем
  • Когда main завершается — все горутины убиваются. Нужно явно ждать их завершения
  • В анонимной функции-горутине переменная цикла захватывается по ссылке — передавай как аргумент
  • Главный принцип: не делись памятью между горутинами — передавай данные через каналы