В главе горутины мы договорились о модели G-M-P: горутины
(G) — это единицы работы, потоки ОС (M) — те, кто реально крутит код на железе, а
логические процессоры (P) — «слоты-разрешения», без которых M не имеет права
исполнять Go-код. P ровно столько, сколько настроено через GOMAXPROCS.
Здесь мы спустимся на уровень ниже и разберём, как из этой тройки получается
работающий планировщик: откуда берётся параллелизм, как горутины перетекают между
процессорами, почему тугой цикл без вызовов когда-то мог заморозить всю программу,
и почему под нагрузкой потоков ОС иногда больше, чем GOMAXPROCS. Управлять этим
вручную почти никогда не нужно — но понимание «почему так» отличает инженера,
который чинит конкурентные баги, от того, кто их боится.
Важная честная оговорка: настоящего параллелизма и race-детектора в браузере нет.
Кооперацию горутин через каналы и select мы можем запустить по-настоящему, а вот
примеры с пакетом runtime (Gosched, GOMAXPROCS) и с гонками данных оставляем
статичными — браузерный yaegi не подключает runtime и не ловит гонки, поэтому их
нужно гонять у себя с go run и go run -race.
GOMAXPROCS — это число P, то есть максимальное число горутин, которые исполняют
Go-код по-настоящему одновременно. По умолчанию равно числу логических CPU,
которые видит рантайм.
Ключевая интуиция: горутин могут быть миллионы, но в каждый момент времени Go-код
крутят максимум GOMAXPROCS из них. Остальные либо ждут своей очереди в
runqueue, либо припаркованы (спят на канале, мьютексе, таймере, сетевом I/O).
n := runtime.GOMAXPROCS(0) // 0 = только прочитать текущее значениеruntime.GOMAXPROCS(4) // ограничить программу четырьмя Pfmt.Println("логических CPU:", runtime.NumCPU(), "P:", n)
Прод-деталь, на которой обжигались целые команды: в контейнере с CPU-лимитом
рантайм исторически видел все ядра хоста, а не выделенную квоту. Машина на 64
ядра, контейнеру выдан 1 CPU — а GOMAXPROCS всё равно 64. Результат: рантайм
заводит кучу P, операционная система душит контейнер троттлингом, латентность
скачет. Долго лечили библиотекой automaxprocs от Uber (она читает cgroup-квоту
и выставляет вменяемое значение); начиная с Go 1.25 рантайм по умолчанию
выставляет GOMAXPROCS по cgroup-квоте. Но привычка проверить GOMAXPROCS
в проде остаётся здравой — особенно если собираете на более старой версии.
Откуда планировщик берёт следующую горутину? Из очередей готовых к запуску G.
У каждого P есть своя локальная очередь (runqueue) — кольцевой буфер на
256 горутин. Когда горутина становится готовой (например, текущая G породила
новую через go f()), она обычно попадает в локальную очередь того же P. Это
дёшево (не нужны глобальные блокировки) и кэш-дружелюбно: связанные горутины
скорее всего работают с тёплыми для этого ядра данными — это и называют locality.
Есть и одна глобальная очередь на весь рантайм. В неё попадают «лишние»
горутины при переполнении локальной, а также те, что стали готовы «издалека» (не
на том P, который их породил). Глобальная очередь требует блокировки, поэтому она
дороже — её используют как запасной резервуар.
Чтобы горутины из глобальной очереди не голодали в пользу бесконечно
пополняемой локальной, P периодически (примерно раз в 61-й тик планировщика)
специально заглядывает в глобальную очередь, даже если в локальной есть работа.
Это маленький, но важный приём против голодания (starvation).
Нагрузка между горутинами обычно неравномерна: один P может разгрести свою
очередь, пока у соседа завал. Центрального диспетчера, который бы это
перераспределял, в Go нет — вместо него работает work-stealing («кража
работы»).
Когда P остаётся без работы (своя локальная очередь пуста), он не сразу засыпает,
а пытается её добыть в таком порядке:
заглянуть в глобальную очередь;
проверить network poller на готовые сетевые горутины;
украсть половину горутин у случайно выбранного другого P.
Кража именно половины — разумный компромисс: украсть слишком мало — придётся
красть снова и снова; украсть всё — обездолишь жертву. Так система сама себя
балансирует: занятые P отдают излишки, простаивающие — подбирают.
Понаблюдать само планирование помогает runtime.Gosched() — добровольная уступка
P другим горутинам. Этот пример обращается к пакету runtime, которого нет в
браузерном yaegi, поэтому он только для разбора — запускайте его у себя:
package mainimport ( "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 дождётся всех и вывод напечатается.
Раньше 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 mainimport ( "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("готово")}
Теперь самое неинтуитивное: почему под нагрузкой потоков ОС бывает больше, чем
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 не бросается спать при первой же пустой очереди. Сначала он
немного спинит — крутится в коротком busy-wait, выискивая работу. Логика
простая: если работа вот-вот появится (а под нагрузкой так и бывает), то короткий
спин дешевле, чем полноценная парковка через ОС и последующее пробуждение —
системные вызовы на сон/побудку стоят тысяч тактов.
Рантайм держит лишь ограниченное число «спиннящих» M одновременно, чтобы не жечь
CPU впустую целой толпой крутящихся вхолостую потоков. Если за время спина работа
не нашлась — M паркуется.
Та же гибридная стратегия пронизывает внутренние блокировки рантайма (например,
защиту очередей): короткий спин, затем — парковка. И это ровно та же идея, что в
user-space мьютексах Go: при коротком ожидании мьютекс
спинит в надежде, что владелец вот-вот отпустит замок, и только при затяжной
конкуренции уводит горутину в сон.
Кто посылает тот самый SIGURG жадной горутине и кто отбирает P у долгого
syscall? Это делает sysmon — фоновый поток-монитор, который работает без
P (ему не нужен слот параллелизма, он стоит особняком). Sysmon периодически
просыпается и присматривает за системой:
запускает асинхронное вытеснение засидевшихся на P горутин;
отбирает P у горутин, надолго застрявших в syscall;
форсирует сборку мусора по таймеру, если её давно не было;
обслуживает таймеры и сетевой poller, когда все P заняты прикладной работой.
Без sysmon рантайм мог бы застрять: некому было бы вытеснить жадную горутину или
вернуть P из затянувшегося вызова. Это тихий сторож, который не даёт системе
заклинить.
Раз уж точки планирования — это в том числе операции с каналами, на них удобно
показать кооперацию горутин вживую. select паркует горутину, пока ни один
канал не готов, и будит её при первом же событии — планировщик при этом
переключается на готовых:
package mainimport ( "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.gopackage mainimport ( "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.