GraphLMS

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

Планировщик глубже

О чём глава

В главе горутины мы договорились о модели G-M-P: горутины (G) — это единицы работы, потоки ОС (M) — те, кто реально крутит код на железе, а логические процессоры (P) — «слоты-разрешения», без которых M не имеет права исполнять Go-код. P ровно столько, сколько настроено через GOMAXPROCS.

Здесь мы спустимся на уровень ниже и разберём, как из этой тройки получается работающий планировщик: откуда берётся параллелизм, как горутины перетекают между процессорами, почему тугой цикл без вызовов когда-то мог заморозить всю программу, и почему под нагрузкой потоков ОС иногда больше, чем GOMAXPROCS. Управлять этим вручную почти никогда не нужно — но понимание «почему так» отличает инженера, который чинит конкурентные баги, от того, кто их боится.

Важная честная оговорка: настоящего параллелизма и race-детектора в браузере нет. Кооперацию горутин через каналы и select мы можем запустить по-настоящему, а вот примеры с пакетом runtime (Gosched, GOMAXPROCS) и с гонками данных оставляем статичными — браузерный yaegi не подключает runtime и не ловит гонки, поэтому их нужно гонять у себя с go run и go run -race.

GOMAXPROCS: сколько горутин бегут параллельно

GOMAXPROCS — это число P, то есть максимальное число горутин, которые исполняют Go-код по-настоящему одновременно. По умолчанию равно числу логических CPU, которые видит рантайм.

Ключевая интуиция: горутин могут быть миллионы, но в каждый момент времени Go-код крутят максимум GOMAXPROCS из них. Остальные либо ждут своей очереди в runqueue, либо припаркованы (спят на канале, мьютексе, таймере, сетевом I/O).

n := runtime.GOMAXPROCS(0) // 0 = только прочитать текущее значение
runtime.GOMAXPROCS(4)      // ограничить программу четырьмя P
fmt.Println("логических CPU:", runtime.NumCPU(), "P:", n)

Прод-деталь, на которой обжигались целые команды: в контейнере с CPU-лимитом рантайм исторически видел все ядра хоста, а не выделенную квоту. Машина на 64 ядра, контейнеру выдан 1 CPU — а GOMAXPROCS всё равно 64. Результат: рантайм заводит кучу P, операционная система душит контейнер троттлингом, латентность скачет. Долго лечили библиотекой automaxprocs от Uber (она читает cgroup-квоту и выставляет вменяемое значение); начиная с Go 1.25 рантайм по умолчанию выставляет GOMAXPROCS по cgroup-квоте. Но привычка проверить GOMAXPROCS в проде остаётся здравой — особенно если собираете на более старой версии.

Runqueue: локальные и глобальные очереди

Откуда планировщик берёт следующую горутину? Из очередей готовых к запуску G.

У каждого P есть своя локальная очередь (runqueue) — кольцевой буфер на 256 горутин. Когда горутина становится готовой (например, текущая G породила новую через go f()), она обычно попадает в локальную очередь того же P. Это дёшево (не нужны глобальные блокировки) и кэш-дружелюбно: связанные горутины скорее всего работают с тёплыми для этого ядра данными — это и называют locality.

Есть и одна глобальная очередь на весь рантайм. В неё попадают «лишние» горутины при переполнении локальной, а также те, что стали готовы «издалека» (не на том P, который их породил). Глобальная очередь требует блокировки, поэтому она дороже — её используют как запасной резервуар.

Чтобы горутины из глобальной очереди не голодали в пользу бесконечно пополняемой локальной, P периодически (примерно раз в 61-й тик планировщика) специально заглядывает в глобальную очередь, даже если в локальной есть работа. Это маленький, но важный приём против голодания (starvation).

Work-stealing: балансировка без диспетчера

Нагрузка между горутинами обычно неравномерна: один P может разгрести свою очередь, пока у соседа завал. Центрального диспетчера, который бы это перераспределял, в Go нет — вместо него работает work-stealing («кража работы»).

Когда P остаётся без работы (своя локальная очередь пуста), он не сразу засыпает, а пытается её добыть в таком порядке:

  1. заглянуть в глобальную очередь;
  2. проверить network poller на готовые сетевые горутины;
  3. украсть половину горутин у случайно выбранного другого P.

Кража именно половины — разумный компромисс: украсть слишком мало — придётся красть снова и снова; украсть всё — обездолишь жертву. Так система сама себя балансирует: занятые P отдают излишки, простаивающие — подбирают.

Понаблюдать само планирование помогает runtime.Gosched() — добровольная уступка P другим горутинам. Этот пример обращается к пакету runtime, которого нет в браузерном yaegi, поэтому он только для разбора — запускайте его у себя:

package main
 
import (
	"fmt"
	"runtime"
	"sync"
)
 
func main() {
	var wg sync.WaitGroup
	for id := 1; id <= 3; id++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for step := 0; step < 2; step++ {
				fmt.Printf("горутина %d, шаг %d\n", id, step)
				runtime.Gosched() // добровольно уступаем P другим
			}
		}(id)
	}
	wg.Wait()
	fmt.Println("все горутины завершились")
}

Здесь нет настоящего параллелизма — горутины крутятся по очереди, — но видно главное: Gosched() возвращает горутину в очередь и даёт шанс другим, а WaitGroup гарантирует, что main дождётся всех и вывод напечатается.

Вытеснение (preemption): как отобрать P у жадной горутины

Раньше Go планировал горутины кооперативно: горутина уступала P только в заранее известных точках планирования — на вызовах функций (рантайм встраивал проверку в пролог функции), операциях с каналами, аллокациях, явном runtime.Gosched(). Пока горутина проходила через такие точки, всё было хорошо.

Проблема — в коде без точек планирования. Тугой цикл, который только считает и не зовёт функций, мог захватить P и не отпускать его:

// До Go 1.14 это могло намертво занять P:
for {
	x++ // нет вызовов функций -> нет точки вытеснения
}
// на GOMAXPROCS=1 другие горутины просто не получали процессор

При GOMAXPROCS=1 такая горутина морозила вообще всё: другие горутины не бежали, сборщик мусора не мог дойти до своей «stop-the-world» фазы (он ждёт, пока все горутины достигнут безопасной точки), и программа подвисала.

С Go 1.14 появилось асинхронное вытеснение (asynchronous preemption). Фоновый монитор замечает горутину, занявшую P слишком долго (порядка 10 мс), и посылает её потоку сигнал (на Unix — SIGURG). Обработчик сигнала аккуратно останавливает горутину в безопасном месте и снимает её с P. Теперь даже цикл без единого вызова функции будет вытеснен — программа не зависнет.

Значит ли это, что про длинные циклы можно не думать? Нет. Асинхронное вытеснение спасает от полной заморозки, но хорошо структурированный CPU-bound код всё равно делают прерываемым явно: проверяют ctx.Done(), режут работу на куски, периодически отдают результаты. Это нужно для отзывчивости и управляемой отмены, а не только ради совместимости со старыми рантаймами.

Кооперативную уступку процессора через Gosched тоже стоит увидеть в коде. Этот пример опирается на пакет runtime, недоступный в браузерном yaegi, поэтому он только для разбора — запустите его локально, чтобы понаблюдать, как без Gosched жадная горутина печатается первой целиком, а с ним управление возвращается планировщику:

package main
 
import (
	"fmt"
	"runtime"
	"sync"
)
 
func main() {
	var wg sync.WaitGroup
	wg.Add(2)
 
	go func() {
		defer wg.Done()
		for i := 0; i < 3; i++ {
			fmt.Println("работяга считает:", i)
			runtime.Gosched() // уступаем после каждого шага
		}
	}()
 
	go func() {
		defer wg.Done()
		fmt.Println("второй успел вклиниться")
	}()
 
	wg.Wait()
	fmt.Println("готово")
}

Блокирующие syscalls и handoff: откуда «лишние» потоки

Теперь самое неинтуитивное: почему под нагрузкой потоков ОС бывает больше, чем GOMAXPROCS.

Когда горутина уходит в блокирующий системный вызов (например, чтение файла, который реально лезет на диск), её поток M застревает в ядре до возврата вызова. Если бы P оставался привязан к этому M, он бы простаивал всё это время — а это ценный слот параллелизма.

Поэтому рантайм делает handoff: отвязывает P от застрявшего M и отдаёт его другому M (берёт из кэша незанятых потоков или создаёт новый). P снова в деле, другие горутины бегут. Когда syscall наконец вернётся, бывший M попытается опять заполучить какой-нибудь P; если свободного нет — горутина ставится в очередь, а сам M уходит в парк (засыпает про запас).

Вывод: число потоков ОС определяется числом одновременно блокирующих syscalls, а не GOMAXPROCS. Сто горутин, разом залипших в блокирующих вызовах, — это потенциально сто потоков. Предел задаёт runtime/debug.SetMaxThreads (по умолчанию 10000); упереться в него — это обычно симптом бага, а не нормальная работа.

Сетевой I/O устроен принципиально иначе и потому масштабируется куда лучше. Сетевые дескрипторы переводятся в неблокирующий режим и обслуживаются network poller (epoll на Linux, kqueue на BSD/macOS, IOCP на Windows). Горутина, ждущая данных из сокета, паркуется без захвата M: она просто спит, а poller будит её, когда сокет готов к чтению или записи. Поэтому миллион сетевых соединений не превращается в миллион потоков — отсюда и репутация Go как языка для сетевых сервисов.

Спин и парковка: почему M не засыпает сразу

Простаивающий M не бросается спать при первой же пустой очереди. Сначала он немного спинит — крутится в коротком busy-wait, выискивая работу. Логика простая: если работа вот-вот появится (а под нагрузкой так и бывает), то короткий спин дешевле, чем полноценная парковка через ОС и последующее пробуждение — системные вызовы на сон/побудку стоят тысяч тактов.

Рантайм держит лишь ограниченное число «спиннящих» M одновременно, чтобы не жечь CPU впустую целой толпой крутящихся вхолостую потоков. Если за время спина работа не нашлась — M паркуется.

Та же гибридная стратегия пронизывает внутренние блокировки рантайма (например, защиту очередей): короткий спин, затем — парковка. И это ровно та же идея, что в user-space мьютексах Go: при коротком ожидании мьютекс спинит в надежде, что владелец вот-вот отпустит замок, и только при затяжной конкуренции уводит горутину в сон.

Sysmon: сторож рантайма

Кто посылает тот самый SIGURG жадной горутине и кто отбирает P у долгого syscall? Это делает sysmon — фоновый поток-монитор, который работает без P (ему не нужен слот параллелизма, он стоит особняком). Sysmon периодически просыпается и присматривает за системой:

  • запускает асинхронное вытеснение засидевшихся на P горутин;
  • отбирает P у горутин, надолго застрявших в syscall;
  • форсирует сборку мусора по таймеру, если её давно не было;
  • обслуживает таймеры и сетевой poller, когда все P заняты прикладной работой.

Без sysmon рантайм мог бы застрять: некому было бы вытеснить жадную горутину или вернуть P из затянувшегося вызова. Это тихий сторож, который не даёт системе заклинить.

select и каналы как точки планирования

Раз уж точки планирования — это в том числе операции с каналами, на них удобно показать кооперацию горутин вживую. select паркует горутину, пока ни один канал не готов, и будит её при первом же событии — планировщик при этом переключается на готовых:

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	jobs := make(chan int)
	done := make(chan struct{})
	var wg sync.WaitGroup
 
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case j := <-jobs:
				fmt.Println("обработал задачу", j)
			case <-done:
				fmt.Println("получен сигнал остановки")
				return
			}
		}
	}()
 
	for i := 1; i <= 3; i++ {
		jobs <- i // отправка будит припаркованную на select горутину
	}
	close(done) // вторая ветка select завершает воркер
	wg.Wait()
	fmt.Println("воркер остановлен")
}

Здесь select честно паркует горутину сразу на двух каналах: jobs приносит работу, done — сигнал остановки. Каждая отправка в jobs будит воркера и передаёт ему управление, а закрытие done срабатывает на второй ветке и даёт горутине корректно завершиться — её вывод гарантированно напечатается.

Почему гонки нельзя показать в браузере

Всё, что выше про планирование, мы смогли запустить, потому что речь шла о кооперации горутин в одном потоке. А вот настоящие гонки данных возникают, только когда две горутины реально пишут в одну память одновременно на разных ядрах. В браузерном yaegi такого параллелизма нет, и race-детектора тоже нет — поэтому следующий пример только разбираем, а проверять его нужно у себя:

// Запускать локально: go run -race main.go
package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	var counter int
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // ГОНКА: чтение-изменение-запись без синхронизации
		}()
	}
	wg.Wait()
	fmt.Println(counter) // на реальном железе почти никогда не 1000
}

counter++ — это на самом деле три операции: прочитать, прибавить, записать. На нескольких ядрах две горутины могут прочитать одно и то же значение и затереть работу друг друга — результат окажется меньше 1000 и будет плавать от запуска к запуску. Лечится либо атомиками (atomic.AddInt64), либо мьютексом. Подробный разбор — в главе про утечки и гонки.

Запомните разделение: кооперативное чередование горутин планировщик показывает и в одном потоке (это мы и запускали выше), а вот одновременный доступ к памяти — свойство настоящего параллелизма, и ловит его только -race на реальной машине.

Частые ошибки

  • Надеяться на асинхронное вытеснение вместо отмены. Да, цикл без вызовов с Go 1.14 вытеснится и не заморозит программу. Но это страховка от зависания, а не замена явной проверки ctx.Done(). CPU-bound секции всё равно режьте на куски и делайте отменяемыми.
  • Считать, что потоков ОС не больше GOMAXPROCS. Блокирующие syscalls через handoff плодят потоки сверх числа P — это нормально. Пугаться стоит не самого факта, а сотен потоков, залипших в блокирующих вызовах, где напрашивается неблокирующий I/O.
  • Не проверять GOMAXPROCS в контейнере. В проде с CPU-квотой не поленитесь убедиться, что рантайм видит квоту, а не все ядра хоста, иначе получите лишние переключения и троттлинг.
  • Сыпать runtime.Gosched() «для надёжности». Добровольная уступка иногда оправдана в специфичных спин-петлях, но в обычном коде она лишняя — доверяйте планировщику, он сам разрулит.

Что дальше

Эта глава — фундамент под поведение, которое вы наблюдаете во всех остальных темах. Понимание вытеснения и work-stealing объясняет, почему горутины дёшевы и почему паттерны хорошо масштабируются. Знание про handoff на syscalls и network poller помогает рассуждать о пулах воркеров и backpressure. А понимание точек планирования смыкается с темой контекста и отмены — почему долгие циклы без проверки ctx.Done() это проблема — и с поиском утечек и гонок под нагрузкой.

Главная мысленная модель: планировщик — это динамический балансировщик горутин по P-слотам, с кражей работы, асинхронным вытеснением и сторожем-sysmon. Вам почти никогда не нужно им управлять, но умение объяснить «почему длинный цикл морозил всё» или «откуда столько потоков под нагрузкой» — это и есть зрелое понимание конкурентности в Go.