9.3 Модель MPG и единицы конкурентного планирования
Первый вопрос, на который должен ответить шедулер, — не «как планировать», а «что планировать». Go называет объект планирования горутиной и реализует её на тройке M, P и G. Прежде чем перейти к алгоритму планирования (начиная с 9.4), этот раздел разбирает три единицы планирования: что такое горутина в контексте истории информатики, как кодируется её контекст выполнения, почему само планирование должно происходить на специальном g0, через какие состояния проходит горутина за свою жизнь и как поток M, несущий её, приостанавливается и возобновляется. Когда эти понятия прояснятся, все последующие алгоритмы планирования сведутся к «перемещению G между этими единицами».
Чтобы не скатиться в пословное перечисление полей исходного кода, приведённые ниже структуры представляют собой сокращённые эскизы: сохраняются только поля, значимые для понимания архитектуры, а комментарии объясняют назначение каждого из них. Полные определения можно сверить с runtime/runtime2.go и runtime/proc.go (раздел актуален для go1.26).
9.3.1 Что такое горутина: стековая сопрограмма
Горутину нередко описывают единственной фразой — «лёгковесный поток». Это не ошибка, однако такое определение скрывает её подлинное происхождение. Если поместить горутину в контекст истории информатики, она окажется стековой сопрограммой.
Концепция сопрограммы восходит к введённому Конвеем в 1963 году термину, где две подпрограммы выступают взаимными вызывающими: каждая из них может передавать управление в промежуточной точке и впоследствии возобновлять выполнение ровно с того места, где остановилась, — в отличие от обычной функции, которая должна дойти до конца, прежде чем вернуть управление. В 2009 году Моура и Иерусалимски предложили чёткую таксономию сопрограмм; её две ортогональные оси по-прежнему служат базовыми координатами при обсуждении сопрограмм:
- Симметричные и асимметричные: симметричные сопрограммы равноправны и переключаются между собой через единственный унифицированный примитив передачи управления; асимметричные имеют явные роли «вызывающего» и «вызываемого», и вызываемая может уступать управление только своему вызывающему. Пользователь Go никогда не видит явного yield, однако внутри рантайма отношение между горутиной и циклом планирования — именно это асимметричное уступание.
- Стековые и бесстековые: стековая сопрограмма владеет собственным независимым стеком вызовов, поэтому может приостанавливаться внутри вложенного вызова на произвольной глубине, и точка приостановки не обязана совпадать со входной функцией сопрограммы; бесстековая сопрограмма не имеет независимого стека и может приостанавливаться лишь на верхнем уровне функции, поэтому приостановка из глубины цепочки вызовов требует переписывания всей этой цепочки в конечный автомат.
Горутина занимает ячейку «асимметричная, стековая». Стековость особенно важна: она означает, что горутина может быть приостановлена в любой функции и на любой глубине вызовов (будь то добровольная блокировка на <-ch или вытеснение шедулером), и при приостановке весь стек вызовов Go вместе с локальными переменными сохраняется нетронутым; при возобновлении выполнение продолжается с точки останова. В более теоретических терминах одна приостановка — это захват однократного ограниченного продолжения текущего выполнения, а физической формой этого продолжения является gobuf, который будет рассмотрен в следующем разделе: набор сохранённых регистров (SP, PC и прочие), достаточный для возобновления выполнения с точки останова.
Цена стековости — необходимость удерживать для каждой горутины область памяти под стек. Go нейтрализует это ограничение принципом «начать малым, расти по требованию»: начальный стек новой горутины составляет всего 2 КБ (stackMin = 2048 в runtime/stack.go), а при нехватке механизм непрерывного стека (14.3 Stack Growth) перемещает и наращивает его целиком. Это снижает фиксированные издержки стековости до уровня, сопоставимого с бесстековыми сопрограммами.
Стековость позволяет обойти то, что Нистром в 2015 году назвал проблемой окраски функций. В языках с бесстековыми сопрограммами (как правило, реализованными через async/await) функция, желающая приостановиться изнутри, должна быть объявлена как async, и «возможность приостановки» становится «цветом» функции, который распространяется вверх по цепочке вызовов: функция, вызывающая async-функцию, сама, как правило, должна быть async, обычные и async-функции не взаимозаменяемы, а стандартная библиотека зачастую вынуждена поддерживать по одной копии для каждого «цвета». Стековые сопрограммы лишены этого разрыва: любая обычная функция может приостановиться на любой глубине без специальных аннотаций, и вызывающий об этом не знает. В Go нет ключевого слова async и нет разделения на «асинхронные» и «синхронные» функции — это прямое следствие стековой архитектуры.
9.3.2 Три единицы планирования: G, M, P
Для понимания шедулера необходимо разобраться в трёх концепциях:
- G: горутина — тело выполнения, создаваемое ключевым словом
go, базовый объект планирования; - M: machine — рабочий поток ОС, сущность, которая реально занимает процессор и выполняет инструкции;
- P: processor — искусственно выделенный набор локальных ресурсов, необходимых для выполнения кода Go. M может выполнять Go-код только при наличии связанного P.
Существование P поначалу вызывает недоумение: раз M — уже поток, зачем добавлять ещё один уровень между M и G? Полный ответ будет дан при рассмотрении похищения работы (9.5); пока достаточно одной формулировки: P — это «разрешение на выполнение Go-кода плюс набор локальных ресурсов». Его количество (GOMAXPROCS) задаёт верхнюю границу параллельно выполняемых пользовательских вычислений, а размещение таких ресурсов, как локальная очередь исполнения и кеши памяти, на P, а не на M, позволяет им переходить от потока к потоку вместе с «разрешением» — что поддерживает похищение работы и сохраняет быстрый путь без блокировок.
G: тело выполнения и его контекст
G — это горутина, поэтому она естественным образом несёт собственный стек выполнения и «снимок точки останова», используемый для возобновления после приостановки:
|
|
Поле sched типа gobuf — это физическое воплощение продолжения, обсуждавшегося в разделе 9.3.1:
|
|
В горутине нет ничего магического: при создании точка входа выполняемой функции записывается в gobuf.pc, а аргументы копируются на стек выполнения; при приостановке текущие SP/PC и прочие регистры сохраняются обратно в gobuf; при возобновлении они восстанавливаются в реальные регистры и выполнение продолжается с точки останова. К atomicstatus необходим атомарный доступ, так как это поле конкурентно читается и записывается другими M (а также GC и системным монитором); именно оно хранит состояния конечного автомата из раздела 9.3.4.
M: сущность потока ОС
M соответствует реальному потоку ОС. Её наиболее важные поля вращаются вокруг вопроса «что поток должен нести с собой для выполнения Go-кода»:
|
|
Каждый M содержит две специальные горутины — g0 и gsignal, которые не выполняют пользовательский код, а берут на себя планирование и обработку сигналов соответственно. curg — пользовательская горутина, выполняющаяся в данный момент. p — то самое «разрешение на выполнение»: M, лишившийся своего P (например, при входе в долгий системный вызов), не может продолжать выполнять Go-код.
P: локальные ресурсы для выполнения Go-кода
P — это абстракция процессора, а не сам процессор. Весь смысл его существования — сосредоточить в одном месте локальные ресурсы, необходимые для выполнения Go-кода, чтобы быстрый путь был свободен от блокировок, а похищение работы было возможным:
|
|
Ключевым элементом P является локальная очередь исполнения. runq — кольцевой буфер ёмкостью 256 элементов; M, владеющий P, берёт из головы и добавляет в хвост, и поскольку никто не конкурирует за него, доступ может быть безблокировочным; похищение (9.5) происходит, когда другой P приходит забрать половину. runnext — однослотовая приоритетная позиция: когда одна горутина пробуждает другую (например, при отправке или получении по каналу), пробуждённая помещается в runnext, а не в хвост очереди, и таким образом «выполняется следующей», сохраняя кешевую локальность между производителем и потребителем. gFree кеширует завершившиеся G вместе с их стеками, чтобы следующий вызов go мог повторно использовать одну из них и избежать нового выделения памяти; именно это является назначением состояния _Gdead из раздела 9.3.4. Размещение этих ресурсов на P и возможность их перехода между M вместе с P — именно так «кеш без блокировок на P» проявляется в шедулере; этот подход роднит его с mcache аллокатора (12.2) и пошардированием sync.Pool по P (11.6).
9.3.3 Почему планирование выполняется на g0
Планирование — это тоже код, и он тоже должен выполняться на каком-то стеке. Если позволить ему работать непосредственно на стеке пользовательской горутины, возникнут проблемы: пользовательский стек мал (изначально 2 КБ) и может находиться на пороге перемещения и увеличения, а такие операции рантайма, как планирование и копирование стека, просто не могут безопасно выполняться на стеке, который «может быть перемещён в любой момент». Решение Go — выделить каждому M специальный g0: его стек больше и зафиксирован в памяти, а цикл планирования рантайма, управление стеком и другие критические операции выполняются именно на g0.
Таким образом, M переключает стеки туда-обратно между «выполнением пользовательского кода» и «выполнением кода планирования». Два примитива рантайма обеспечивают это переключение:
mcall(fn): переключается с текущей пользовательской горутины на g0, выполняетfnна стеке g0, иfnникогда не возвращается в исходную g. Операции вродеgoparkиgoschedImpl, «уступающие управление шедулеру», входят в g0 именно через него.systemstack(fn): временно переключается на стек g0 для выполненияfn, затем возвращается в исходную горутину для продолжения. Фрагменты рантайма, нуждающиеся в большом стеке или не допускающие вытеснения (такие как рост стека и части работы GC), проходят через него.
Таким образом, «выполнение пользовательского кода» и «решение о том, кто выполняется следующим» чётко разделены по двум стекам: пользовательская горутина занимается только своим делом, а в момент, когда ей нужно уступить управление или быть запланированной, управление через mcall переходит на g0, где schedule() на g0 выбирает следующую G и прыгает в неё через execute → gogo. Большинство переходов состояний, упоминаемых далее в этом разделе, происходит сразу после этого «переключения на g0».
9.3.4 Автомат состояний жизненного цикла горутины
Жизнь горутины — это миграция поля atomicstatus между несколькими состояниями. Состояния определены в runtime/runtime2.go, а переходы между ними осуществляются единообразно через casgstatus (compare-and-swap g status) для обеспечения потокобезопасности. Основные состояния таковы:
_Gidle: только что выделена, ещё не инициализирована;_Grunnable: находится в некоторой очереди исполнения, ожидает планирования, ещё не выполняется;_Grunning: выполняет пользовательский код на некотором M, уже привязана к M и P;_Gsyscall: выполняет системный вызов, пользовательский код не выполняется;_Gwaiting: заблокирована внутри рантайма (например, при отправке или получении по каналу, вызовеtime.Sleepили получении блокировки), отсутствует в очереди исполнения, требует явного пробуждения;_Gdead: не используется — возможно, только что завершилась или является пустой оболочкой, ожидающей повторного использования; кешируется вp.gFree/sched.gFree;_Gcopystack: стек перемещается (рост или уменьшение непрерывного стека), в этот момент код не выполняется;_Gpreempted: остановилась самостоятельно из-за вытеснения, напоминает_Gwaiting, но ожидает, пока вытесняющий агент переведёт её обратно в_Gwaiting.
Помимо этих состояний, в go1.26 добавлено диагностическое состояние _Gleaked (значение 10). Это не рядовое звено жизненного цикла, а метка, которую GC проставляет заблокированной горутине, подозреваемой в утечке: если GC в процессе сканирования обнаруживает, что некоторая _Gwaiting-горутина более не может быть разблокирована (она недостижима), то через casgstatus(gp, _Gwaiting, _Gleaked) (runtime/mgc.go) она помечается как утёкшая; если впоследствии горутина снова становится достижимой, состояние восстанавливается через casgstatus(gp0, _Gleaked, _Gwaiting). Это диагностический слой, наложенный поверх «заблокированного» состояния; рантайм не освобождает горутину на этом основании.
Движущей силой этих переходов является набор функций рантайма, с которыми мы будем встречаться снова и снова: newproc создаёт новую G, execute помещает её на CPU, gopark добровольно блокирует, goready пробуждает, entersyscall/exitsyscall входят в системный вызов и выходят из него, goexit завершает. Обозначив их на рёбрах, жизнь горутины выглядит так:
stateDiagram-v2
[*] --> _Gidle: newproc выделяет
_Gidle --> _Gdead: установить как мёртвую оболочку
_Gdead --> _Grunnable: инициализировать контекст, поставить в очередь / переиспользовать gFree
_Grunnable --> _Grunning: execute / запланировать на CPU
_Grunning --> _Grunnable: gosched добровольно уступает
_Grunning --> _Gwaiting: gopark блокирует (канал, блокировка, sleep)
_Gwaiting --> _Grunnable: goready пробуждает
_Grunning --> _Gsyscall: entersyscall
_Gsyscall --> _Grunning: exitsyscall быстро переполучает P
_Gsyscall --> _Grunnable: exitsyscall не нашёл P, поставить в очередь
_Grunning --> _Gcopystack: рост / уменьшение стека
_Gcopystack --> _Grunning: перемещение завершено
_Grunning --> _Gpreempted: вытеснена, остановилась сама
_Gpreempted --> _Gwaiting: вытесняющий агент берёт управление
_Gwaiting --> _Gleaked: GC считает её утёкшей
_Gleaked --> _Gwaiting: снова достижима, восстановлена
_Grunning --> _Gdead: goexit завершает
_Gdead --> [*]
Создание горутины — это именно первые несколько шагов данной диаграммы: newproc сначала переводит G из _Gidle в _Gdead и добавляет её в allg (чтобы GC знал о ней, но не сканировал неинициализированный стек), затем инициализирует стек выполнения и gobuf из точки входа функции и аргументов, после чего через casgstatus переводит в _Grunnable и ставит в очередь — в ожидании, пока execute поместит её на CPU. Состояния также включают семейство битов _Gscan, взаимодействующих с GC (например, _Gscanrunning) и используемых для сканирования стека горутины без её прерывания; для сохранения читаемости диаграммы они опущены, подробности — в разделе 13 Garbage Collection.
9.3.5 Сравнение с аналогами: единицы конкурентного выполнения в других языках
Если поставить горутину рядом с её аналогами, таксономия «стековые / бесстековые» из раздела 9.3.1 немедленно проявляет своё значение. В таблице ниже сравниваются единицы конкурентного выполнения нескольких языков; ключевой параметр — наличие независимого стека и вытекающее из него «наличие или отсутствие проблемы окраски функций»:
| Система | Стековая? | Представление | Стоимость запуска |
|---|---|---|---|
| Горутина Go | Да | независимый стек + продолжение gobuf, непрерывный стек растёт по требованию |
начальный стек 2 КБ |
| Процесс Erlang/BEAM | Да | независимый лёгковесный процесс, приватная куча, планируется поверх BEAM | порядка сотен байт |
| Виртуальный поток Java (Loom, JEP 444) | Да | продолжение, монтируемое на поток-носитель для выполнения | стек растёт по требованию, значительно меньше платформенного потока |
| Сопрограмма Lua | Да (асимметричная) | независимый стек, явный yield через coroutine.resume/yield |
лёгкая, управляется интерпретатором |
| Сопрограмма Kotlin | Нет | suspend компилируется в CPS-автомат состояний, без независимого стека |
крошечная (только объект состояния), но имеет окраску функций |
На таксономическую колонку стоит обратить особое внимание. Горутины Go, процессы Erlang, виртуальные потоки Java и сопрограммы Lua — все стековые, поэтому ни у одного из них нет проблемы окраски функций: вызов на любой глубине может быть приостановлен. Сопрограммы Kotlin бесстековые: компилятор транслирует suspend-функции в CPS-автомат состояний, и «цвет» suspend распространяется вверх по цепочке вызовов — именно тот разрыв, которого, как говорилось в 9.3.1, Go избегает благодаря стековой архитектуре. Сопрограммы Lua заслуживают отдельного упоминания: статья 2009 года Моуры и Иерусалимски, ставшая основой таксономии сопрограмм, выросла из дизайна сопрограмм Lua; «асимметричная стековая» родословная горутин происходит из той же линии, с той лишь разницей, что Go скрывает явные resume/yield внутри рантайма, оставляя пользователю только go и каналы.
9.3.6 Приостановка и возобновление рабочего потока
Вернёмся к рабочему потоку M, несущему G. Шедулер должен балансировать между двумя противоборствующими требованиями: поддерживать достаточно работающих потоков, чтобы насытить аппаратный параллелизм, и при этом приостанавливать избыточные потоки для экономии процессорных ресурсов. Принцип «голубятни» делает это противоречие наглядным: пусть процесс имеет потоков M и пользователь создал горутин G; тогда при существует горутин, для которых в данный момент нет M (нужны дополнительные потоки — unpark); при существует потоков M, у которых нет G для выполнения (они должны спать — park).
Найти оптимальный баланс сложно, и эта сложность проявляется в двух аспектах. Во-первых, множество M, каждый из которых держит локальную очередь и не видит состояния остальных, — это по существу распределённая система: нет глобальных часов, синхронизирующих все потоки, и вычисление глобального предиката вроде «есть ли где-нибудь незанятая работа» на быстром пути без барьеров, по теории консенсуса, невозможно. Во-вторых, оптимальное решение о приостановке требует информации о будущем: в идеале, если бы мы знали, что новая G станет готовой в следующий момент, не стоит приостанавливать M сейчас. Но момент, когда G становится готовой, случаен (представьте веб-сервис, где каждый входящий запрос создаёт G), и его нельзя предвидеть.
Ряд наивных подходов нежизнеспособен: централизованное управление всем состоянием требует глобальной блокировки, которая становится узким местом при большом числе конкурирующих сущностей; немедленное пробуждение M при каждом появлении готовой G приводит к «дребезгу» потоков, поскольку «сразу после пробуждения в следующий момент снова нет работы»; безусловное пробуждение дополнительного потока при каждом событии порождает множество бесполезных циклов park/unpark, когда поток «просыпается, не находит работы и немедленно засыпает снова».
Решение Go — ввести состояние вращения для рабочих потоков: M, не нашедший работы ни в одной локальной очереди, в глобальной очереди и в сетевом пуллере, не засыпает немедленно, а сначала кратко вращается в поисках работы. Ключевые принципы таковы:
- при пробуждении G сначала проверить, существует ли уже вращающийся поток (
sched.nmspinning); если да — не будить дополнительный поток и дать уже ищущему работу поймать её; - новый поток пробуждается только тогда, когда существует свободный P и нет ни одного вращающегося потока;
- когда последний вращающийся поток находит работу и прекращает вращение, в его замену пробуждается новый вращающийся поток.
Этот набор правил устраняет необоснованные всплески пробуждений потоков, сохраняя при этом верхнюю границу аппаратного параллелизма. Это можно представить как кассы в банке: расторопные клиенты (вращающиеся M) уже стоят наготове у любого открывшегося окошка (G, готовая к выполнению), и только когда все уже заняли позиции, а окошко всё ещё пустует, мы приглашаем нового клиента.
Тонкость реализации состоит именно в требовании, чтобы переходы между состоянием вращения и его отсутствием стыковались без зазоров, — иначе возникает гонка между «добавлением новой G» и «прекращением вращения потоком», где каждая из сторон полагает, что вторая обработает ситуацию, в результате чего ни одна не делает этого и процессор оказывается недозагружен. С этой целью обе стороны вставляют барьер в стиле StoreLoad: когда G становится готовой, сначала ставим G в локальную очередь, затем барьер, затем проверяем nmspinning; когда поток прекращает вращение, сначала уменьшаем nmspinning, затем барьер, затем повторно сканируем все локальные очереди, чтобы убедиться в отсутствии оставшейся работы. Два барьерных прохода перекрещиваются, гарантируя отсутствие окна, в котором «только что добавленная G остаётся невостребованной». Следует отметить, что данная логика пробуждения применяется только к локальным очередям P; добавление работы в глобальную очередь пробуждение потоков не инициирует.
На этом три единицы планирования и несущие их потоки обозначены в полной мере: G — стековая сопрограмма, подлежащая планированию; M — поток, выполняющий работу; P — разрешение, связывающее их и несущее локальные ресурсы. Как они взаимодействуют в каждом цикле планирования — тема раздела 9.4 Цикл планирования и 9.5 Похищение работы.
Дополнительная литература
- Melvin E. Conway. “Design of a Separable Transition-Diagram Compiler.” Communications of the ACM, 6(7), 1963. https://doi.org/10.1145/366663.366704 (происхождение термина и концепции сопрограммы)
- Ana Lúcia de Moura and Roberto Ierusalimschy. “Revisiting Coroutines.” ACM TOPLAS, 31(2), 2009. https://doi.org/10.1145/1462166.1462167 (таксономия симметричных/асимметричных, стековых/бесстековых сопрограмм, основанная на дизайне сопрограмм Lua)
- Bob Nystrom. “What Color is Your Function.” 2015. https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/ (проблема окраски функций, которую обходят стековые сопрограммы)
- Keith Randall. Contiguous Stacks (проектный документ Go), 2013. https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ (непрерывные стеки: реализация, снижающая фиксированные издержки «стековости»)
- Dmitry Vyukov. Scalable Go Scheduler Design Doc, 2012.
https://golang.org/s/go11sched (прообраз вращающихся потоков,
nmspinningи park/unpark) - Ron Pressler et al. JEP 444: Virtual Threads. OpenJDK, 2023. https://openjdk.org/jeps/444 (стековые виртуальные потоки Java Loom)
- Kotlin. KEEP: Coroutines.
https://github.com/Kotlin/KEEP/blob/master/proposals/coroutines
(бесстековые сопрограммы:
suspendкомпилируется в CPS-автомат состояний) - The Go Authors. runtime/runtime2.go, proc.go, stack.go.
https://github.com/golang/go/tree/master/src/runtime
(структуры
g/m/p/gobuf, константы состояний_G*,casgstatus)