Горутины: лёгкие потоки Go

Разбираем вопрос, с которого почти всегда начинается Go-секция интервью: что такое горутина и чем она отличается от обычного потока.

Горутина (goroutine) — это лёгкая функция, которую планировщик Go выполняет параллельно с остальной программой. Горутины живут внутри одного или нескольких потоков ОС, но сами потоками не являются — их создание почти ничего не стоит.

Вопрос: чем горутина отличается от потока операционной системы?

Чёткий ответ. Поток ОС — это дорогая штука: под него ядро выделяет память под стек (обычно 1–8 мегабайт) и тратит время на переключение контекста. Горутина — это структура внутри самого Go-рантайма. Стартовый стек горутины — всего около 2 килобайт, и он растёт по мере необходимости. Поэтому в одной программе можно спокойно запустить сотни тысяч горутин, а сопоставимое число потоков ОС положит любую систему.

За горутинами следит планировщик Go (модель "M:N"): он сам решает, на каком из небольшого числа реальных потоков ОС в данный момент выполнить ту или иную горутину, и умеет переключаться между ними, когда одна из них ждёт, например, ответа от сети. Разработчику эту логику писать не нужно — рантайм берёт её на себя.

Как запустить горутину

Синтаксис нарочно предельно простой: перед вызовом функции ставится ключевое слово go. Функция начинает выполняться "в фоне", а строка кода сразу после неё продолжает работать, не дожидаясь её завершения.

package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("Привет из горутины")
}

func main() {
	go sayHello()               // запустили и не ждём
	fmt.Println("Привет из main")
	time.Sleep(100 * time.Millisecond) // даём горутине шанс отработать
}

Вывод:

Привет из main
Привет из горутины

Обратите внимание на порядок строк: он не гарантирован. main и горутина выполняются независимо, поэтому "Привет из main" вполне может напечататься и до, и после "Привет из горутины" — это зависит от того, как планировщик распределит время между ними. На собеседовании это частый вопрос-ловушка: неопытный кандидат отвечает "выведется в таком порядке, как написано в коде", хотя гарантий порядка тут нет.

А вот time.Sleep в примере — это костыль исключительно для демонстрации, в реальном коде так синхронизацию не делают: программа может завершиться раньше, чем горутина успеет отработать, потому что main не ждёт горутины сама по себе. Как ждать по-настоящему — разберём в следующем уроке (там появится sync.WaitGroup).

Классическая ловушка: захват переменной цикла

Это едва ли не самый частый вопрос про горутины на собеседовании. Представьте, что вы запускаете несколько горутин в цикле, и каждая должна напечатать "свой" номер итерации.

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 3; i++ {
		go func() {
			fmt.Println(i) // ЛОВУШКА: берём i из внешней области
		}()
	}
	time.Sleep(100 * time.Millisecond)
}

Вывод (в старых версиях Go, до 1.22):

3
3
3

Почему так? Все три горутины замыкают одну и ту же переменную i, а не её значение на момент запуска. Пока горутины реально доберутся до fmt.Println, цикл в main уже успевает досчитать до конца, и i становится равна 3 для всех трёх горутин сразу — вот почему все они печатают одно и то же число.

Есть два способа это починить:

for i := 0; i < 3; i++ {
	i := i // делаем локальную копию на каждой итерации
	go func() {
		fmt.Println(i)
	}()
}

// или передать значение параметром — так даже нагляднее
for i := 0; i < 3; i++ {
	go func(n int) {
		fmt.Println(n)
	}(i)
}

Вывод (порядок строк может отличаться, но значения всегда 0, 1, 2):

0
1
2

Важная деталь для собеседования: начиная с Go 1.22 язык изменил семантику — теперь у каждой итерации цикла своя собственная переменная i, и старая ловушка сама по себе исчезает. Но вопрос про неё всё равно продолжают задавать: важно понимать саму механику захвата переменных по ссылке, а не просто помнить "в новых версиях уже пофиксили".

Частые ошибки на собеседовании

  • Путают горутину с потоком ОС и говорят, что она "тяжёлая" — на деле горутины стартуют с крошечным стеком.
  • Считают, что go func() гарантирует какой-то порядок выполнения относительно остального кода — не гарантирует.
  • Не могут объяснить ловушку захвата переменной цикла или не знают, что в Go 1.22+ поведение изменилось.
  • Используют time.Sleep как "решение" проблемы ожидания горутин в реальном коде — это не синхронизация, а угадывание времени.

Итог

  • Горутина — лёгкая единица параллелизма Go, управляемая рантаймом, а не ОС.
  • Запускается ключевым словом go перед вызовом функции; выполнение продолжается сразу же, не дожидаясь горутины.
  • Порядок выполнения горутин не гарантирован.
  • Переменная цикла, захваченная замыканием, до Go 1.22 была общей на все итерации — классическая ловушка на собеседовании.
Проверьте себя
1. Чем горутина принципиально отличается от потока операционной системы?
AНичем, это два названия одного и того же
BГорутина стартует с маленьким растущим стеком и управляется рантаймом Go, а не ядром ОС
CГорутина работает только на одном ядре процессора
DГорутину нельзя остановить после запуска
2. Что напечатает следующий код в Go до версии 1.22: for i := 0; i < 3; i++ { go func() { fmt.Println(i) }() } (с последующим ожиданием)?
A0, 1, 2 в любом порядке
BСкорее всего 3, 3, 3 — все горутины видят одну общую переменную i
CПрограмма не скомпилируется
D1, 2, 3 в любом порядке
3. Что делает конструкция go someFunc() ?
AОстанавливает выполнение main до завершения someFunc
BЗапускает someFunc как горутину и сразу продолжает выполнение дальше, не дожидаясь её
CКомпилирует someFunc в отдельный бинарник
DГарантирует, что someFunc выполнится раньше следующей строки