9.5 Управление потоками
9.1 заложил трёхуровневую структуру GMP: G — единица выполнения на уровне пользователя, P — разрешение на планирование и носитель локальных ресурсов, а M — «нога», одолженная у операционной системы. Предыдущие разделы сосредоточились преимущественно на G и P; этот раздел переключает внимание на M и отвечает на ряд вопросов, откладывавшихся до сих пор: что такое M на самом деле, откуда он берётся, почему GOMAXPROCS ограничивает количество P, тогда как число потоков нередко оказывается больше, почему единственный блокирующий системный вызов не тормозит остальные G, и какую цену платит рантайм, когда пользователь хочет закрепить горутину за конкретным потоком (LockOSThread).
Через весь раздел проходит одна мысль: поток — это дорогой ресурс. Создание потока означает переход в режим ядра, выделение стека и регистрацию маски сигналов; уничтожение обходится не дешевле. Многие решения планировщика Go — повторное использование простаивающих M, передача P во время системного вызова, предохранитель в десять тысяч потоков — подчинены одному принципу: создавать как можно меньше, переиспользовать как можно больше.
9.5.1 M — это поток операционной системы
M (machine) — это абстракция одного потока операционной системы. При запуске процесса загрузочный поток оборачивается в m0 — глобальную переменную, существующую на протяжении всей жизни процесса и не размещаемую в куче; в дальнейшем каждый M соответствует потоку ядра, явно созданному рантаймом. Отношение M и G — «поток выполняет горутины»: получив P, M берёт G из локальной очереди P и исполняет их. Приведённый ниже упрощённый набросок оставляет лишь поля, связанные с управлением потоками:
|
|
Новый поток создаётся по цепочке newm -> newm1 -> newosproc. На Linux newosproc в конечном счёте сводится к единственному системному вызову clone(2), чьи флаги очерчивают границу между «потоком» и «процессом»:
|
|
Именно эти разделяемые компоненты отличают поток от процесса: адресное пространство, файловые дескрипторы и обработка сигналов общие, лишь регистры и стек остаются раздельными. Тем не менее создание потока обходится недёшево: оно требует перехода в режим ядра через clone, подготовки системного стека для g0 и установки маски сигналов (newosproc отключает сигналы через sigprocmask до вызова clone и восстанавливает их после, чтобы новый поток начинал с чистого состояния). После входа в mstart новый поток должен ещё пройти раунд инициализации в minit. Именно эта совокупность расходов лежит в основе принципа «переиспользуй вместо того, чтобы создавать без нужды». Попытка повтора при EAGAIN в newosproc с намёком «может потребоваться увеличить максимальное количество пользовательских процессов» лишний раз подтверждает: поток — дефицитный ресурс, на который операционная система накладывает квоты.
9.5.2 Повторное использование: простаивающий M на семафоре
Раз создание дорого, рантайм не уничтожает M, как только тот завершил работу. Когда M заканчивает текущее задание и временно не имеет P для привязки, он не завершается, а паркуется, ожидая повторной диспетчеризации. Эту пару «парковка — возобновление» реализуют две функции: stopm и startm.
stopm возвращает текущий M в глобальный список простаивающих, после чего M засыпает на своём park:
|
|
Ядро mPark — вызов notesleep(&gp.m.park). note — внутренний одноразовый примитив событий рантайма, который на каждой платформе реализуется через соответствующий механизм сна ядра: futex или семафор. Иными словами, запаркованный M не занимает CPU; он спит внутри ядра, ожидая вызова notewakeup, который его разбудит.
Путь пробуждения проходит через startm: когда P нужен поток для работы (появилась готовая G, системный вызов передал свой P и т. п.), startm сначала вызывает mget, чтобы извлечь запаркованный M из списка простаивающих, записывает целевой P в его nextp и будит его через notewakeup на park; и только когда список простаивающих оказывается пуст, функция обращается к newm и действительно создаёт новый поток. Именно этот порядок — «сначала переиспользовать, создавать лишь когда взять нечего» — не даёт созданию потоков попасть на горячий путь выполнения.
stateDiagram-v2
[*] --> Spinning: newm / пробуждение
Spinning --> Running: захватил P и G
Running --> Spinning: нет локальной G, идёт красть
Running --> Syscall: входит в системный вызов
Syscall --> Running: exitsyscall возвращает P
Spinning --> Parked: нет P для привязки, stopm паркует
Parked --> Spinning: startm будит (mget + notewakeup)
Running --> [*]: mexit (редко, обычно вызывается LockOSThread)
Стоит подчеркнуть: на нормальном пути M практически никогда не завершается. mexit вызывается лишь в нескольких ситуациях, наиболее типичная из которых описана в разделе 9.5.6: «G, заблокировавшая поток, завершается, не сняв блокировку». В обычном режиме M функционирует в цикле «парковка, пробуждение, снова парковка» — подобно постоянному дежурному, всегда готовому к вызову, а не временному работнику, нанятому и уволенному за один раз.
9.5.3 GOMAXPROCS ограничивает P, а не M
Читатели нередко придерживаются ошибочного представления: установить GOMAXPROCS в 8 — значит иметь не более 8 потоков. На самом деле ограничивается количество P, то есть верхняя граница параллелизма при выполнении кода Go, но не число M. Число M определяется динамически — «сколько потоков в данный момент имеют работу» — и вполне может превышать GOMAXPROCS.
Наиболее распространённая причина превышения — системные вызовы. Когда M застревает в блокирующем системном вызове (9.5.4), его P передаётся другому M для выполнения остальных G; в этот же момент сосуществуют «M, застрявший в системном вызове», и «M, захвативший P», и суммарное число потоков превышает число P. LockOSThread и extra-M для обратных вызовов cgo также ведут к тому, что M оказывается больше, чем P. С другой стороны, P — это «разрешение на выполнение кода Go», чьё общее число неизменно ограничено; M — лишь «нога», одолженная для выполнения кода; когда одна «нога» спотыкается о системный вызов, её P занимает другая, а спотыкнувшаяся разрешения не держит.
Оставлять число потоков неограниченным опасно: бесконтрольные системные вызовы или обратные вызовы cgo могут привести к тому, что рантайм будет создавать потоки без ограничений и в конечном счёте уничтожит весь процесс. Поэтому Go устанавливает предохранитель — sched.maxmcount, по умолчанию равный 10 000:
|
|
Как только число потоков достигает этой отметки, программа аварийно завершается с сообщением thread exhaustion. Порог установлен не для нормальных программ, а как предохранитель, который «срабатывает рано при аномалии, не давая положить машину». Пользователь может изменить его через debug.SetMaxThreads (реализовано через setmaxthreads; передача -1 возвращает текущее значение). Обратите внимание: checkmcount исключает extra-M, поскольку их количество определяется параллелизмом внешних обратных вызовов — это отдельный счёт от «потоков, созданных самим кодом Go».
9.5.4 Системные вызовы и передача P
Это ключевой момент раздела. 9.1 дал обещание: горутина, застрявшая в блокирующем системном вызове, не будет морить голодом остальные горутины на том же P. Механизм, выполняющий это обещание, — именно «передача P во время системного вызова».
Интуиция такова: M собирается войти в системный вызов, который может надолго не возвращать управление, и в это время он не может выполнять код Go, а значит, P в его распоряжении простаивает. Вместо того чтобы заставлять P ждать вместе с ним, лучше отсоединить P и передать его другому M для обслуживания остальных G из очереди. После возврата из системного вызова исходный M пытается снова получить P и продолжить работу. Вокруг этой идеи рантайм различает быстрый и медленный пути.
Вход в системный вызов происходит через entersyscall (построен на reentersyscall). Функция переводит G в состояние _Gsyscall, сохраняет стек и PC для обратной трассировки GC, записывает указатель текущего P в m.oldp и копирует p.syscalltick, чтобы впоследствии определить, «был ли P изъят». Ключевой момент: entersyscall не освобождает P и не меняет его состояние. m.p по-прежнему указывает на исходный P, P остаётся в состоянии _Prunning, изменяется лишь то, что G перешла в _Gsyscall. Рантайм оптимистично рассчитывает, что системный вызов вернётся быстро, и оставляет P нетронутым на M в расчёте воспользоваться им сразу по возврате. oldp — лишь пометка «какой P использовался до входа в системный вызов», доступная медленному пути для попытки вернуть P в случае, если тот был изъят.
|
|
По возврате управление переходит в exitsyscall, которая сначала оптимистично переводит G обратно в _Grunning, затем проверяет, сохранился ли P (pp := gp.m.p.ptr()):
- Быстрый путь: если
m.pпо-прежнему ненулевой (системный вызов был столь коротким, что sysmon не успел изъять P), P используется немедленно, без единой блокировки. P никуда не уходил, поэтому расходов на повторную привязку нет. - Медленный путь: если P уже был изъят sysmon (
m.p == nil), вызываетсяexitsyscallTryGetP(oldp)для попытки вернуть исходныйoldp; если это не удаётся — захватывается свободный P; если и это невозможно — G помещается обратно в глобальную очередь и вызываетсяstopmдля парковки.
Так кто же и когда изымает P? Ответ — поток-монитор sysmon (9.8). sysmon периодически обходит все P и в retake сравнивает syscalltick каждого P в состоянии _Prunning: если обнаруживается, что M в имени данного P застрял в системном вызове более чем на один тик sysmon (не менее 20 мкс), он действует и изымает P. Здесь в игру вступает относительно новый механизм — setBlockOnExitSyscall: он сначала приостанавливает этот поток, не давая ему упреждающе вернуть себе P внутри exitsyscall, затем takeP отсоединяет P от данного M, и наконец handoffp передаёт P другому M. Этот порог позволяет тратиться на передачу лишь для системных вызовов, «действительно заблокировавшихся надолго»; короткий системный вызов возвращается по быстрому пути задолго до того, как sysmon успевает среагировать.
Описанная конструкция — «P сохраняет состояние, sysmon принудительно изымает» — результат недавней эволюции. В прежней реализации при входе P в системный вызов ему назначалось специальное состояние
_Psyscall, и возвращающийся M либо sysmon оспаривали владение через CAS по этому состоянию. В Go 1.26_Psyscallбыл убран (в исходном коде он понижен до_Psyscall_unused); вместо этого sysmon активно и явно изымает P черезsetBlockOnExitSyscall/takeP, не полагаясь больше на то, что M «самостоятельно обнаружит» потерю P. Семантика осталась прежней, но автомат состояний стал проще, а окно конкуренции — чётче.
|
|
Если изобразить всю цепочку в виде временно́й диаграммы, сразу становится наглядным, как «один системный вызов блокируется, а остальные G продолжают работу»:
sequenceDiagram
participant M1 as M1 (застрявший в системном вызове)
participant P as P (держит G для выполнения)
participant SM as sysmon
participant M2 as M2 (подхватывает работу)
M1->>M1: entersyscall: G переходит в Gsyscall, P не освобождается (m.p сохраняется, oldp записывается как пометка)
M1->>SM: при необходимости будит sysmon
Note over M1: медленный системный вызов, долго не возвращается
SM->>P: обход retake, сравнивает syscalltick, задержка более ~20 мкс
SM->>M1: setBlockOnExitSyscall (приостановка, запрет упреждающего возврата P)
SM->>P: takeP отсоединяет P от M1 (очищает m.p у M1)
SM->>M2: handoffp вызывает startm (переиспользует или создаёт M2)
M2->>P: acquirep, затем выполняет остальные G на P
Note over M1: системный вызов наконец возвращается
M1->>M1: медленный путь exitsyscall: m.p уже пуст, возврат P через oldp или захват свободного P, при неудаче — stopm паркует
Если изменить сценарий на «короткий системный вызов», шаги sysmon вообще не происходят, m.p у M1 никогда не очищается, и exitsyscall видит P на месте и продолжает использовать его напрямую — весь быстрый путь обходится без единой блокировки. Два пути — быстрый и медленный — делают типичный случай практически безрасходным, гарантируя при этом, что редкая длительная блокировка не снизит параллелизм. Именно этот механизм выполняет обещание из 9.1.
Следует разграничивать разные виды блокировок. Сетевой I/O и таймеры не идут по описанному выше пути «блокировка с удержанием M»; они передаются сетевому опросчику netpoll (9.9): G приостанавливается, M и P немедленно идут выполнять другие G, а когда I/O готов, netpoll переводит G обратно в состояние готовности. По-настоящему занимают M синхронные системные вызовы, не поддающиеся асинхронному исполнению, — файловый I/O,
fork/exec, — а также вызовы cgo; именно они нуждаются в передаче P как страховке.
9.5.5 Обратные вызовы cgo и extra-M
Все рассмотренные до сих пор M были созданы самим рантаймом через clone, и рантайм знает их происхождение и состояние. Но существует и другой вид потоков — те, что Go не создавал: когда код на C вызывает обратный вызов в Go на потоке, который Go не создавал (cgo callback), у этого потока нет g0, нет P, рантайм ничего о нём не знает, и тем не менее код Go должен на нём выполняться.
Ответ рантайма — заготовить набор extra-M. Они предварительно выделяются функцией oneNewExtraM, навешиваются на отдельный список; каждый extra-M несёт собственную горутину-заглушку в состоянии _Gdeadextra и связан с ней взаимно через lockedg/lockedm. Когда внешний поток осуществляет обратный вызов, needm берёт extra-M из этого списка и устанавливает его на себя, тем самым получая g0 и контекст, необходимые для выполнения кода Go; по завершении обратного вызова dropm возвращает extra-M обратно. mstartm0 вызывает newextram на раннем этапе запуска рантайма, обеспечивая наличие в списке хотя бы одного extra-M — иначе при первом входящем обратном вызове произойдёт дедлок.
|
|
extra-M не учитываются в maxmcount из 9.5.3, поскольку их количество определяется параллелизмом внешних обратных вызовов и не относится к категории «потоков, созданных кодом Go». Когда хост-процесс многократно вызывает один и тот же поток C через ключ pthread для обратных вызовов, cgoBindM также привязывает extra-M к этому потоку C, избавляя от расходов на заимствование и возврат при каждом вызове. Этот механизм — необходимый слой склейки между мирами Go и C, а также ещё одна причина, по которой число потоков превышает GOMAXPROCS.
9.5.6 LockOSThread
До сих пор M были свободно взаимозаменяемы: не имело значения, какой M выполняет какую G. Но некоторые сценарии требуют, чтобы горутина всегда выполнялась на одном и том же потоке ОС, — именно для этого предназначена runtime.LockOSThread. Потребность возникает в двух случаях: во-первых, ряд библиотек C (как правило, графических — OpenGL, GLib) хранит состояние в thread-local storage (TLS) и должен вызываться строго на фиксированном потоке; во-вторых, программа изменила состояние ядра потока через системный вызов (например, применив unshare с CLONE_NEWNS для помещения потока в изолированное пространство имён Linux), после чего этот поток «приватизирован» и более не пригоден для использования другими горутинами.
Внутренняя функция рантайма lockOSThread проста: она увеличивает счётчик и вызывает dolockOSThread, которая заставляет g и m указывать друг на друга:
|
|
Публичная функция LockOSThread, доступная пользователю, делает ещё один шаг: лениво запускает шаблонный поток (template thread) по требованию. Это контрмера против скрытой проблемы, которую несёт с собой блокировка потока. Как только поток «приватизирован» пользователем (изменены пространство имён, маска сигналов и т. п.), его состояние ядра становится «нестандартным», и клонирование нового потока с его помощью скопировало бы эту нестандартность. Шаблонный поток — это резервный поток, всегда находящийся в «заведомо корректном состоянии»; он не выполняет пользовательские G и отвечает исключительно за безопасное создание новых потоков. Поэтому в newm есть проверка: если функция обнаруживает, что выполняется на заблокированном M или потоке cgo, она не клонирует поток самостоятельно, а помещает запрос на создание потока в список newmHandoff, предоставляя шаблонному потоку выполнить его вместо неё.
Но как простая запись двух полей lockedg/lockedm гарантирует, что g выполняется только на данном m? Ответ скрыт в цикле планирования (9.4). В самом начале schedule проверяет, есть ли у текущего M заблокированная g:
|
|
И обратно: когда заблокированная g не может немедленно выполняться по какой-либо причине (например, заблокирована), stoplockedm передаёт P этого M другому через handoffp, паркует его и ждёт, пока g снова не станет готовой к выполнению и не разбудит его — тогда acquirep забирает P обратно для её эксклюзивного обслуживания. Здесь и проявляется цена: данный M монополизирован одной g и не может обслуживать другие; P приходится передавать туда-обратно всякий раз, когда g блокируется; а если заблокированная g завершается без вызова UnlockOSThread, рантайм просто завершает этот M вместе с g (mexit) — это одна из немногих ситуаций, когда M завершается на нормальном пути. UnlockOSThread лишь уменьшает счётчик, и когда он достигает нуля, очищает оба поля без какой-либо специальной обработки.
Именно из-за этих побочных эффектов LockOSThread едва ли можно назвать образцовой возможностью. Она создаёт немало трудностей в управлении для планировщика, и единственная причина её существования — поддержка написанных в прошлом веке библиотек C, зависящих от thread-local состояния. Если бы экосистема была достаточно богата, чтобы обходиться без таких библиотек, эта возможность вполне могла бы не существовать.
9.5.7 Кто управляет потоками: родословная
Рассмотрение подхода Go в историческом контексте позволяет лучше понять сделанные компромиссы. Вопрос «как пользовательский код отображается на потоки ядра» исторически решался несколькими типичными способами:
- 1:1 (каждый пользовательский поток соответствует одному потоку ядра): сюда относятся потоки POSIX и ранняя модель потоков в Java. Просто и прямолинейно, но создание, переключение и память (у каждого потока довольно большой стек) оцениваются в расчёте на один поток ядра, и с ростом параллелизма подход перестаёт справляться.
- N:1 (множество пользовательских потоков на одном потоке ядра): ранние «зелёные потоки» работали именно так. Переключение крайне дёшево, но есть фатальный изъян: стоит одному пользовательскому потоку выполнить блокирующий системный вызов — и единственный поток ядра вместе со всеми пользовательскими потоками останавливается; кроме того, невозможно задействовать несколько ядер.
- Пул потоков: не решает проблему модели отображения, лишь амортизирует стоимость создания, накапливая потоки для повторного использования. Не отвечает на вопрос «что если задача блокируется»: заблокированная задача продолжает занимать поток в пуле.
- M:N динамическое управление (подход Go): M горутин мультиплексируются на N потоков ядра, причём рантаймовый планировщик выступает посредником между ними. Это объединяет дешевизну переключения из N:1 с возможностью использовать несколько ядер и устойчивостью к блокировкам из 1:1: переключение на уровне пользователя не требует входа в ядро, экономя стоимость схемы 1:1; а опираясь на передачу P из этого раздела и повторное использование M из 9.5.2, он обходит фатальный изъян N:1 — «одна блокировка останавливает всё». Цена — значительный рост сложности рантайма; разделы этой главы, предыдущие и последующие, и есть развёртывание этой сложности.
Данная линия рассуждений не уникальна для Go. В Erlang/BEAM давно существует планировщик, отображающий лёгкие процессы на небольшое число потоков ОС; внутренняя практика Google в области fiber имеет тот же исток. Особого внимания заслуживает Java: долгое время она придерживалась модели 1:1, а виртуальные потоки, официально поставленные с JDK 21 в 2023 году (Project Loom, JEP 444), по существу являются движением в сторону Go — мультиплексированием большого числа виртуальных потоков на небольшое число потоков-носителей с отмонтированием виртуального потока от носителя при блокирующем вызове, чтобы носитель мог выполнять другие виртуальные потоки. Это та же инженерная интуиция, что и передача P в данном разделе и освобождение M горутиной во время системного вызова. Схождение двух независимо развивавшихся направлений к схожему решению само по себе свидетельствует: в условиях ограничений «требуется массовый параллелизм, дешёвое переключение и устойчивость к блокировкам одновременно» M:N динамическое управление — почти неизбежный ответ.
Выгоды производительности никогда не достаются бесплатно. Go сосредотачивает всю сложность управления потоками в рантайме: парковка и пробуждение, передача P, патрулирование sysmon и многочисленные частные случаи extra-M и шаблонного потока. Пользователь при этом может практически не подозревать о существовании потоков — писать десятки тысяч горутин, не задумываясь, на каком потоке они окажутся. За этим удобством «делать потоки невидимыми для пользователя» стоит стопка механизмов, которые рантайм берёт на себя.
Дополнительная литература
- The Go Authors. runtime/proc.go (
newm/newm1/mstart,stopm/startm/mPark,entersyscall/exitsyscall/reentersyscall,handoffp/retake,acquirep/releasep,checkmcount, а также связанные с extra-M функцииnewextram/oneNewExtraM/needm/dropm). https://github.com/golang/go/blob/master/src/runtime/proc.go - The Go Authors. runtime/os_linux.go (
newosprocиcloneFlags, создание потока черезclone). https://github.com/golang/go/blob/master/src/runtime/os_linux.go - Linux man-pages. clone(2). https://man7.org/linux/man-pages/man2/clone.2.html
(семантика флагов
CLONE_VM/CLONE_THREAD, граница между потоком и процессом) - The Go Authors. Документация runtime.LockOSThread / UnlockOSThread. https://pkg.go.dev/runtime#LockOSThread
- Трекер задач Go: #20458 (уточнение семантики
LockOSThread), #20395 (завершение заблокированного потока при выходе горутины), #21827 (происхождение шаблонного потока), #22227 (отключение newmHandoff на plan9), #18023 (аномальное замедление из-заLockOSThread), #14592 (завершение простаивающих потоков ОС). https://github.com/golang/go/issues - Ulrich Drepper. Futexes Are Tricky. Red Hat, 2011. https://www.akkadia.org/drepper/futex.pdf (семантика и подводные камни futex, от которых зависит приостановка/возобновление M)
- Sape Mullender, Russ Cox. Semaphores in Plan 9. 2008. https://swtch.com/semaphore.pdf
(проектный исток примитивов
noteиsemaphoreрантайма) - Ron Pressler, Alan Bateman. JEP 444: Virtual Threads. OpenJDK, 2023. https://openjdk.org/jeps/444 (Project Loom: движение Java к модели M:N)
- Эта книга: 9.1 Задача планирования и модель GMP, 9.4 Цикл планирования, 9.8 Системный мониторинг, 9.9 Сетевой опросчик.