Go under the hood
Go: Under the Hood

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 — это горутина, поэтому она естественным образом несёт собственный стек выполнения и «снимок точки останова», используемый для возобновления после приостановки:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// g: тело выполнения горутины (эскиз)
type g struct {
	stack        stack   // диапазон памяти стека [stack.lo, stack.hi)
	stackguard0  uintptr // граница для проверки переполнения стека; установка в stackPreempt превращает её в сигнал вытеснения
	sched        gobuf   // контекст выполнения: снимок регистров, сохраняемый при приостановке и восстанавливаемый при возобновлении
	atomicstatus uint32  // статус горутины (см. 9.3.4), должен читаться и записываться атомарно
	goid         uint64  // номер горутины
	m            *m      // M, на которой сейчас выполняется эта g (nil, если не выполняется)
	param        unsafe.Pointer // параметр, передаваемый пробудителем при пробуждении g
	preempt      bool    // сигнал вытеснения, копия stackguard0 = stackPreempt
	waitreason   waitReason // при статусе _Gwaiting: причина блокировки (для диагностики)
}

Поле sched типа gobuf — это физическое воплощение продолжения, обсуждавшегося в разделе 9.3.1:

1
2
3
4
5
6
7
8
// gobuf: контекст выполнения горутины, достаточный для возобновления с точки останова (эскиз)
type gobuf struct {
	sp   uintptr        // указатель стека
	pc   uintptr        // счётчик команд: следующая инструкция к выполнению
	g    guintptr       // горутина, которой принадлежит этот контекст
	ctxt unsafe.Pointer // контекст замыкания (обрабатывается особо как корень GC)
	bp   uintptr        // указатель фрейма (на архитектурах с включённым framepointer)
}

В горутине нет ничего магического: при создании точка входа выполняемой функции записывается в gobuf.pc, а аргументы копируются на стек выполнения; при приостановке текущие SP/PC и прочие регистры сохраняются обратно в gobuf; при возобновлении они восстанавливаются в реальные регистры и выполнение продолжается с точки останова. К atomicstatus необходим атомарный доступ, так как это поле конкурентно читается и записывается другими M (а также GC и системным монитором); именно оно хранит состояния конечного автомата из раздела 9.3.4.

M: сущность потока ОС

M соответствует реальному потоку ОС. Её наиболее важные поля вращаются вокруг вопроса «что поток должен нести с собой для выполнения Go-кода»:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// m: рабочий поток ОС (эскиз; оригинальная структура содержит более пятидесяти полей)
type m struct {
	g0       *g       // горутина, выделенная для выполнения кода планирования и рантайма (см. 9.3.3)
	curg     *g       // пользовательская горутина, выполняющаяся в данный момент
	p        puintptr // текущий связанный P (без P невозможно выполнять Go-код)
	mcache   *mcache  // кеш выделения памяти этого потока (фактически переходит вместе с P, см. 12.2)
	gsignal  *g       // горутина, выделенная для обработки сигналов (см. 9.7)
	tls      [tlsSlots]uintptr // потокозависимое хранилище, содержащее текущую g и прочее
	spinning bool     // находится ли поток в состоянии вращения в поисках работы (см. 9.3.6)
	alllink  *m       // ссылка в глобальный список allm
}

Каждый M содержит две специальные горутины — g0 и gsignal, которые не выполняют пользовательский код, а берут на себя планирование и обработку сигналов соответственно. curg — пользовательская горутина, выполняющаяся в данный момент. p — то самое «разрешение на выполнение»: M, лишившийся своего P (например, при входе в долгий системный вызов), не может продолжать выполнять Go-код.

P: локальные ресурсы для выполнения Go-кода

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// p: локальные ресурсы для выполнения Go-кода (эскиз)
type p struct {
	id     int32
	status uint32   // _Pidle / _Prunning / _Psyscall / _Pgcstop ...
	m      muintptr // связанный M (nil означает простой)
	mcache *mcache  // один кеш выделения памяти на каждый P (быстрый путь без блокировок, см. 12.2)

	// локальная очередь исполнения: кольцевой буфер без блокировок плюс приоритетный слот
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr // кольцевая очередь локальных исполнимых G, доступная без блокировок
	runnext  guintptr      // приоритетная G «выполнить следующей»: сохраняет локальность только что пробуждённой горутины
	gFree struct {         // мёртвые G, кешированные этим P: уже завершившиеся и пригодные для повторного использования (вместе со стеком)
		gList
		n int32
	}
}

Ключевым элементом 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. Шедулер должен балансировать между двумя противоборствующими требованиями: поддерживать достаточно работающих потоков, чтобы насытить аппаратный параллелизм, и при этом приостанавливать избыточные потоки для экономии процессорных ресурсов. Принцип «голубятни» делает это противоречие наглядным: пусть процесс имеет nn потоков M и пользователь создал pp горутин G; тогда при p>np > n существует p−np - n горутин, для которых в данный момент нет M (нужны дополнительные потоки — unpark); при p<np < n существует n−pn - p потоков M, у которых нет G для выполнения (они должны спать — park).

Найти оптимальный баланс сложно, и эта сложность проявляется в двух аспектах. Во-первых, множество M, каждый из которых держит локальную очередь и не видит состояния остальных, — это по существу распределённая система: нет глобальных часов, синхронизирующих все потоки, и вычисление глобального предиката вроде «есть ли где-нибудь незанятая работа» на быстром пути без барьеров, по теории консенсуса, невозможно. Во-вторых, оптимальное решение о приостановке требует информации о будущем: в идеале, если бы мы знали, что новая G станет готовой в следующий момент, не стоит приостанавливать M сейчас. Но момент, когда G становится готовой, случаен (представьте веб-сервис, где каждый входящий запрос создаёт G), и его нельзя предвидеть.

Ряд наивных подходов нежизнеспособен: централизованное управление всем состоянием требует глобальной блокировки, которая становится узким местом при большом числе конкурирующих сущностей; немедленное пробуждение M при каждом появлении готовой G приводит к «дребезгу» потоков, поскольку «сразу после пробуждения в следующий момент снова нет работы»; безусловное пробуждение дополнительного потока при каждом событии порождает множество бесполезных циклов park/unpark, когда поток «просыпается, не находит работы и немедленно засыпает снова».

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

  1. при пробуждении G сначала проверить, существует ли уже вращающийся поток (sched.nmspinning); если да — не будить дополнительный поток и дать уже ищущему работу поймать её;
  2. новый поток пробуждается только тогда, когда существует свободный P и нет ни одного вращающегося потока;
  3. когда последний вращающийся поток находит работу и прекращает вращение, в его замену пробуждается новый вращающийся поток.

Этот набор правил устраняет необоснованные всплески пробуждений потоков, сохраняя при этом верхнюю границу аппаратного параллелизма. Это можно представить как кассы в банке: расторопные клиенты (вращающиеся M) уже стоят наготове у любого открывшегося окошка (G, готовая к выполнению), и только когда все уже заняли позиции, а окошко всё ещё пустует, мы приглашаем нового клиента.

Тонкость реализации состоит именно в требовании, чтобы переходы между состоянием вращения и его отсутствием стыковались без зазоров, — иначе возникает гонка между «добавлением новой G» и «прекращением вращения потоком», где каждая из сторон полагает, что вторая обработает ситуацию, в результате чего ни одна не делает этого и процессор оказывается недозагружен. С этой целью обе стороны вставляют барьер в стиле StoreLoad: когда G становится готовой, сначала ставим G в локальную очередь, затем барьер, затем проверяем nmspinning; когда поток прекращает вращение, сначала уменьшаем nmspinning, затем барьер, затем повторно сканируем все локальные очереди, чтобы убедиться в отсутствии оставшейся работы. Два барьерных прохода перекрещиваются, гарантируя отсутствие окна, в котором «только что добавленная G остаётся невостребованной». Следует отметить, что данная логика пробуждения применяется только к локальным очередям P; добавление работы в глобальную очередь пробуждение потоков не инициирует.

На этом три единицы планирования и несущие их потоки обозначены в полной мере: G — стековая сопрограмма, подлежащая планированию; M — поток, выполняющий работу; P — разрешение, связывающее их и несущее локальные ресурсы. Как они взаимодействуют в каждом цикле планирования — тема раздела 9.4 Цикл планирования и 9.5 Похищение работы.

Дополнительная литература

  1. Melvin E. Conway. “Design of a Separable Transition-Diagram Compiler.” Communications of the ACM, 6(7), 1963. https://doi.org/10.1145/366663.366704 (происхождение термина и концепции сопрограммы)
  2. Ana Lúcia de Moura and Roberto Ierusalimschy. “Revisiting Coroutines.” ACM TOPLAS, 31(2), 2009. https://doi.org/10.1145/1462166.1462167 (таксономия симметричных/асимметричных, стековых/бесстековых сопрограмм, основанная на дизайне сопрограмм Lua)
  3. Bob Nystrom. “What Color is Your Function.” 2015. https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/ (проблема окраски функций, которую обходят стековые сопрограммы)
  4. Keith Randall. Contiguous Stacks (проектный документ Go), 2013. https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ (непрерывные стеки: реализация, снижающая фиксированные издержки «стековости»)
  5. Dmitry Vyukov. Scalable Go Scheduler Design Doc, 2012. https://golang.org/s/go11sched (прообраз вращающихся потоков, nmspinning и park/unpark)
  6. Ron Pressler et al. JEP 444: Virtual Threads. OpenJDK, 2023. https://openjdk.org/jeps/444 (стековые виртуальные потоки Java Loom)
  7. Kotlin. KEEP: Coroutines. https://github.com/Kotlin/KEEP/blob/master/proposals/coroutines (бесстековые сопрограммы: suspend компилируется в CPS-автомат состояний)
  8. 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)