Жизненный цикл горутины
Всем привет! Меня зовут Дима, я лид команды государственных интеграций в Ozon Банке и очень люблю ковыряться в сурс-коде Go. Это всегда очень интересно: никогда не знаешь, что встретится в очередной функции. Иногда могут быть какие-то невероятные костыли, каждый из которых, справедливости ради, — осознанное задокументированное решение. А иногда — элегантные подходы, вдохновляющие на написание ещё одной json-варилки.
Вы когда-нибудь, глядя на горутину, думали, что у неё под капотом? Я вот решил посмотреть. И сегодня расскажу о том, как рантайм Go создаёт и управляет горутинами, о статусах, которые у них есть, и некоторых хитростях, к которым прибегают создатели языка.
Дисклеймер: предполагается, что читатель уже понимает устройство рантайма — GMP-модели, наличие локальной и глобальной очередей горутин.
Дисклеймер #2: когда я писал этот текст, мной двигали исключительно любознательность и желание узнать, что же там происходит в недрах рантайма. На практике при написании кода эти знания не особо полезны, но если вы любопытны так же, как и я, желаю приятного чтения :)
Как появляется структура новой горутины
Жизненный цикл горутины начинается в момент её создания, когда исполнение кода доходит до ключевого слова go. Тогда же появляется и её структура. Как это происходит? Сначала взглянем на код, использующийся для инициализации новой горутины.
func newproc1(fn *funcval, callergp *g, callerpc uintptr, parked bool, waitreason waitReason) *g {
// …
newg := gfget(pp)
if newg == nil {
newg = malg(stackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg)
}
// …
var status uint32 = _Grunnable
if parked {
status = _Gwaiting
newg.waitreason = waitreason
}
casgstatus(newg, _Gdead, status)
// …
return newg
}
Несмотря на название функции newproc1
, именно она используется для создания новых горутин. Не будем обращать особого внимания на её аргументы — посмотрим сразу на строку newg := gfget(pp)
. По названию переменной можно догадаться, что это и есть новая горутина, которую мы хотели создать. В таком случае gfget
— функция, которая по идее создаёт новую рутину. Но нет: стоит заглянуть внутрь gfget
, и становится понятно, что она ничего не создаёт.
Чтобы понять и принять этот факт, вспомним, что одна из основных идей для достижения высокой производительности в Go — переиспользование ресурсов. Это же касается и горутин.
Так вот, существует нечто, именуемое в некоторых комментариях в коде gfree list
. По сути это структура данных для динамической аллокации памяти. Конкретно в нашем случае — связанный список из горутин, доступных для переиспользования.
Например, все горутины, завершившие свою работу, пополняют этот список и в будущем переиспользуются для инициализации на их основе новых горутин. Это позволяет не тратить лишние ресурсы на выделение памяти под стек горутины и саму её структуру. Структура новой горутины берётся именно из этого списка уже использованных горутин и только в случае, если в gfree list
не нашлось структуры горутины. В таком случае рантайм выделяет память под новую горутину и её стек:
if newg == nil {
newg = malg(stackMin) // выделили память под новую структуру горутины и её стек
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // добавили горутину в глобальный список всех существующих горутин, он используется как минимум для обхода сборщиком мусора стеков всех горутин
}
Что ж, остаётся непонятной только строка casgstatus(newg, _Gidle, _Gdead)
. Кто такой этот ваш G-дед? Возможно вы уже догадались, что это один из статусов горутины. Полный список статусов и их значение рассмотрим чуть ниже, а пока достаточно понимать, что горутина в статусе _Gdead
считается неиспользуемой в данный момент. Все причины, по которым она может оказаться в этом статусе, мы также рассмотрим позже. Сейчас же давайте подумаем, почему свежесозданную горутину переводят в этот статус?
Ответ на этот вопрос становится логичнее, если подумать о том, что планировщик в Go распределённый. А в исходном коде в целях оптимизации почти не используются блокировки.
Никто не застрахован, что прямо сейчас к нам не постучится сборщик мусора и не попробует просканировать стек ещё не проинициализированной горутины (под неё только выделена память). Поэтому создатели языка прячут горутину от сборщика мусора, переводя её в статус «неиспользуемой». Обратно из этого статуса рантайм переведёт горутину после инициализации всех её полей и добавления в локальную или глобальную очередь горутин.
Статусы горутин
Теперь, когда мы осознали, как появляется структура новой горутины, перейдём к возможным статусам горутин. Вот они слева направо:
_Gidle
— горутина уже аллоцирована, но пока не проинициализирована._Grunnable
— горутина готова к запуску, т.е. проинициализирована и добавлена в локальную или глобальную run queue._Grunning
— горутина может исполнять код. Она не находится ни в одной из очередей горутин и имеет ассоциированные с ней M и P._Gsyscall
— горутина исполняет системный вызов, а не пользовательский код. Что именно считается системным вызовом, зависит от операционной системы. Чаще всего это операции, связанные с работой с файлами, сокетами или памятью ОС. Горутины в этом статусе не находятся ни в локальной, ни в глобальной очереди._Gwaiting
— горутина заблокирована рантаймом. Она не исполняет пользовательский код и не находится ни в локальной, ни в глобальной очереди, но где-то зафиксирована — например, в wait queue канала._Gdead
— статус, предназначенный для того, чтобы отметить горутину как неиспользуемую. В этом статусе горутина не исполняет пользовательский код и может не иметь ассоциированного с ней стека._Gcopystack
— стек горутины реаллоцируется. В этом статусе горутина не исполняет пользовательский код и не находится ни в одной из очередей._Gpreempted
— горутина остановлена из-за того, что и планировщик, и сама горутина в одиночку занимают слишком много процессорного времени — на момент написания этого текста это 10 мс. По сути это многим известный механизм «preemption».
Кроме вышеперечисленных статусов также есть 2, которые на данный момент не используются, поэтому мы о них говорить не будем. Но ради приличия упомяну — это _Gmoribund_unused
и _Genqueue_unused
.
И если вы решили, что всеми 10 статусами всё ограничивается, спешу сообщить, что есть ещё 5 специальных статусов, основанных на перечисленных выше:
_Gscanrunnable
_Gscanrunning
_Gscansyscall
_Gscanwaiting
_Gscanpreempted
Не трудно заметить, что во всех них есть приставка _Gscan. Это означает, что горутина заблокирована сборщиком мусора на время сканирования её стека.
Исключение — _Gscanrunning
— здесь схема работы чуточку другая. Статус используется, чтобы на короткий промежуток времени заблокировать изменения статусов горутины и запустить механизм самосканирования её стека. Кстати, если вы хотите больше узнать о работе сборщика мусора, то рекомендую другую мою статью на эту тему.
В целом _Gscan
также используются в ситуациях, когда по какой-то причине необходимо заблокировать переход горутины в другой статус. Скан-статусы образуются с помощью битовых операций с почти одноимёнными статусами, например, _Grunnable = _Gscanrunnable &~ _Gscan
.
Это в очередной раз показывает стремление разработчиков языка оптимизировать каждый механизм шедулера, ведь битовые операции на процессорах выполняются крайне быстро.
Переходы между статусами
Вернёмся немного назад и посмотрим на окончание блока кода инициализации новой горутины:
var status uint32 = _Grunnable
if parked {
status = _Gwaiting
newg.waitreason = waitreason
}
casgstatus(newg, _Gdead, status)
Интереснее всего здесь условие if parked
, из-за которого горутина может оказаться в статусе _Gwaiting
вместо _Grunnable
. А также на строку newg.waitreason = waitreason. Именно из-за waitreason горутина стартует в статусе ожидания. Потенциально возможных причин много: сборка мусора, лок мьютекса или даже очистка процессорных кешей. Все перечислять не стану — оставлю лишь ссылку на список всех возможных причин.
Теперь, наконец, посмотрим на схему всех переходов между статусами горутин и список причин, которые к этим переходам могут привести:
Эта же самая схема, но в чуть менее запутанном виде:
_Grunning → _Gwaiting
:
при применении stop the world во время сборки мусора, горутина, инициировавшая STW, меняет статус, чтобы сборщик мог просканировать её стек https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1446
после завершения работы специальной utility-функции, позволяющей исполнять код per-P https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2010
при плановом переключении исполняемой в данный момент горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/coro.go#L158
во время сборки мусора на этапе mark termination: текущую запущенную горутину останавливают, чтобы запустить сканирование её стека https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1063
в специальной utility-функции, позволяющей исполнять код per-P https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2010
при плановом переключении исполняемой в данный момент корутины https://github.com/golang/go/blob/go1.23.4/src/runtime/coro.go#L158
во время сборки мусора перед сканированием стека горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/mgcmark.go#L220
во время этапа mark termination при сборке мусора (подробнее в статье) https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1022
во время сборки мусора, перед тем, как специальные горутины-воркеры, занимающиеся сканированием стека, просканировали стеки друг друга https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1510
_Gwaiting → _Grunnable
:
при запуске воркеров сборщика мусора, ответственных за «покраску» достижимых в куче и на стеке объектов https://github.com/golang/go/blob/go1.23.4/src/runtime/mgcpacer.go#L811 подробнее в статье о сборщике мусора)
при поиске горутины, готовой к запуску, но находящейся в ожидании https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1034
то же, что описано выше, но для трейсинга https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3277
то же, что и выше, но для нетпола https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3345
то же, что и выше, но для специальных горутин GC marking phase https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3393
при батчевом запуске горутин https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3891
при дебаге для переключения горутины из состояния ожидания https://github.com/golang/go/blob/go1.23.4/src/runtime/debugcall.go#L252
_Gwaiting → _Grunning
:
при применении stop the world во время сборки мусора, горутина, инициировавшая STW, меняет статус, чтобы сборщик мог просканировать её https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1448
после завершения работы специальной utility-функции, позволяющей исполнять код per-P https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2012
при плановом запуске зашедуленной горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/coro.go#L226
во время сборки мусора после завершения сканирования стека горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/mgcmark.go#L243
во время этапа mark termination сборки мусора (подробнее в статье) https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1063
во время сборки мусора, перед тем, как специальные горутины-воркеры, занимающиеся сканированием стека, просканировали стеки друг друга https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1510
_Grunning → _Gcopystack
:
при изменении размера стека горутины — в этом статусе сборщик мусора не сканирует стек горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/stack.go#L1122
_Gcopystack → _Grunning
:
после изменения размера стека горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/stack.go#L1130
_Gidle → _Gdead
:
при применении stop the world во время сборки мусора, горутина, инициировавшая STW, меняет статус, чтобы сборщик мог просканировать её стек https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1446
при создании новой горутины её до момента полной инициализации переводят в статус dead, чтобы спрятать от сборщика мусора
при создании новой горутины есть хитрость с переводом её в статус dead, чтобы спрятать её от сборщика мусора до момента полной инициализации (добавления её в локальную или глобальную очередь) https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2403
_Gdead → _Gsyscall
:
при привязке новой горутины к новому треду, появляющемуся в результате CGO-колбека. Используемая здесь горутина только инициализирована и находиться в статусе _Gdead, чтобы скрыться от сборщика мусора https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2344
_Gdead → _Gwaiting
:
при создании новой горутины сразу в «припаркованном» состоянии https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L5066
_Grunning → _Gdead
:
при завершении выполнения работы горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L4275
_Grunning → _Gsyscall
:
во время системного вызова https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L4410
_Grunning → _Grunnable
:
при шедулинге горутины (например, когда 1 горутина работала слишком долго, и шедулер решил, что пора дать поработать другой) https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L4122
при переключении остановки горутины в режиме дебага https://github.com/golang/go/blob/go1.23.4/src/runtime/debugcall.go#L242
_Grunnable → _Grunning
:
при запуске готовой к запуску горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3222
_Gsyscall → _Grunning
:
по окончании системного вызова https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L4660
_Gsyscall → _Gdead
:
при возвращении M в пул свободных ресурсов — ассоциированная с ним горутина переводится в _Gdead https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2478
_Gpreempted → _Gwaiting
:
при переводе запримпченной горутины в обычный статус ожидания ради дальнейшего её перевода в статус готовой к запуску горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/preempt.go#L153
Переходы в и из scan-статусов опущены, поскольку их используют повсеместно для временной блокировки смены статуса — да и статья не может быть бесконечной.
Единственный перевод через scan-статус, который я всё же отметил на схеме — из _Grunning
в _Gpreempted
через _Gscanpreempted
. Дело в том, что «чистых» переходов в статус preempted из других (не scan-*) статусов не бывает. Всё из-за опасений за изменение статуса горутины до того, как все работы по остановке текущий горутины (её откреплению от M) будут завершены: https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L4186
Чуть ниже в этом же блоке кода происходит переход статуса в обычный _Gpreempted
.
Что же в итоге?
Изучать рантайм языка программирования интересно: всё-таки не многие разработчики участвуют в создании целого языка. С другой же — очень полезно: глубокие знания устройства языка помогают избежать ошибок, а заодно совершить небольшие открытия (как это случилось у меня с переиспользованием «мёртвых» горутин).
А бонусом такими знаниями можно впечатлять интервьюера на собеседовании)