Язык Go — конкурентность: горутины
Горутины в Go — что это, как запустить, замыкания в цикле и почему без каналов не обойтись.
Содержание
Что такое горутина
Горутина — это функция, которая запускается и работает параллельно с остальным кодом. Не вместо, а вместе.
Запустить горутину просто — пишешь 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завершается — все горутины убиваются. Нужно явно ждать их завершения - В анонимной функции-горутине переменная цикла захватывается по ссылке — передавай как аргумент
- Главный принцип: не делись памятью между горутинами — передавай данные через каналы