Жизненный цикл горутины

Всем привет! Меня зовут Дима, я лид команды государственных интеграций в 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 распределённый. А в исходном коде в целях оптимизации почти не используются блокировки.

Никто не застрахован, что прямо сейчас к нам не постучится сборщик мусора и не попробует просканировать стек ещё не проинициализированной горутины (под неё только выделена память). Поэтому создатели языка прячут горутину от сборщика мусора, переводя её в статус «неиспользуемой». Обратно из этого статуса рантайм переведёт горутину после инициализации всех её полей и добавления в локальную или глобальную очередь горутин.

Статусы горутин

Теперь, когда мы осознали, как появляется структура новой горутины, перейдём к возможным статусам горутин. Вот они слева направо:

  1. _Gidle —  горутина уже аллоцирована, но пока не проинициализирована.

  2. _Grunnable — горутина готова к запуску, т.е. проинициализирована и добавлена в локальную или глобальную run queue.

  3. _Grunning — горутина может исполнять код. Она не находится ни в одной из очередей горутин и имеет ассоциированные с ней M и P.

  4. _Gsyscall — горутина исполняет системный вызов, а не пользовательский код. Что именно считается системным вызовом, зависит от операционной системы. Чаще всего это операции, связанные с работой с файлами, сокетами или памятью ОС. Горутины в этом статусе не находятся ни в локальной, ни в глобальной очереди.

  5. _Gwaiting — горутина заблокирована рантаймом. Она не исполняет пользовательский код и не находится ни в локальной, ни в глобальной очереди, но где-то зафиксирована — например, в wait queue канала.

  6. _Gdead — статус, предназначенный для того, чтобы отметить горутину как неиспользуемую. В этом статусе горутина не исполняет пользовательский код и может не иметь ассоциированного с ней стека.

  7. _Gcopystack — стек горутины реаллоцируется. В этом статусе горутина не исполняет пользовательский код и не находится ни в одной из очередей.

  8. _Gpreempted — горутина остановлена из-за того, что и планировщик, и сама горутина в одиночку занимают слишком много процессорного времени — на момент написания этого текста это 10 мс. По сути это многим известный механизм «preemption».

Кроме вышеперечисленных статусов также есть 2, которые на данный момент не используются, поэтому мы о них говорить не будем. Но ради приличия упомяну — это _Gmoribund_unused и _Genqueue_unused.

И если вы решили, что всеми 10 статусами всё ограничивается, спешу сообщить, что есть ещё 5 специальных статусов, основанных на перечисленных выше:

  1. _Gscanrunnable

  2. _Gscanrunning

  3. _Gscansyscall

  4. _Gscanwaiting

  5. _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 :

  1. при применении stop the world во время сборки мусора, горутина, инициировавшая STW, меняет статус, чтобы сборщик мог просканировать её стек https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1446 

  2. после завершения работы специальной utility-функции, позволяющей исполнять код per-P https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2010

  3. при плановом переключении исполняемой в данный момент горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/coro.go#L158

  4. во время сборки мусора на этапе mark termination: текущую запущенную горутину останавливают, чтобы запустить сканирование её стека https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1063 

  5. в специальной utility-функции, позволяющей исполнять код per-P https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2010

  6. при плановом переключении исполняемой в данный момент корутины https://github.com/golang/go/blob/go1.23.4/src/runtime/coro.go#L158

  7. во время сборки мусора перед сканированием стека горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/mgcmark.go#L220

  8. во время этапа mark termination при сборке мусора (подробнее в статье) https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1022

  9. во время сборки мусора, перед тем, как специальные горутины-воркеры, занимающиеся сканированием стека, просканировали стеки друг друга https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1510 

_Gwaiting → _Grunnable:

  1. при запуске воркеров сборщика мусора, ответственных за «покраску» достижимых в куче и на стеке объектов https://github.com/golang/go/blob/go1.23.4/src/runtime/mgcpacer.go#L811 подробнее в статье о сборщике мусора)

  2. при поиске горутины, готовой к запуску, но находящейся в ожидании https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1034

  3. то же, что описано выше, но для трейсинга https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3277 

  4. то же, что и выше, но для нетпола https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3345 

  5. то же, что и выше, но для специальных горутин GC marking phase https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3393 

  6. при батчевом запуске горутин https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L3891 

  7. при дебаге для переключения горутины из состояния ожидания  https://github.com/golang/go/blob/go1.23.4/src/runtime/debugcall.go#L252

_Gwaiting → _Grunning:

  1. при применении stop the world во время сборки мусора, горутина, инициировавшая STW, меняет статус, чтобы сборщик мог просканировать её https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1448

  2. после завершения работы специальной utility-функции, позволяющей исполнять код per-P https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L2012

  3. при плановом запуске зашедуленной горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/coro.go#L226

  4. во время сборки мусора после завершения сканирования стека горутины https://github.com/golang/go/blob/go1.23.4/src/runtime/mgcmark.go#L243

  5. во время этапа mark termination сборки мусора (подробнее в статье) https://github.com/golang/go/blob/go1.23.4/src/runtime/mgc.go#L1063

  6. во время сборки мусора, перед тем, как специальные горутины-воркеры, занимающиеся сканированием стека, просканировали стеки друг друга 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:

  1. при применении stop the world во время сборки мусора, горутина, инициировавшая STW, меняет статус, чтобы сборщик мог просканировать её стек https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L1446 

  2. при создании новой горутины её до момента полной инициализации переводят в статус dead, чтобы спрятать от сборщика мусора

  3. при создании новой горутины есть хитрость с переводом её в статус 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. при шедулинге горутины (например, когда 1 горутина работала слишком долго, и шедулер решил, что пора дать поработать другой) https://github.com/golang/go/blob/go1.23.4/src/runtime/proc.go#L4122 

  2. при переключении остановки горутины в режиме дебага 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.

Что же в итоге?

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

А бонусом такими знаниями можно впечатлять интервьюера на собеседовании)

Комментарии (0)