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

О лекции

Горутина — дешёвая единица работы со своим стеком, не поток ОС. Планировщик связывает G (горутина), M (поток) и P (очередь исполнения).

Конкурентность Go golang собеседование

Вызов go f() запускает функцию рядом с текущей. Управление почти сразу возвращается. Это горутина. Она стартует с маленьким стеком (килобайты, не мегабайты потока) и растёт по мере нужды.

На I/O-сервере тысячи горутин — норма. Один процесс Go держит несколько потоков ОС и крутит на них много горутин. Пока горутина ждёт сеть, её паркуют, и поток может исполнять другую. Миллионы горутин — уже вопрос утечек, стека и дескрипторов, не «язык так не умеет».

Аналогия: много талонов в очереди (G), несколько кассиров (M), несколько окошек (P). Кассир без окошка не обслуживает. Окошек мало. Талонов может быть тысячи.

G, M и P

G — горутина: стек, состояние, указатель в код. Готовые к работе G стоят в очередях. M — поток ОС, который умеет исполнять Go-код. P — логический ресурс: своя очередь и кэш аллокатора. Чтобы исполнять Go, M должен держать P. GOMAXPROCS задаёт число P. Это сколько потоков одновременно крутят Go-код. Это не лимит на число горутин и не лимит на потоки, застрявшие в syscall.

func handle(ctx context.Context, wg *sync.WaitGroup, in <-chan Job) {
	wg.Add(1)
	go func() {
		defer wg.Done()
		select {
		case <-ctx.Done():
			return
		case job := <-in:
			job.Run()
		}
	}()
}

G исполняется на M, пока у M есть P. Запуск горутины с отменой и ожиданием.

Пример. HTTP-хендлер делает go saveLog(entry) и сразу отвечает. Под нагрузкой накопились сотни тысяч G, которые ждут медленный диск. GOMAXPROCS тут ни при чём: лимит P не ограничивает число горутин.

Если у P кончилась очередь, он берёт работу у другого P. Это work stealing. Системный вызов: M блокируется в ядре, P могут отдать другому M, чтобы Go-код не встал всем процессом. Блокирующий Read файла через syscall занимает поток. Read сокета через netpoller паркует только G.

Утечки и паника

Горутина, навсегда заблокированная на канале, мьютексе или select, живёт вместе со стеком и замыканием. «По горутине на запрос» без таймаута и без ограничения параллелизма роняет сервис под всплеском. Кто остановит работу: context. Кто дождётся конца: WaitGroup или закрытый канал.

recover ловит панику только в той же горутине и только из defer. Паника в дочерней G завершает процесс, если её там не поймали. Паника не перелетает в main сама.

Пример. Захват переменной цикла: до Go 1.22 все горутины могли увидеть последний i. Сейчас у каждой итерации своя переменная. Явный параметр go func(i int) { ... }(i) всё равно яснее и совместим со старым кодом.

Типичная ошибка

Сказать «горутина это поток» или что GOMAXPROCS ограничивает число горутин. GOMAXPROCS — про число P. Вторая ошибка — запустить G и не определить, кто её отменит и кто дождётся: нет context, нет WaitGroup, канал никто не закроет.

На собеседовании

G — дешёвые задачи, M — потоки ОС, P — очереди, их число задаёт GOMAXPROCS. Горутина ≠ поток. Сразу: кто остановит (context) и кто подождёт (WaitGroup). Паника не перелетает в другую горутину.