Назад к блогу

Что внутри планировщика горутин Go: G, M, P и как всё крутится

Что внутри планировщика горутин Go: G, M, P и как всё крутится

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

Планировщик Go устроен вокруг трёх сущностей: G, M и P. Разобраться в их взаимодействии сложно, потому что большая часть работы происходит до main, а правила «кто кого может исполнять» размазаны по десяткам функций. Ниже — механика по шагам: что инициализируется при старте, почему M без P бесполезен, как рождается горутина, как она паркуется и как системный монитор отбирает P у зазевавшихся потоков.

Bootstrap: что инициализируется до main

До запуска main рантайм должен подготовить структуры, которыми будут пользоваться планировщик и системные горутины. Этим занимается schedinit. Первым делом она инициализирует набор блокировок рантайма, чтобы к моменту появления конкурентных исполнителей они уже были готовы:

  • sched.lock защищает поля планировщика schedt, включая счётчики M и очереди;
  • sysmonlock нужен, чтобы удержанием этого мьютекса блокировать sysmon от взаимодействия с остальным рантаймом;
  • deferlock и sudoglock — центральные блокировки для пулов структур _defer и sudog соответственно;
  • deadlock и paniclk используются при фатальных ситуациях: первая — при обнаружении взаимной блокировки, вторая — при панике;
  • allglock защищает список всех горутин allgs, а allpLock — массив всех P allp.

Отдельно инициализируется sched.midle — список свободных M, ожидающих работу.

Затем schedinit вызывает mcommoninit(gp.m, -1), чтобы подготовить текущий M. Поскольку id передан как -1, ветка с явным присваиванием не выполняется, и M получает идентификатор через mReserveID: под sched.lock берётся текущее значение sched.mnext, оно увеличивается на единицу, а checkmcount проверяет, не превышен ли sched.maxmcount — при превышении печатается сообщение и делается throw("thread exhaustion"). Новый M сразу считается «running» для checkdead. Помимо идентификатора, mcommoninit заполняет слабый указатель на самого себя, инициализирует генератор случайных чисел и профилирование, при наличии сигнального стека задаёт его границу, добавляет M в список allm через атомарную запись (чтобы другие потоки, итерирующие по allm без sched.lock, видели корректный указатель), а на некоторых платформах выделяет буфер для трассировки cgo.

Следующий шаг — procresize. Число procs — это целевое количество P, которое берётся либо из переменной окружения GOMAXPROCS, либо из defaultGOMAXPROCS(numCPUStartup). При росте числа P массив allp расширяется, и новые P инициализируются через pp.init(i); при уменьшении для лишних P вызывается pp.destroy(), который освобождает ресурсы и переводит P в состояние _Pdead. procresize возвращает список P с локальной работой, которые вызывающий должен запланировать. На этапе bootstrap этот список обязан быть пустым — запущенных горутин ещё нет, поэтому не-nil результат означает, что на новом P уже есть готовая к запуску горутина, чего быть не может. В этом случае срабатывает throw("unknown runnable goroutine during bootstrap").

Почему M без P не может исполнять Go-код

Право M исполнять горутину определяется наличием у него P. Связывание выполняет wirep — первый шаг функции acquirep. Она проверяет, что у текущего M ещё нет P, что у самого P нет M и что его статус — _Pidle, иначе бросает ошибку. Затем устанавливает gp.m.p в этот P, pp.m в текущий M и переводит pp.status в _Prunning. После этого M считается владельцем P.

acquirepNoTrace — это внутренняя часть acquirep без трассировочных событий: сначала вызывает wirep, затем запоминает текущий M как «старый» (он понадобится при следующем освобождении P) и подготавливает кэш аллокатора P к возможной очистке, потому что этот кэш мог остаться от предыдущего владельца.

releasepNoTrace делает обратное: проверяет, что у M есть P, что P действительно принадлежит этому M и находится в _Prunning, освобождает отложенную работу по маркировке GC, а затем обнуляет gp.m.p и pp.m и переводит pp.status в _Pidle. Именно на эту связку опирается schedule(): он берёт pp := mp.p.ptr() и вызывает findRunnable(). Если у M нет P, брать очередь неоткуда — исполнять горутину нечем.

Локальные и глобальные очереди, work stealing

У каждого P есть локальная очередь готовых горутин. С ней работают несколько функций, и у них разные правила доступа. runqput, runqputslow, runqputbatch, runqget и runqdrain помечены как выполняемые только владельцем P: только он может безопасно менять хвост очереди и слот runnext. runqput пишет элемент в кольцевой буфер и публикует его через атомарную запись с release-семантикой, а runqget снимает runnext через CAS — и только текущий P может установить его в ненулевое значение. runqgrab, наоборот, может вызываться любым P: она только читает голову и хвост очереди через атомарные загрузки с acquire-семантикой и забирает элементы через атомарный CAS головы, то есть конкурирует с владельцем как потребитель, а не как производитель. runqsteal использует runqgrab, чтобы украсть половину элементов из очереди другого P и положить их в свою локальную очередь, после чего сама публикует свой хвост.

Если работать с локальной очередью без владения P, запись хвоста и runnext пойдёт без синхронизации с владельцем, что нарушит инварианты, на которые опираются runqget и runqgrab: например, проверку согласованности головы и хвоста или проверку переполнения при краже.

Отдельного внимания заслуживает слот runnext. Когда runqput вызывается с флагом «положить в runnext», он в цикле читает текущее значение слота и пытается заменить его указателем на новую горутину через CAS. Если слот был пуст, на этом всё заканчивается. Если там уже кто-то был, старая горутина вытесняется и отправляется в обычную локальную очередь (или в глобальную, если очередь полна). Горутина, снятая из runnext функцией runqget, получает флаг наследования времени: она доживает оставшийся квант текущей горутины, а не начинает новый.

Когда локальная очередь заполнена — то есть число элементов достигло её длины, — runqput вызывает runqputslow. Тот вычисляет половину от текущего числа элементов и проверяет, что это ровно половина длины очереди (иначе throw("runqputslow: queue is not full")). Из очереди копируется эта половина, к ней добавляется сама новая горутина, голова сдвигается атомарным CAS, и весь батч из n+1 элементов уходит в глобальную очередь под sched.lock через globrunqputbatch, который добавляет все элементы и очищает батч. Половина берётся потому, что при заполненной очереди число элементов равно её длине, и деление пополам даёт ровно половину длины.

Создание горутины: newproc1

При создании горутины рантайм сначала пытается переиспользовать уже существующую структуру G. Этим занимается gfget(pp): она смотрит в локальный список свободных G у P. Если он пуст, но в глобальных списках (отдельно для G со стеками и без) что-то есть, под блокировкой sched.gFree.lock переносится партия в локальный список, пока в нём меньше 32 элементов, причём предпочтение отдаётся G со стеками; после переноса попытка повторяется. Если и после этого взять нечего, переиспользовать структуру не удаётся. Для полученной G проверяется стек: если он есть, но его размер не совпадает с startingStackSize, старый стек освобождается, а поля стека обнуляются; если стека нет вовсе, выделяется новый через stackalloc(startingStackSize) и задаётся stackguard0. Быстрый путь предпочтительнее, потому что он берёт готовую G из локального списка без блокировки глобального и без обязательного выделения стека — стек аллоцируется только тогда, когда его не было.

Если готовой G нет, создаётся новая. Её стек вычисляется так: к запрошенному размеру stackMin прибавляется системный резерв stackSystem, а сумма округляется вверх. Память под стек выделяется под системным стеком и сохраняется в G. Затем задаются границы: stackguard0 — нижняя граница плюс защитный запас, stackguard1 — все единицы, и обнуляется нижнее слово стека. По этим полям вытеснение и проверка переполнения работают так: каждый вызов в горутине сравнивает указатель стека с stackguard0, поэтому выставление туда специального значения сворачивает запрос на вытеснение в обычную проверку переполнения.

После получения G её статус переводится из _Gidle в _Gdead через casgstatus, и G публикуется в списке всех горутин. casgstatus меняет статус атомарно: сначала проверяет, что ни старое, ни новое значение не содержат бит _Gscan и что они не равны — иначе печатает сообщение и бросает throw("casgstatus: bad incoming values"). Затем в цикле выполняется CAS по gp.atomicstatus. Если CAS не удался и старое значение — _Gwaiting, а текущий статус уже _Grunnable, бросается throw("casgstatus: waiting for Gwaiting but is Grunnable"). При неудаче CAS цикл повторяется, ожидая, пока GC завершит сканирование и вернёт статус к исходному: сначала до десяти раз применяется procyield(1), затем osyield() с пересчётом времени следующей попытки. Атомарность нужна потому, что все чтения и записи статуса G идут через readgstatus, casgstatus, castogscanstatus и casfrom_Gscanstatus, а GC может параллельно выставлять бит _Gscan. После успешного CAS, если у G есть привязанная группа synctest, ей сообщается о смене статуса.

Наследование полей от родителя устроено неодинаково. Группа synctest и метки pprof копируются только для пользовательских горутин — для системных вместо этого увеличивается счётчик sched.ngsys, а ветка наследования пропускается, потому что системные горутины не должны входить в synctest-группы и нести метки pprof. Поля, связанные с режимами FIPS и DIT, копируются от родителя безусловно, а признак секретного режима выставляется только при включённом соответствующем эксперименте и непустом значении у родителя.

Парковка и пробуждение: gopark

Когда горутина должна подождать, она вызывает gopark. Тот сохраняет в M параметры ожидания — блокировку, функцию проверки, причину и данные трассировки — и переключается на системный стек через mcall(park_m), потому что дальнейшие действия нельзя выполнять на стеке паркуемой горутины. park_m работает уже на стеке g0: при необходимости увеличивает счётчик активных в synctest-группе, переводит горутину из _Grunning в _Gwaiting и вызывает dropg(). dropg() разрывает связь между M и горутиной: обнуляет m.curg и обратную ссылку curg.m. Только после этого вызывается функция проверки, переданная в gopark. Если проверка не подтверждает, что горутину можно парковать, горутина переводится обратно в _Grunnable и возвращается к исполнению. Перевод в _Gwaiting происходит до вызова проверки потому, что горутина может быть разбужена к моменту вызова, если нет внешней синхронизации, запрещающей это.

Как sysmon отбирает P и вытесняет горутины

retake сначала пропускает все P, у которых нет M или статус не _Prunning — такие не выполняются, отбирать нечего. Для остальных сравнивается счётчик смен временного среза schedtick с сохранённым значением: если они различаются, P сменил срез, и retake лишь обновляет сохранённые значения. Если счётчик не изменился и с момента последней записи прошло не меньше forcePreemptNS, вызывается preemptone(pp) и выставляется sysretake — это порог по времени, а не по счётчику системных вызовов.

Далее retake пытается заблокировать выход P из системного вызова. Если P действительно в системном вызове и sysretake не выставлен, сравнивается syscalltick с сохранённым: при различии сохранённые значения обновляются, поток возобновляется, и отбор не происходит. Если счётчик не менялся, проверяется, пуста ли очередь P, есть ли свободные или крутящиеся M и не истёк ли дополнительный интервал в 10 миллисекунд: при выполнении всех условий поток возобновляется, и P не отбирается. Только если эти условия не выполнены, P отбирается: он переводится в _Pidle, поток возобновляется, и P передаётся другой нити через handoffp.

preemptone(pp) — это best-effort попытка попросить горутину на P остановиться. Она берёт M и текущую горутину и отказывается работать, если M нет, если это текущий M, если горутины нет или это g0, а также если горутина находится в системном вызове. Затем выставляет флаг вытеснения и подменяет stackguard0 на специальное значение, чтобы вытеснение свернулось в обычную проверку переполнения стека, которую делает каждый вызов. Для асинхронного вытеснения, если оно поддерживается и не отключено, выставляется флаг у P, статус горутины захватывается через перевод в _Gscanrunning, проверяется, что горутина всё ещё на том же M, и посылается сигнал вытеснения; иначе статус возвращается обратно.

handoffp(pp) решает, что делать с освободившимся P. Если у P есть локальная работа, в глобальной очереди есть работа, есть работа по трассировке или работа для сборщика мусора — сразу запускается новый M с этим P. Если работы нет, но нет и крутящихся или простаивающих M, предпринимается попытка перевести счётчик крутящихся M из нуля в единицу и запустить M в режиме поиска работы. В самом конце, если работы так и не нашлось, P кладётся в список простаивающих.

Пограничные случаи и что видит клиент

При выходе из системного вызова exitsyscall сначала пытается атомарно перевести горутину из _Gsyscall в _Grunning; если CAS не удался (например, у горутины есть synctest-группа), используется casgstatus. Затем забирается старый P, сохранённый у M, и если его нет, предпринимается попытка получить простаивающий P из общего списка. Если P получить не удалось, управление уходит в exitsyscallNoP: горутина переводится в _Grunnable и помещается в глобальную очередь, а счётчик горутин в системных вызовах без P уменьшается — но только если поток не является C-потоком.

При одновременных кандидатах на слот runnext работает та же механика, что и при обычной постановке: новая горутина кладётся через CAS, а вытесненная старая отправляется в обычную локальную очередь.

Что из этого следует на практике

  • Число P ограничивает параллелизм исполнения Go-кода. M без P не может взять горутину из очереди, поэтому увеличение числа потоков ОС само по себе не увеличит параллелизм: всё упирается в GOMAXPROCS.
  • Локальные очереди P — не потокобезопасная структура общего пользования. Только владелец P может писать в хвост очереди и в слот runnext; остальные P могут лишь забирать элементы из головы через атомарные операции. Поэтому кража работы всегда идёт с одного конца, а добавление — с другого.
  • Слот runnext даёт приоритет и наследование кванта. Свежесозданная или только что разбуженная горутина, попавшая в runnext, будет исполнена следующей и доживёт остаток текущего временного среза, а не начнёт новый.
  • Переиспользование G дешевле создания. Быстрый путь берёт готовую структуру из локального списка P без блокировки глобального и без выделения стека; стек аллоцируется только тогда, когда его не было, — поэтому частая смена горутин обходится дешевле, чем кажется.
  • Вытеснение кооперативное. preemptone лишь выставляет флаг и подменяет stackguard0, а сама горутина остановится только на ближайшем вызове, где проверяется переполнение стека. Горутина в системном вызове такому вытеснению не поддаётся — вместо этого у неё отбирают P.
  • P отбирается у потока в системном вызове не сразу. Сначала проверяется, сменился ли счётчик системных вызовов, пуста ли очередь и есть ли кому подхватить работу; и только если все условия против, P забирается и передаётся другой нити.
  • Статус горутины меняется только атомарно и с учётом GC. Любая смена статуса проходит через CAS, а при неудаче цикл ждёт, пока сборщик мусора закончит сканирование, — поэтому статус нельзя менять напрямую.

Где смотреть в коде

Источники

Похожее