Планировщик Go устроен вокруг трёх сущностей: Gструктура, описывающая горутину: её стек, статус и служебные поля, Mструктура, описывающая поток операционной системы, на котором исполняется код и Pструктура, дающая M право исполнять Go-код: локальную очередь горутин, кэш аллокатора и таймеры. Разобраться в их взаимодействии сложно, потому что большая часть работы происходит до main, а правила «кто кого может исполнять» размазаны по десяткам функций. Ниже — механика по шагам: что инициализируется при старте, почему M без P бесполезен, как рождается горутина, как она паркуется и как системный монитор отбирает P у зазевавшихся потоков.
Bootstrap: что инициализируется до main
До запуска main рантайм должен подготовить структуры, которыми будут пользоваться планировщик и системные горутины. Этим занимается schedinitфункция инициализации планировщика рантайма, которая вызывается при старте программы до входа в main. Первым делом она инициализирует набор блокировок рантайма, чтобы к моменту появления конкурентных исполнителей они уже были готовы:
sched.lockзащищает поля планировщикаschedt, включая счётчики M и очереди;sysmonlockнужен, чтобы удержанием этого мьютекса блокировать sysmonсистемный монитор — служебная горутина, которая следит за долгими системными вызовами и вытесняет зациклившиеся горутины от взаимодействия с остальным рантаймом;deferlockиsudoglock— центральные блокировки для пулов структур_deferиsudogсоответственно;deadlockиpaniclkиспользуются при фатальных ситуациях: первая — при обнаружении взаимной блокировки, вторая — при панике;allglockзащищает список всех горутинallgs, аallpLock— массив всех Pallp.
Отдельно инициализируется 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проверки, не заклинило ли программу: если все M спят и работы нет, рантайм падает с deadlock. Помимо идентификатора, mcommoninit заполняет слабый указатель на самого себя, инициализирует генератор случайных чисел и профилирование, при наличии сигнального стека задаёт его границу, добавляет M в список allm через атомарную запись (чтобы другие потоки, итерирующие по allm без sched.lock, видели корректный указатель), а на некоторых платформах выделяет буфер для трассировки cgo.
Следующий шаг — procresizeфункция, которая приводит число процессоров P в соответствие с заданным значением, создавая новые P или освобождая лишние. Число 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состояние простаивающего P, который ни за кем не закреплён и готов быть занят, иначе бросает ошибку. Затем устанавливает gp.m.p в этот P, pp.m в текущий M и переводит pp.status в _Prunningсостояние P, закреплённого за M и исполняющего Go-код. После этого 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 у потока сначала пропускает все P, у которых нет M или статус не _Prunning — такие не выполняются, отбирать нечего. Для остальных сравнивается счётчик смен временного среза schedtick с сохранённым значением: если они различаются, P сменил срез, и retake лишь обновляет сохранённые значения. Если счётчик не изменился и с момента последней записи прошло не меньше forcePreemptNSпорог времени, по истечении которого sysmon просит горутину на P остановиться, вызывается preemptone(pp) и выставляется sysretakeфлаг, означающий, что P нужно отобрать принудительно, потому что горутина в системном вызове не может ответить на запрос вытеснения — это порог по времени, а не по счётчику системных вызовов.
Далее retake пытается заблокировать выход P из системного вызова. Если P действительно в системном вызове и sysretake не выставлен, сравнивается syscalltickсчётчик, увеличивающийся при каждом входе P в системный вызов с сохранённым: при различии сохранённые значения обновляются, поток возобновляется, и отбор не происходит. Если счётчик не менялся, проверяется, пуста ли очередь 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, а при неудаче цикл ждёт, пока сборщик мусора закончит сканирование, — поэтому статус нельзя менять напрямую.
Где смотреть в коде
- proc.go: Store
- proc.go: runqput
- proc.go: mReserveID
- proc.go: set
- proc.go: push
- proc.go: malg
- proc.go: runqputbatch
- proc.go: changegstatus