9.4 Цикл планирования
Предыдущие разделы подготовили материал: мы знаем, что такое G, M и P (9.3), и знаем, как M находит работу (9.2). Этот раздел запускает всё в движение — мы наблюдаем, как цикл планирования непрерывно выбирает горутины и выполняет их в рамках одного потока, и как он удерживает баланс между «позволить отдельной горутине чуть дольше занимать процессор» (пропускная способность и локальность) и «не позволить ни одной горутине оголодать» (справедливость).
Весь приведённый ниже код — сокращённые наброски, сохраняющие только каркас, важный для понимания архитектуры, и отбрасывающие побочные ветки, такие как GC, трассировка, профилирование и блокировка потоков. Полные определения можно сверить с runtime/proc.go; все версии ниже соответствуют go1.26.
9.4.1 Цикл, который никогда не возвращается
Планировщик Go основан на принципе «кооперативное выполнение до момента уступки»: выбранная горутина работает до тех пор, пока добровольно не уступит управление, не заблокируется или не будет вытеснена (9.7) — а не прерывается по истечении фиксированного кванта времени посредством прерывания от таймера, как это делает ядро ОС. После того как рабочий поток стартует из mstart, он в конечном счёте попадает в цикл планирования schedule и с этого момента крутится внутри него вплоть до завершения потока. В самом минимальном виде этот каркас представляет собой двухшаговый цикл:
|
|
findRunnable никогда не возвращает nil: если получить работу не удаётся, она переводит M в режим активного ожидания (spinning) или засыпает, блокируясь внутри себя до пробуждения — так что schedule не нуждается в ветке обработки ситуации «нечего делать». execute тоже никогда не возвращается: она совершает прыжок на стек пользовательского G, после чего стековый фрейм schedule переиспользуется. Для возврата управления в логику планирования используется переключение стека, описанное в 9.4.2.
flowchart TD
MS[mstart: поток запускается] --> SC["schedule: выбирает G на g0"]
SC --> FR["findRunnable: runnext/локальная → глобальная → сеть → кража"]
FR -->|нашли| EX["execute: casgstatus → _Grunning"]
FR -->|не нашли| PARK["активное ожидание / stopm, повтор после пробуждения"]
PARK --> FR
EX --> GOGO["gogo: переключается на стек G"]
GOGO --> RUN["G выполняет пользовательский код"]
RUN -->|блокировка / Gosched / вытеснение| MC["mcall: переключается обратно на g0, запускает колбэк"]
MC --> SC
RUN -->|функция завершается| GE["goexit → goexit0: возвращает G в gFree"]
GE --> SC
Логика планировщика — schedule, findRunnable и другие функции — выполняется на выделенном системном стеке g0 (9.3), а не на стеке пользовательского G. Это обеспечивает чёткое разделение обязанностей: g0 занимается планированием, пользовательский G — работой. По этой же причине schedule никогда по-настоящему не возвращается: она выбирает G, совершает прыжок на его стек, а для возврата управления в логику планирования необходимо переключение стека из следующего раздела, а не обычный возврат из функции.
9.4.2 Два переключения: execute и mcall
В цикле планирования задействованы два переключения стека, работающие в противоположных направлениях. Вместе они составляют физическую реализацию машины состояний горутины из 9.3: машина состояний описывает, какие переходы возможны, а эти две подпрограммы — как именно переход происходит.
Прыжок с g0 на пользовательский G: после того как schedule выбирает G, она вызывает execute. Та переводит G в состояние _Grunning, привязывает его к текущему M, а затем вызывает ассемблерную подпрограмму gogo, которая загружает сохранённый контекст G (поля gobuf из 9.3: sp, pc, bp и другие) обратно в регистры. Управление оказывается на стеке пользовательского G, продолжаясь с того места, где он был последний раз переключён.
|
|
Хитрость gogo в том, что это «билет в один конец»: загрузив регистры, она выполняет прямой JMP на pc горутины, не оставляя кода для возврата в планировщик. При первом запуске нового G его pc указывает на пользовательскую функцию fn, а «адрес возврата» из fn был заранее установлен на goexit при построении стека в newproc1 (см. 9.4.4). Поэтому по return из fn управление естественным образом попадает в goexit — именно через эту точку входа управление возвращается обратно в рантайм.
Прыжок с пользовательского G обратно на g0: когда G должен уступить управление (Gosched, блокировка на канале, вытеснение или завершение функции), он в конечном счёте вызывает mcall. Та сохраняет текущий контекст в gobuf горутины, переключается на стек g0 и выполняет колбэк на g0:
|
|
Конкретный колбэк зависит от причины уступки: добровольная уступка проходит через goschedImpl, которая заново ставит G в очередь и продолжает выполнение schedule; блокирующее ожидание проходит через park_m, которая переводит G в состояние _Gwaiting и затем входит в schedule; завершение выполнения проходит через goexit0. В любом из случаев по завершении колбэка управление возвращается в schedule, и цикл замыкается. Этот круговой маршрут и есть физическая реализация переходов горутины между состоянием «выполняется» и остальными состояниями.
sequenceDiagram
participant g0 as g0 (стек планировщика)
participant G as пользовательский G
g0->>G: execute → gogo (загрузить gobuf, переключиться на стек G)
Note over G: G выполняет пользовательский код
G->>g0: mcall (сохранить контекст, переключиться обратно на стек g0)
Note over g0: выполнить колбэк на g0<br/>goschedImpl / park_m / goexit0
g0->>g0: вернуться в schedule, выбрать следующий G
9.4.3 Справедливость: никто не должен голодать
Кооперативное планирование несёт в себе неотъемлемый риск: если «наиболее удобный» G из локальной очереди всегда получает управление первым, некоторые G могут не получить его никогда. Для этого schedule устанавливает несколько клапанов справедливости, которые вместе не дают кооперативному планированию приводить к голоданию на практике.
Периодическая проверка глобальной очереди. Прежде чем обратиться к локальной очереди, каждые 61 тиков планирования findRunnable сначала извлекает G из глобальной очереди:
|
|
Это устраняет конкретный сценарий голодания: два G, взаимно пробуждающих друг друга, будут передавать управление в рамках локальной очереди, заполняя её и оставляя G из глобальной очереди ждать вечно. Принудительное обращение к глобальной очереди через фиксированный интервал разрушает эту монополию. Следует отметить, что используемый счётчик — schedtick, который увеличивается только при старте нового кванта (см. execute в 9.4.2); унаследованный через runnext квант не учитывается, поэтому «каждые 61» отсчитываются от запусков, действительно начинающих новый квант, а не от каждого переключения G. В комментарии к исходному коду сказано только «для обеспечения справедливости», не поясняя, почему именно 61; широко распространённое утверждение «61 — простое число, это позволяет избежать резонанса» является народной спекуляцией, и в данной книге мы принимаем лишь сам факт «61».
Ограничение runnext для предотвращения голодания. G, только что пробуждённый или только что порождённый оператором go, помещается в слот runnext своего P для первоочередного запуска и наследует остаток кванта текущего G (inheritTime, второе значение, возвращаемое runqget в 9.4.2). Это позволяет паре горутин «взаимодействие — затем выполнение» планироваться компактно как единица, что улучшает локальность кэша. Однако runnext можно использовать и во вред: два G, вызывающие runnext друг для друга, могут монополизировать процессор. Рантайм полагается на вытеснение по кванту со стороны sysmon (9.8) как на подстраховку. В исходном коде есть примечательная деталь: для платформ без sysmon (например, wasm) рантайм полностью отключает runnext:
|
|
Это показательный момент с точки зрения архитектуры: справедливость — не один механизм, а результат совместной работы нескольких. runnext обеспечивает пропускную способность и локальность ценой потенциального взаимного голодания; эта цена компенсируется вытеснением со стороны sysmon; когда же этот механизм вытеснения исчезает, оптимизация, на него опирающаяся, должна быть также отключена.
Полный порядок выбора горутин по-прежнему соответствует описанному в 9.2, упорядоченному от наибольшей частоты попаданий к наименьшей и от наименьших затрат на синхронизацию к наибольшим:
flowchart LR
A["runnext + локальная очередь<br/>(на P, без блокировок)"] --> B["глобальная очередь<br/>(требует sched.lock)"]
B --> C["сетевой поллер netpoll<br/>(горутины с готовым I/O)"]
C --> D["кража у других P<br/>(случайный P, украсть половину)"]
D --> E["всё пусто: активное ожидание / stopm"]
Только когда все источники исчерпаны, поток переходит в режим активного ожидания (кратковременный busy-wait в расчёте на скорое появление работы) или засыпает через stopm. Сам по себе этот порядок «сначала локальная, затем глобальная, в конце — кража» является компромиссом между пропускной способностью и справедливостью: ранние шаги дёшевы и способствуют локальности, а более поздние гарантируют, что работа в итоге будет подхвачена каким-либо простаивающим M.
9.4.4 Рождение и смерть горутины
За пределами цикла находятся два конца.
Рождение. Оператор go f() компилятор транслирует в вызов newproc, который выполняет построение G на системном стеке:
|
|
В newproc1 и происходит фактическое построение G, и здесь воплощена философия «сначала переиспользование»: сначала берётся уже использованный G (вместе со стеком) из свободного списка gFree своего P, и только если там пусто — выделяется новый из кучи; затем обнуляется его контекст, sched.pc указывается на пользовательскую функцию, а «адрес возврата» из fn заранее устанавливается на goexit (именно поэтому в 9.4.2 G «естественно попадает в goexit» после прыжка gogo), и, наконец, G переводится в состояние _Grunnable. runqput(pp, newg, true) помещает только что порождённый G в runnext, позволяя типичному паттерну «порождение — затем выполнение» запускаться компактно; wakep затем будит M при наличии свободного параллелизма, как можно скорее превращая новый G в реальный параллелизм.
Смерть. Когда функция G завершается, управление попадает не к вызывающему, а на заранее установленный рантаймом goexit, который через goexit1 → mcall(goexit0) переключается обратно на g0, где goexit0 производит очистку:
|
|
G не освобождается, а помещается обратно в gFree, избегая повторного выделения структуры G и начального стека. Именно поэтому высокочастотное создание горутин остаётся дешёвым: начиная со второго вызова go f() рантайм в основном извлекает старый G из gFree и корректирует точку входа, а не строит его с нуля. Рождение берёт из gFree, смерть возвращает в gFree; оба конца симметрично разделяют один и тот же свободный список на уровне P — тот же приём «послойного снижения конкуренции», что и в кэше аллокатора на уровне P (12.2).
9.4.5 Эволюция архитектуры
Цикл не принял нынешнюю форму сразу; каждый из его клапанов соответствует усвоенному уроку из истории. Прослеживая нить эволюции, мы можем объяснить происхождение констант и ограничений, которые раньше казались произвольными.
flowchart LR
A["до Go 1.0<br/>единая глобальная runq + одна большая блокировка"]
A -->|"редизайн Вьюкова, 2012"| B["Go 1.1<br/>локальная очередь на P<br/>+ кража работы + spinning M"]
B -->|"runnext + клапан 61"| C["добавлено постепенно<br/>оптимизация локальности + защита от голодания"]
C -->|"proposal 24543"| D["Go 1.14<br/>асинхронное вытеснение по сигналу"]
Ранний планировщик (Go 1.0 и до него) имел лишь одну глобальную очередь запуска в паре с одной глобальной блокировкой. Каждому M для взятия или постановки G приходилось конкурировать за эту блокировку, что становилось узким местом по мере роста числа ядер. В своём проектном документе 2012 года Вьюков отправился именно от этой боли, предложив схему с локальной очередью на P, кражей работы и spinning M — всё это было реализовано в Go 1.1. Этот шаг заложил всю основу для 9.2 и данного раздела: локальная очередь делает подавляющее большинство операций взятия и постановки безблокировочными, кража гарантирует, что работа не зависнет на каком-то P, а spinning M на короткое время занимается активным ожиданием перед дорогостоящей операцией пробуждения нового потока — рассчитывая, что работа вот-вот появится.
Локальная очередь решила проблему конкуренции, но породила проблему справедливости, что привело к двум патчам из 9.4.3: слот runnext обеспечивает локальность для пары G «взаимодействие — затем выполнение», а проверка глобальной очереди каждые 61 тиков закрывает брешь, при которой взаимно пробуждающая себя пара G монополизирует локальную очередь. Эти патчи применялись постепенно, устраняя побочные эффекты после того, как схема с локальной очередью прижилась.
Последний недостающий элемент — вытеснение. Ранее оно было кооперативным и происходило только при проверке стека в прологе функции; плотный цикл без вызовов функций (например, for {}) мог вечно удерживать P, не уступая управление, замораживая даже STW на неопределённый срок. В Go 1.14 было введено асинхронное вытеснение по сигналу (proposal 24543): рантайм отправляет сигнал целевому потоку и принудительно перехватывает управление в безопасной точке. Это закрыло последнюю прореху в кооперативном планировании и стало именно той подстраховкой, на которую рассчитывает runnext из 9.4.3.
9.4.6 Взгляд со стороны теории планирования
Обобщая несколько клапанов schedule, планировщик Go представляет собой гибрид «кооперативного выполнения до уступки + добровольной уступки + асинхронного вытеснения по сигналу как подстраховки». Он занимает среднее место на спектре архитектур планирования.
Чисто кооперативное планирование (ранние потоки пользовательского пространства, цикл событий Node в рамках одной задачи) обладает высокой пропускной способностью и дешёвым переключением, поскольку точка уступки определяется самой программой, без необходимости сохранять полный контекст прерывания; недостаток в том, что задача, не уступающая управления, может блокировать всё остальное, а справедливость целиком зависит от добросовестности программы. Чисто вытесняющее планирование по кванту (потоки ядра) справедливо и не зависит от кооперации задач, но требует дорогого переключения и непредсказуемых точек вытеснения, что неблагоприятно для локальности.
Go занимает позицию между этими двумя полюсами: по умолчанию оно достигает пропускной способности и локальности через кооперативную уступку (каналы, Gosched, проверка вытеснения в прологе функции) и кражу работы (9.2), а затем использует асинхронное вытеснение, управляемое sysmon примерно раз в 10 мс (9.7), как подстраховку справедливости, гарантируя, что даже чисто вычислительный G без точек уступки в конечном счёте будет переключён. Деталь из 9.4.3 — «нет sysmon — отключить runnext» — это и есть внутренняя логика данного гибрида, явно обнажённая: когда механизм вытеснения отсутствует, кооперативные оптимизации, опирающиеся на него как на подстраховку, также должны быть отключены.
Это перекликается с вытеснением по счётчику редукций в Erlang/BEAM. BEAM выделяет каждому процессу фиксированный бюджет редукций, уменьшая его на единицу при каждой операции, например при вызове функции, и вытесняет процесс, когда бюджет исчерпан. Обе системы балансируют «дешевизну кооперации» и «справедливость вытеснения», различаясь лишь расположением точки вытеснения: Go помещает её в проверку стека при вызове функции и в асинхронные сигналы, а BEAM — в подсчёт редукций. Подсчёт BEAM детерминирован и не зависит от реального времени, обеспечивая более равномерную гранулярность справедливости; вытеснение Go по сигналу срабатывает по реальному времени, имеет более лёгкую реализацию и естественнее вписывается в безопасные точки GC. Не существует «идеального» планирования (9.1 обсуждал нижнюю границу конкурентного отношения для онлайн-планирования); несколько клапанов schedule — это конкретный и взвешенный ответ Go на компромисс между пропускной способностью, задержкой и сложностью реализации.
Дополнительная литература
- Dmitry Vyukov. Scalable Go Scheduler Design Doc, 2012. https://go.dev/s/go11sched (происхождение архитектуры кражи работы + spinning M)
- The Go Authors. runtime/proc.go (
schedule,findRunnable,execute,mcall,goschedImpl,newproc,goexit0,runqput). go1.26. https://github.com/golang/go/blob/master/src/runtime/proc.go - The Go Authors. runtime/asm_amd64.s (ассемблерные реализации
gogoиmcall). https://github.com/golang/go/blob/master/src/runtime/asm_amd64.s - Robert D. Blumofe, Charles E. Leiserson. Scheduling Multithreaded Computations by Work Stealing. JACM 46(5), 1999. https://doi.org/10.1145/324133.324234 (теоретическая основа кражи работы)
- Erik Stenman. The BEAM Book: Scheduling. https://blog.stenmans.org/theBeamBook/#CH-Scheduling (сравнение с вытеснением по счётчику редукций)
- Nimrod Aviram et al. / The Go Authors. Goroutine preemption (архитектура асинхронного вытеснения), Go 1.14. https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption
- Phil Hofer et al. runtime: scheduler is slow when goroutines are frequently woken. Go issue #18237, 2016. https://github.com/golang/go/issues/18237 (эмпирические измерения задержки пробуждения spinning M)
- Эта книга: 9.2 Стратегия планирования, 9.3 G, M, P и машина состояний, 9.7 Вытеснение, 9.8 Системный мониторинг.