Горутина — это та самая «лёгкая нить», ради которой Go и затевался. Слово go
перед вызовом — и функция начинает работать рядом с вашим кодом. Звучит просто,
но за этим стоит целый рантайм: свой стек, своя структура состояния и
планировщик, который раскладывает десятки тысяч горутин по горстке потоков ОС.
Здесь разберёмся, что именно создаёт go, почему горутины настолько дешевле
потоков, как устроена модель G-M-P, и — самое практичное — что происходит,
когда горутина блокируется на канале или уходит в системный вызов. Это
фундамент: на нём держатся каналы, select, context и всё остальное в курсе.
Горутина — это функция, которая выполняется конкурентно с остальными в том же
адресном пространстве. Запускается одним словом:
go doWork() // запустили и сразу пошли дальшеgo func() { // или анонимно, с замыканием fmt.Println("hi")}()
Ключевое свойство: goне блокирует. Вызывающий код продолжается немедленно,
а новая горутина просто встаёт в очередь планировщика и будет исполнена когда-то
потом. Никаких гарантий, что она стартует прямо сейчас, нет.
Важно не путать горутину с потоком ОС. Горутина — сущность рантайма Go: у неё
есть структура g (состояние), собственный стек и счётчик инструкций. Потоки ОС
тоже есть, но их немного, и рантайм сам мультиплексирует на них горутины.
Чтобы увидеть, что горутина действительно отдельная единица исполнения, дождёмся
её через канал (иначе main может завершиться раньше, и вывод не появится):
package mainimport "fmt"func main() { done := make(chan string) go func() { done <- "горутина отработала" }() fmt.Println("main ждёт...") fmt.Println(<-done) // блокируемся, пока горутина не пришлёт результат}
Сравним с потоком ОС. Поток стартует с фиксированным стеком — часто 1–8 МБ, — и
каждое переключение между потоками проходит через ядро. Это дорого и по памяти, и
по времени.
Горутина стартует со стеком всего ~2 КБ. Причём этот стек не фиксированный: он
растёт и сжимается по мере надобности. Когда места не хватает, рантайм выделяет
буфер побольше и копирует туда стек целиком (stack growth/copy), правя
указатели. Поэтому сотня тысяч спящих горутин — это сотни мегабайт, а не терабайты.
Второе: переключение между горутинами происходит в user space, без
системного вызова. Рантайм сам решает, какую горутину поставить на поток, и это
стоит десятки наносекунд против микросекунд у потоков ОС.
Практический вывод: в Go нормально иметь горутину на соединение, на запрос, на
единицу работы — это идиоматично. Но «дёшево» не значит «бесплатно». Каждая
горутина держит стек и может удерживать ссылки на объекты, мешая сборщику мусора
их освободить. Горутина, которая никогда не завершится, — это
утечка.
Запустим много горутин и убедимся, что это обычное дело. Считаем сумму через
буферизованный канал, чтобы каждая горутина могла отправить результат не блокируясь:
package mainimport "fmt"func main() { const n = 1000 results := make(chan int, n) for i := 1; i <= n; i++ { go func(x int) { results <- x }(i) } sum := 0 for i := 0; i < n; i++ { sum += <-results // собираем ровно n результатов } fmt.Println("сумма 1..1000 =", sum)}
Планировщик Go построен на трёх сущностях. Понять их — значит понять, почему
горутины так хорошо масштабируются.
G (goroutine) — единица работы: стек, инструкции, текущее состояние.
M (machine) — поток ОС, на котором код реально исполняется.
P (processor) — логический контекст планирования: локальная очередь готовых
G плюс ресурсы для их исполнения. Число P равно GOMAXPROCS (по умолчанию —
число ядер).
Главное правило: чтобы исполнять Go-код, M обязан держать P. Схема работы такая —
M берёт P, достаёт из его локальной runqueue очередную G и исполняет её.
Когда G блокируется (скажем, на канале), M возвращает её в очередь ожидания и
берёт из P следующую готовую горутину. Поток ни секунды не простаивает зря.
runtime.GOMAXPROCS(4) // иллюстрация; runtime в тренажёре недоступен — не более 4 горутин исполняются по-настоящему параллельно
Отсюда важное различие. Параллелизм ограничен числом P (а значит, числом
ядер): по-настоящему одновременно работают столько горутин, сколько есть P.
Конкурентность — это число G, и оно может быть огромным. Конкурентность — про
структуру программы (много независимых задач, которые продвигаются вперемешку);
параллелизм — про их физически одновременное исполнение на разных ядрах. Можно
иметь конкурентную программу без параллелизма (один P) — горутины всё равно будут
честно чередоваться.
Подробнее про work-stealing (когда P с пустой очередью «крадёт» горутины у
соседа) и про вытеснение долго работающих G — в главе
планировщик глубже.
Это самая важная часть для интуиции. Когда горутина встаёт на <-ch,
mu.Lock(), time.Sleep или другой примитив рантайма, поток ОС не
блокируется. Рантайм снимает горутину с M (это называется «паркинг») и тут же
ставит на этот M другую готовую G. Один поток продолжает обслуживать тысячи
горутин, по очереди.
package mainimport "fmt"func main() { ch := make(chan int) go func() { ch <- 42 // эта горутина паркуется здесь, пока никто не читает }() v := <-ch // а здесь паркуется main, пока нет отправителя; затем встречаются fmt.Println("получили:", v)}
Обе стороны небуферизованного канала «встречаются»: отправитель ждёт получателя,
получатель — отправителя, и обмен происходит ровно в точке встречи. Пока они ждут,
поток обслуживает другие горутины (или просто спит, если их нет).
А вот с блокирующими системными вызовами иначе. Когда горутина делает syscall
(чтение файла, сетевой вызов в обход сетевого поллера, и т.п.), M «уходит в ядро»
вместе с этой G. Чтобы остальные горутины не застряли, рантайм отвязывает P от
заблокированного M и отдаёт его другому M — создавая при необходимости новый
поток. Когда syscall вернётся, G снова встанет в очередь готовых. Поэтому даже
блокирующий ввод-вывод не парализует всю программу.
Покажем паркинг наглядно через select с таймером — горутина-отправитель
«думает», а получатель ждёт, не сжигая поток:
package mainimport ( "fmt" "time")func main() { ch := make(chan string) go func() { time.Sleep(10 * time.Millisecond) // «работа»; горутина припаркована ch <- "готово" }() select { case res := <-ch: fmt.Println("результат:", res) case <-time.After(time.Second): fmt.Println("таймаут") }}
Захват переменной цикла (до Go 1.22). Классическая ловушка замыканий:
for i := 0; i < 3; i++ { go func() { fmt.Println(i) }() // до Go 1.22 печатает 3,3,3}
До Go 1.22 переменная i была одна на весь цикл, и все горутины видели её
финальное значение — обычно 3. Лечилось копией внутри тела: i := i. Начиная с
Go 1.22 переменная цикла создаётся заново на каждой итерации, и баг исчез. Но
привычка передавать значение явно (как аргумент горутине) полезна: код часто
читают и собирают на старых версиях. Надёжный способ — передать параметром:
package mainimport ( "fmt" "sort" "sync")func main() { var wg sync.WaitGroup out := make([]int, 0, 3) var mu sync.Mutex for i := 0; i < 3; i++ { wg.Add(1) go func(i int) { // i передан аргументом — каждая горутина видит своё defer wg.Done() mu.Lock() out = append(out, i) mu.Unlock() }(i) } wg.Wait() sort.Ints(out) // порядок завершения горутин недетерминирован — сортируем fmt.Println(out)}
go без ожидания завершения. Если main вернётся, программа умрёт, не дав
горутинам доделать работу:
go save(data) // main выходит раньше, save может не успеть
Нужна явная синхронизация — sync.WaitGroup, канал или
context. Никогда не полагайтесь на time.Sleep «чтобы успело».
Паника в горутине роняет весь процесс.recover действует только в своей
горутине; непойманная паника в любой горутине завершает всю программу — её не
поймать снаружи. Если запускаете в горутине код, который может паниковать,
ставьте defer/recoverвнутри неё:
package mainimport ( "fmt" "sync")func main() { var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() defer func() { if r := recover(); r != nil { fmt.Println("поймали панику:", r) } }() panic("что-то сломалось") }() wg.Wait() fmt.Println("main жив, процесс не упал")}
Думать, что go запускает немедленно. Запуск только ставит горутину в
очередь. Если вам нужно, чтобы она точно начала или закончила, — синхронизируйтесь
явно, не надейтесь на порядок.
Важная оговорка про этот тренажёр: примеры выше исполняются интерпретатором в
браузере, и настоящего параллелизма там нет — горутины чередуются на одном
потоке. Этого достаточно, чтобы прочувствовать паркинг и композицию, но не
race-детектор и не одновременное исполнение на ядрах.
Поэтому гонку показываем статичным блоком, без запуска. Вот классический пример:
две горутины инкрементируют общий счётчик без синхронизации.
// data race: НЕ запускать так в реальном кодеvar counter intvar wg sync.WaitGroupfor i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 100000; j++ { counter++ // чтение-модификация-запись без защиты — гонка } }()}wg.Wait()fmt.Println(counter) // на реальном железе почти никогда не 200000
counter++ — это не одна операция, а три: прочитать, прибавить, записать. На двух
ядрах эти тройки переплетаются, и часть инкрементов теряется. Запустите такое
локально с go run -race, и детектор покажет гонку. Лечится
мьютексом или sync/atomic, либо — идиоматичнее —
передачей счётчика через канал. Подробный разбор — в главе
утечки и гонки.
Держите в голове образ «зелёных потоков», которые рантайм мультиплексирует на
P-слоты. Запуск дёшев, переключение дёшево, блокировка на примитивах Go не съедает
поток ОС. Но у каждой горутины есть владелец, обязанный её завершить.
Самый полезный вопрос, который стоит задавать себе при каждом go, —
«кто и когда остановит эту горутину?». Если внятного ответа нет — перед вами
потенциальная утечка. Этот вопрос будет красной нитью всего курса.
Горутины — фундамент всего тренажёра. В топике 1 (задачи 1–4) вы запускаете
горутины вокруг каналов и select и обязаны
гарантировать их завершение (задача 01 «Or-Channel» — про отсутствие утечки при
композиции). Топик 4 (13–15) и топик 6 (21–22) напрямую про управление
жизненным циклом горутин через context и про
утечки и гонки. Везде держите в голове правило
«у каждой go — свой stop».