GraphLMS

Go
0/32 решеноНачать
Глава 01 · Основы~12 мин чтения

Горутины и планировщик

О чём глава

Горутина — это та самая «лёгкая нить», ради которой Go и затевался. Слово go перед вызовом — и функция начинает работать рядом с вашим кодом. Звучит просто, но за этим стоит целый рантайм: свой стек, своя структура состояния и планировщик, который раскладывает десятки тысяч горутин по горстке потоков ОС.

Здесь разберёмся, что именно создаёт go, почему горутины настолько дешевле потоков, как устроена модель G-M-P, и — самое практичное — что происходит, когда горутина блокируется на канале или уходит в системный вызов. Это фундамент: на нём держатся каналы, select, context и всё остальное в курсе.

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

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

go doWork()           // запустили и сразу пошли дальше
go func() {           // или анонимно, с замыканием
    fmt.Println("hi")
}()

Ключевое свойство: go не блокирует. Вызывающий код продолжается немедленно, а новая горутина просто встаёт в очередь планировщика и будет исполнена когда-то потом. Никаких гарантий, что она стартует прямо сейчас, нет.

Важно не путать горутину с потоком ОС. Горутина — сущность рантайма Go: у неё есть структура g (состояние), собственный стек и счётчик инструкций. Потоки ОС тоже есть, но их немного, и рантайм сам мультиплексирует на них горутины.

Чтобы увидеть, что горутина действительно отдельная единица исполнения, дождёмся её через канал (иначе main может завершиться раньше, и вывод не появится):

package main
 
import "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 main
 
import "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)
}

Модель G-M-P

Планировщик 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 main
 
import "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 main
 
import (
	"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 main
 
import (
	"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 main
 
import (
	"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 int
var wg sync.WaitGroup
for 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, — «кто и когда остановит эту горутину?». Если внятного ответа нет — перед вами потенциальная утечка. Этот вопрос будет красной нитью всего курса.

Что дальше

  • Каналы — основной способ горутинам общаться и синхронизироваться.
  • select — ждать сразу нескольких каналов, ставить таймауты и отмену.
  • sync-примитивыWaitGroup, Mutex, atomic, когда каналов недостаточно.
  • context — штатный механизм отмены и дедлайнов для горутин.
  • планировщик глубже — work-stealing, вытеснение, сетевой поллер.

Как это связано с задачами тренажёра

Горутины — фундамент всего тренажёра. В топике 1 (задачи 1–4) вы запускаете горутины вокруг каналов и select и обязаны гарантировать их завершение (задача 01 «Or-Channel» — про отсутствие утечки при композиции). Топик 4 (13–15) и топик 6 (21–22) напрямую про управление жизненным циклом горутин через context и про утечки и гонки. Везде держите в голове правило «у каждой go — свой stop».