golang

Горутины изнутри, часть 2

  • суббота, 22 августа 2026 г. в 00:00:14
https://habr.com/ru/articles/1073044/

Это вторая часть серии про горутины и рантайм Go. В ней три сюжета: стек, который переезжает по памяти как квартирант; вытеснение, которое прилетает сигналом операционной системы; и полный жизненный цикл горутины, который дословно объясняет каждую строку дампа паники. Первая часть рассказывала, почему потоки ОС дорогие и как Go дошел до схемы GMP: она не обязательна, но там завязка всей истории.

План серии:

Короткий пересказ для тех, кто пришел сразу сюда. Горутина: структура g плюс маленький стек в куче Go, исполняет ее поток ОС (M), а право исполнять Go-код называется P, и их ровно GOMAXPROCS. В Go 1.2 горутину научились прерывать хаком: в поле проверки стека пишут невозможное значение, и первый же вызов функции уводит горутину в планировщик. Дыра хака: цикл без вызовов функций неубиваем. На этом месте мы остановились.

TL;DR для сканирующих: стеки Go начинались сегментированными и болели «горячим расщеплением» (hot split), в 1.3 их сделали цельными и копируемыми, в 1.4 ужали до 2 КБ. Стек растет удвоением до потолка в 1 ГБ, а сжимает его сборщик мусора. С 1.14 цикл без вызовов функций больше не вечен: рантайм стреляет в поток сигналом SIGURG и подделывает вызов функции. Горутина рождается в newproc, спит через gopark, умирает в goexit и переиспользуется из пула: блокировка на канале стоит наносекунды и не трогает поток.

Маршрут части: 2014-2020 на общей ленте серии
Маршрут части: 2014-2020 на общей ленте серии

Все замеры, как и в первой части, сняты на одной машине: Intel Core i9-14900KF (24 ядра, 32 потока), Linux, Go 1.27.0. Код демо лежит в репозитории dsbasko/sandbox-gorutines: каталог там называется так же, как демо в тексте. Цитаты из исходников и коммитов даю на английском с переводом рядом. У каждой стоит ссылка на файл и строку, и ведет она на тег go1.27.0, а не на главную ветку. Внутри тега номера строк неизменны.

Go 1.3-1.5: стек, который умеет переезжать

Первая часть закончилась странным фактом: в Go 1.2 стек горутины вырос с 4 до 8 КБ, хотя обещали дешевизну. Пришло время рассказать, от чего этим ростом откупались.

До Go 1.3 стек горутины не был цельным куском памяти. Он был цепочкой сегментов: когда функции не хватало места, рантайм выделял новый сегмент, а в его служебной шапке сохранял ссылку на старый. Функция вернулась, и сегмент тут же освобождался, а горутина возвращалась на прежний кусок. Звучит экономно: стек занимает ровно столько сегментов, сколько нужно прямо сейчас.

Теперь смотри, где эта экономия взрывается. Представь цикл на миллион итераций, внутри которого вызывается функция, и вызов этот пришелся ровно на границу сегмента. Каждая итерация: выделить сегмент, скопировать служебные данные, выполнить функцию из трех инструкций, вернуться, освободить сегмент. Миллион пар «выделить и освободить» на пустом месте. Эту болезнь назвали hot split, горячее расщепление.

Злее всего то, что болезнь невидима. Код корректен, профилировщик показывает время в рантайме, а перенос одной безобидной строчки сдвигает границу сегмента, и программа внезапно ускоряется вдвое или замедляется втрое. Рост стека до 8 КБ в Go 1.2 (та самая «reasonable stopgap», разумная затычка) снижал вероятность попасть на границу, но не убирал ее.

Hot split: цикл на границе сегмента
Hot split: цикл на границе сегмента

Go 1.3 в июне 2014 года убрал причину. Из release notes:

Go 1.3 has changed the implementation of goroutine stacks away from the old, “segmented” model to a contiguous model. When a goroutine needs more stack than is available, its stack is transferred to a larger single block of memory.

Стек стал одним непрерывным блоком, а при нехватке места он не пришивает сегмент, а переезжает целиком в блок побольше. Границы больше нет, значит, нет и hot split. Тем же релизом, к слову, закрылся долг из первой части: локальная очередь P перестала жить под маленьким мьютексом и стала тем самым lock-free кольцом на 256 слотов, о котором любят спрашивать на собеседованиях.

Осталось понять, как вообще возможен «переезд» стека. Тут два механизма, и оба стоят разбора.

Две инструкции в начале каждой функции

Сначала о том, как рантайм узнает, что стека не хватает. В первой части я упоминал пролог: компилятор вставляет в начало почти каждой функции проверку. Вот она вся, в комментарии из исходников (stack.go:24):

Each function compares its stack pointer against g->stackguard to check for overflow.

Каждая функция сравнивает свой указатель стека с полем stackguard0 горутины (красной чертой чуть выше дна стека). Указатель ниже черты: места не хватает, прыгаем в morestack. Та сохраняет контекст горутины, переключается на служебный стек потока и зовет newstack(), которая выделяет новый блок.

Насколько вырасти? Вдвое (stack.go:1177):

// src/runtime/stack.go:1177 (Go 1.27), функция newstack
// Allocate a bigger segment and move the stack.
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2

Комментарий над функцией объясняет выбор (stack.go:1045): «Stack growth is multiplicative, for constant amortized cost» (рост мультипликативный, ради постоянной амортизированной цены). Каждый следующий переезд вдвое дороже, но случается вдвое реже, и средняя цена байта остается константой. Со слайсами закон похож, но не тот же: append удваивает только маленькие слайсы (до 256 элементов), а дальше добавляет примерно четверть. Стек удваивается всегда, без порогов.

Вопрос на предсказание: указатель на локальную переменную

Теперь сам переезд, и здесь первый вопрос на предсказание этой части. Стек горутины скопировали в другое место памяти. В стеке лежала локальная переменная x, а где-то в программе живет указатель &x. Он указывает на старый адрес, которого после переезда больше нет. Почему программа не падает?

Ответ: рантайм чинит указатели. Руками. Функция copystack (stack.go:929) копирует содержимое одним memmove, считает дельту между старым и новым адресом, а потом обходит стек кадр за кадром и правит все, что указывало внутрь старого диапазона (stack.go:608):

adjustpointer checks whether vpp is in the old stack described by adjinfo. If so, it rewrites vpp to point into the new stack.

Проверяется буквально: попадает ли значение в диапазон старого стека. Попало? Плюс дельта. Правятся локальные переменные, аргументы, сохраненные адреса кадров, цепочки defer и panic, записи, которыми горутина числится в очередях каналов.

Потрогать переезд руками можно демо stack_move. Оно берет адрес локальной переменной, уводит горутину на 20 000 кадров вглубь и печатает адрес снова. Адреса разные, значение прежнее.

Остается вопрос, откуда рантайм вообще знает, какие слова в памяти указатели, а какие обычные числа. От компилятора: тот для каждого кадра функции оставляет битовую карту «здесь указатель, здесь нет». Эти карты и так нужны сборщику мусора, так что переезд стека получил их даром.

Переезд стека: копия плюс починка указателей
Переезд стека: копия плюс починка указателей

Из этого правила растет и известная грабля с uintptr. Для рантайма uintptr не указатель, а число, и документация unsafe предупреждает прямым текстом (unsafe.go:60):

Even if a uintptr holds the address of some object, the garbage collector will not update that uintptr’s value if the object moves.

Сохранил адрес стековой переменной в uintptr, стек переехал, число осталось старым. Теперь ты знаешь, почему за такое бьют на код-ревью.

Стек умеет и худеть

Рост разобрали. Есть и обратная операция. Горутина-долгожитель один раз распарсила глубоко вложенный JSON, стек вырос до мегабайта, рекурсия закончилась, и без обратного механизма этот мегабайт остался бы за горутиной навсегда.

Сжимает стеки сборщик мусора, и выбор исполнителя не случаен: GC и так обходит стек каждой горутины в поисках указателей, карты у него в руках. Правило консервативное: если горутина использует меньше четверти своего стека, стек уменьшается вдвое (тем же copystack, только в меньшую сторону). Демо stack_shrink: две тысячи горутин раздувают стеки рекурсией до 33 КБ и засыпают на мелкой глубине.

глубина отработана, стек еще большой: 33685 байт на горутину
после 1-го GC:  17137 байт на горутину
после 2-го GC:  9322 байт на горутину
после 4-го GC:  5275 байт на горутину

Каждый цикл GC срезает лишнее вдвое, пока не упрется в минимум. А с флагом GODEBUG=gcshrinkstackoff=1 та же программа так и держит свои 33 КБ на горутину до конца жизни.

Потолок: гигабайт, которого никто не видел

Расти вечно стек не может. Лимит выставляется при старте программы, и в его выборе есть деталь, за которую я люблю исходники Go (proc.go:160):

// src/runtime/proc.go:160 (Go 1.27), runtime.main
// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
// Using decimal instead of binary GB and MB because
// they look nicer in the stack overflow failure message.
if goarch.PtrSize == 8 {
	maxstacksize = 1000000000
}

Лимит десятичный, миллиард байт, а не гигабайт, потому что «так красивее выглядит в сообщении об ошибке». Проверим сообщение. Демо stack_overflow: рекурсия без дна, а в main стоит defer с recover(), который якобы все поймает:

// stack_overflow/main.go (фрагмент)
func down(n int) int {
	return down(n+1) + n // рекурсия без дна
}
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0x1e430ee60398 stack=[0x1e430ee60000, 0x1e432ee60000]
fatal error: stack overflow

runtime stack:
runtime.throw({0x49b82a?, 0x200000001?})
...

Всего вывод занял 278 строк, включая жемчужину ...44739039 frames elided...: рантайм честно сообщил, что свернул 44 миллиона кадров рекурсии. Обрати внимание на три вещи. Первая: это fatal error, а не panic, и мой recover() не выполнился, программа умерла сразу. Вторая: в цепочке вызовов видны runtime.morestack и runtime.newstack, те самые герои этой главы, пойманные на месте работы.

Третья, самая тонкая: стек в дампе (stack=[...]) занимает 512 МБ, а не гигабайт. До лимита стек не дорастает никогда: умирает попытка удвоить 512 МБ, потому что 1 073 741 824 больше миллиарда.

1.4 и 1.5: жатва

Копируемые стеки открыли дорогу вниз. Раз ошибка в размере больше не приговор (не угадали, стек переедет), стартовый размер можно взять минимальный. В Go 1.4 Кит Рэндалл опустил его с 8 КБ до 2 КБ, и в коммите есть замер: 100 000 горутин на linux/amd64 подешевели с 819 МБ до 245 МБ. С тех пор stackMin = 2048 (stack.go:78) не менялся ни разу, одиннадцать лет. Обещание «горутина стоит пару килобайт» стало правдой только здесь, в конце 2014 года, после пяти лет работы.

Мелкая оговорка для дотошных: с Go 1.19 рантайм еще умнее. Он считает средний размер стеков по живым горутинам и выдает переиспользованным горутинам стек этого среднего размера, чтобы не гонять первые переезды. Совсем новые горутины по-прежнему получают ровно 2 КБ.

Go 1.5 в августе 2015 года дожал остальное. GOMAXPROCS по умолчанию стал равен числу ядер (одна строчка и отдельный дизайн-док go15gomaxprocs, объясняющий, почему это безопасно): эпоха «Go однопоточный, пока не попросишь» закончилась. Рантайм переехал с C на Go полностью: ноль файлов .c против 66 в Go 1.0. И появился слот runnext с легендарным результатом в бенчмарке BenchmarkPingPongHog: минус 99.88%. Что это за слот и откуда такая цифра, расскажу в главе про каналы, у нее прямая связь с блокировками.

Годы с 1.6 по 1.13 для нашей истории пролетают одним абзацем: модель не менялась, шел тюнинг таймеров, профилирования и пауз сборщика мусора. А вот в феврале 2020 года случился релиз, закрывший шестилетний долг.

Go 1.14: сигнал вместо вежливой просьбы

Вспомни дыру из первой части: вытеснение через проверку стека работает только там, где есть вызов функции. Цикл for { i++ } не вызывает ничего, флаг в stackguard0 никто не читает, горутина неубиваема. Чтобы понять, как это починили, сначала надо познакомиться с тем, кто вообще принимает решение «пора вытеснять».

sysmon: надзиратель без права исполнения

Планировщик Go не отдельный процесс и не фоновая задача. Это код, который рабочий поток исполняет между горутинами: горутина заблокировалась, поток зашел в планировщик, взял следующую. У такой схемы есть слепое пятно: горутина, которая никогда не отдает поток рантайму, не дает планировщику ни единого шанса вмешаться. Значит, нужен кто-то снаружи.

Этот кто-то называется sysmon, и я обещал вспомнить его имя еще в первой части: он родился в том же Go 1.1, что и GMP. Это отдельный поток ОС, который рантайм запускает при старте программы, и он живет вне правил GMP: у него никогда нет P, поэтому пользовательский код он не исполняет в принципе. Его работа: спать и проверять. Сон адаптивный, начинается с 20 микросекунд, при простое удваивается до потолка в 10 миллисекунд, а если программе совсем нечего делать, sysmon засыпает глубоко и не жжет батарейку. За один проход он успевает многое: проверяет сеть, будит принудительную сборку мусора (раз в две минуты, если обычная не случалась), а главное, зовет функцию retake().

retake() обходит все P и ищет два вида непорядка. Первый: P исполняет одну и ту же горутину дольше кванта. Квант задан константой (proc.go:6677):

// src/runtime/proc.go:6677 (Go 1.27)
// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms

Десять миллисекунд, значение не менялось с Go 1.2. Заметь: «10 мс» тут не точный квант, а нижняя граница. Сам sysmon спит до 10 мс между проверками, так что реальное время до вытеснения плавает от 10 до примерно 20 мс.

Второй вид непорядка: P завис вместе с потоком в системном вызове. Тут вытеснять некого: горутина исполняет код ядра ОС, а не Go, и никакие флаги она не проверяет. Поэтому sysmon отбирает не горутину, а P. Право исполнять Go-код передается другому потоку, а застрявший пусть сидит в ядре без права. Подробно этот сценарий разберем в третьей части, он там главный герой.

Нашел непорядок первого вида: зови preemptone(). До Go 1.13 включительно вся ее сила состояла в записи флага в stackguard0 и надежде, что горутина вызовет функцию. В Go 1.14 к надежде добавили оружие.

Выстрел

12 октября 2019 года Остин Клементс закоммитил 177a36a:

runtime: implement async scheduler preemption This adds signal-based preemption to preemptone.

Асинхронное вытеснение через сигнал. Сигналами операционная система умеет прерывать поток в произвольной точке: поток останавливается, исполняется специальная функция-обработчик, потом поток продолжает с того же места. Обычно сигналы означают события вроде «процесс просят завершиться» (SIGTERM) или «деление на ноль» (SIGFPE). Рантайм Go приспособил их под вытеснение.

Механика в три хода. Ход первый: preemptone() теперь дополнительно зовет preemptM(), а тот шлет потоку, на котором крутится жадная горутина, сигнал SIGURG. Ход второй: обработчик сигнала (doSigPreempt) проверяет, что горутина стоит в безопасной для остановки точке, и делает главный трюк: правит сохраненный контекст потока так, будто прерванный код сам только что вызвал функцию asyncPreempt. Шапка preempt.go описывает это дословно (preempt.go:41):

it adjusts the signal context to make it look like the signaled thread just called asyncPreempt and resumes the thread. asyncPreempt spills all registers and enters the scheduler.

Ход третий: обработчик возвращается, поток просыпается и обнаруживает себя внутри asyncPreempt, вызова которой в программе не было. Она сохраняет все регистры и уводит горутину в планировщик. Сигнал ничего не остановил сам: он подделал вызов функции там, где его не было, и дальше сработала обычная кооперативная машина.

Красивая деталь для любопытных: при обычном вызове функции регистры делят по договору, часть бережет вызывающий код, часть вызванная функция. Сигнал же прилетает посреди произвольной инструкции, где живым может быть что угодно. Поэтому asyncPreempt сохраняет вообще все: на amd64 это BP, флаги процессора и 14 регистров общего назначения на стеке горутины (112 байт), а векторные регистры уезжают в отдельный буфер при P. Файл с этим кодом даже не пишут руками, его генерирует скрипт mkpreempt.go под каждую архитектуру.

Выстрел SIGURG: сигнал подделывает вызов функции
Выстрел SIGURG: сигнал подделывает вызов функции

Почему именно SIGURG

Выбор сигнала - отдельная история, и комментарий в signal_unix.go (signal_unix.go:44) читается как чек-лист инженерного решения. Сигнал должен пропускаться отладчиками насквозь, иначе каждое вытеснение будет останавливать твой дебаггер. Не должен использоваться внутри libc. Должен быть безвредным при ложном срабатывании: SIGALRM не подходит, обработчик не отличит вытеснение от настоящего будильника. И должен существовать на macOS, что вычеркивает realtime-сигналы целиком. SIGURG прошел по всем пунктам по забавной причине (signal_unix.go:67):

We use SIGURG because it meets all of these criteria, is extremely unlikely to be used by an application for its “real” meaning (both because out-of-band data is basically unused and…)

Настоящее назначение SIGURG (срочные, out-of-band данные в TCP) практически мертво, сигнал стоял без дела, его и забрали. Отсюда практическое следствие: если ты запустишь Go-программу под strace и увидишь ливень SIGURG, это не сломанная сеть и не атака. Это планировщик работает.

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

Проверяет все это isAsyncSafePoint (preempt.go:390), и если точка небезопасна, сигнал пропадает впустую, а sysmon пришлет новый на следующем круге. Вытеснение в Go всегда работает по принципу «как получится», и комментарий к preemptone этого не скрывает (proc.go:6908): «This function is purely best-effort».

Машина времени

Теперь обещанное с первой части демо. Программа preempt: одна горутина крутит счетчик в цикле без вызовов функций, GOMAXPROCS=1, а main пытается запустить сборку мусора, которой нужно остановить все горутины:

// preempt/main.go (фрагмент)
go func() {
	for {
		sink.Add(1) // цикл без вызовов функций
	}
}()
time.Sleep(10 * time.Millisecond)
runtime.GC()

Запускаю на современном Go:

go build -o /tmp/preempt ./preempt
/tmp/preempt
шаг 1: зову runtime.GC() - ему нужен STW, значит нужно вытеснить горутину
шаг 2: GC завершился за 80.917236ms (итераций накрутили: 31428568)
шаг 3: вторая горутина получила процессор: 42

Работает: сборщик дождался своего, вытеснив цикл сигналом. А теперь выключим сигнальное вытеснение одной переменной окружения:

GODEBUG=asyncpreemptoff=1 /tmp/preempt
(ни одной строки вывода; программа висит, пока не прибьешь Ctrl+C)

Это готовая машина времени в Go 1.13. Флаг asyncpreemptoff=1 отключает только сигналы, кооперативный флаг в stackguard0 рантайм ставит по-прежнему, но в цикле без вызовов функций его некому проверить. Программа виснет даже раньше первого Println: единственный P занят навсегда. Одна переменная окружения показывает разницу между «до 1.14» и «после» нагляднее любых слов.

У сигнального вытеснения есть и цена, о которой release notes 1.14 предупредили честно: программы на Unix стали получать больше сигналов, и медленные системные вызовы теперь чаще прерываются с ошибкой EINTR. Код, который зовет syscall напрямую, обязан уметь повторять вызов. Если после апгрейда на 1.14 твоя обертка над syscall начала спорадически падать, теперь ты знаешь виновника.

И финальный штрих: сигнальное вытеснение не заменило кооперативное, они работают вместе по сей день. Флаг в stackguard0 ставится всегда и срабатывает первым, если горутина вызывает функции; сигнал догоняет тех, кто не вызывает. Хак 2013 года и оружие 2020 года дополняют друг друга.

Жизнь горутины: от go f() до пула мертвых

Мы уже знаем, как горутину прерывают. Теперь пройдем весь ее путь: рождение, сон, смерть и то, что после смерти. Побочным эффектом научимся читать дамп паники построчно.

Рождение: что на самом деле делает go f(x)

Вопрос, на котором горят почти все новички: в момент go f(x) чему равен x? Тому, что сейчас, или тому, что будет, когда горутина реально запустится? Ответ жестко прописан в спецификации языка:

The function value and parameters are evaluated as usual in the calling goroutine.

Аргументы вычисляются немедленно, в вызывающей горутине, до того как go вернет управление. Компилятор делает это буквально: переписывает go f(x, y) в «вычисли x1, y1 := x, y прямо здесь, потом запусти замыкание без аргументов, которое вызовет f(x1, y1)». В рантайм уезжает один-единственный указатель на замыкание: сигнатура творца горутин выглядит как newproc(fn *funcval) (proc.go:5334). Запомни эту механику: в третьей части она объяснит самый массовый баг в истории Go (тот самый, с переменной цикла, который чинили на уровне языка в 1.22).

newproc берет структуру g (чаще из пула, об этом ниже), выделяет стек, записывает паспортные данные и ставит горутину в очередь. Паспортные данные нам скоро пригодятся: goid (номер горутины), gopc (адрес того самого оператора go, который ее породил) и parentGoid (номер родителя).

Про goid есть неочевидная деталь. Чтобы не дергать глобальный счетчик на каждый go f(), каждый P забирает номера пачками по 16 штук и раздает из своей пачки. Поэтому номера горутин не идут подряд: две горутины, созданные друг за другом на разных P, получат номера, различающиеся на десятки. Сейчас увидишь, во что это выливается в дампе.

Дамп паники, прочитанный построчно

Демо panic_dump: запускаем горутину, которая навсегда виснет на канале, ждем и роняем программу:

// panic_dump/main.go (фрагмент)
func waiter(ch chan int) {
	<-ch // получателя никто не разбудит
}

func main() {
	ch := make(chan int)
	go waiter(ch)
	time.Sleep(100 * time.Millisecond)
	panic("смотрим на дамп")
}
go build -o /tmp/panic_dump ./panic_dump
GOTRACEBACK=all /tmp/panic_dump
panic: смотрим на дамп

goroutine 1 [running]:
main.main()
	/.../panic_dump/main.go:15 +0x85

goroutine 6 [chan receive]:
main.waiter(...)
	/.../panic_dump/main.go:8
created by main.main in goroutine 1
	/.../panic_dump/main.go:13 +0x66

Сначала о флаге: без GOTRACEBACK=all ты увидел бы только упавшую горутину, спящие в дамп по умолчанию не попадают. Полезно знать до того, как понадобится.

Теперь разбор. goroutine 1: это main, и она всегда номер один, потому что глобальный счетчик начинается с нуля. goroutine 6: в программе один-единственный оператор go, а номер шестой, младшие разобрали служебные горутины рантайма. Полагаться на эти номера нельзя ни секунды: та же программа от прогона к прогону печатает разные, а раздача пачками по 16 разгоняет разброс тем сильнее, чем больше в машине ядер. [running] и [chan receive] в квадратных скобках: статус.

И строка created by main.main in goroutine 1 с точным файлом и номером строки: это те самые gopc и parentGoid из паспорта. К моменту падения родитель мог сто раз уйти в другой код или вообще умереть, но рантайм сохранил адрес оператора go при рождении, и дамп ведет тебя ровно к нему.

Со статусами стоит познакомиться списком, он короткий. В исходниках их двенадцать (считая две мертвые заглушки, runtime2.go:37), новичку хватает шести:

  • _Grunnable: стоит в очереди, ждет процессора

  • _Grunning: исполняется прямо сейчас

  • _Gsyscall: ушла в системный вызов

  • _Gwaiting: заблокирована (канал, мьютекс, сон)

  • _Gdead: умерла или еще не родилась

  • _Gcopystack: прямо сейчас переезжает ее стек, мы видели это в прошлой главе

В дампах вместо waiting рантайм печатает причину ожидания: [chan receive], [select], [sync.Mutex.Lock], [sleep], [IO wait]. Причин в исходниках около полусотни, и каждая пишется в горутину в момент засыпания. Повиси наш waiter дольше минуты, скобки показали бы еще и время: [chan receive, 3 minutes].

Конечный автомат: шесть статусов горутины
Конечный автомат: шесть статусов горутины

Сон: почему поток не засыпает вместе с горутиной

Что физически происходит, когда waiter выполняет <-ch на пустом канале? Любая блокировка в Go (канал, мьютекс, time.Sleep, select) сходится в одну функцию рантайма: gopark (proc.go:457). Она записывает в горутину причину ожидания, ту самую строку для дампа. Потом переключается на служебный стек потока, меняет статус горутины на _Gwaiting, отцепляет ее от потока и последней строкой зовет schedule(): главный цикл планировщика, поиск следующей работы.

Прочитай последнее предложение еще раз, в нем разгадка половины магии Go. Горутина «уснула», а поток не остановился ни на такт: он в ту же микросекунду начал исполнять другую горутину. Спящая горутина никому ничего не стоит: это структура в куче со статусом waiting, на которую указывает запись в очереди канала. Ни потока, ни процессора, ни опроса в цикле. Вот почему сто тысяч горутин, ждущих каналы, обошлись нам в первой части десятком потоков ОС.

Здесь же пора представить персонажа, который до сих пор прятался за словами «служебный стек потока». У каждого потока M есть собственная особая горутина g0, на Linux со стеком в 16 КБ (на macOS и Windows она садится прямо на системный стек потока). Она никогда не исполняет пользовательский код: на ней работает сам планировщик.

Зачем отдельный стек? Стек обычной горутины 2 КБ и умеет переезжать, а код, который сам двигает чужие стеки, не может рисковать переездом собственного. Поэтому любая операция над судьбой горутины (парковка, пробуждение, рост стека, смерть) начинается с прыжка на g0. Когда в трейсбеке видишь runtime.mcall или runtime.systemstack, это он и есть, прыжок на служебный стек.

Смерть: трюк с подмененным адресом возврата

Как рантайм узнает, что функция горутины вернулась и пора прибираться? Напрашивается ответ «следит», но следить некому и незачем. Вместо этого стек каждой горутины с рождения собран с обманом (stubs.go:298):

goexit is the return stub at the top of every goroutine call stack. Each goroutine stack is constructed as if goexit called the goroutine’s entry point function.

Стек построен так, будто функцию горутины вызвала служебная заглушка goexit. Функция честно доработала, выполнила return и «вернулась» туда, откуда ее никто не вызывал: прямиком в уборочный код рантайма. Тот переводит горутину в _Gdead, обнуляет поля и последней строкой, как и gopark, зовет schedule(). Поток немедленно берет следующую работу.

Из этого трюка бесплатно следует ответ на вопрос «почему у go f() нельзя получить возвращаемое значение». Некому отдавать. Функция возвращается не вызывающему коду, а заглушке, и спецификация прямо говорит: результаты отбрасываются. Хочешь значение из горутины, бери канал или общую переменную, других путей нет по построению.

А куда девается сама структура g с ее стеком? В мусор? Нет: в пул. У каждого P есть список свободных структур gFree; мертвая g ложится туда и ждет следующего go f(), который возьмет ее вместо аллокации новой. Стек стандартного размера остается приклеенным к структуре.

Вот почему go f() в горячем цикле почти не аллоцирует: и структура, и стек переиспользуются с прошлых смертей. И вот почему runtime.NumGoroutine() может показывать десяток горутин, а память под стеки не возвращается системе мгновенно: мертвые лежат в пуле со стеками наготове.

Смерть горутины: подложенный goexit и пул
Смерть горутины: подложенный goexit и пул

Врезка: почему нет goroutine.Kill()

Вопрос, который задает каждый, кто впервые получил зависшую горутину: как убить ее снаружи? Никак, и это не упущение, а решение. В структуре g нет ни поля приоритета, ни ручки принудительного завершения; единственный публичный способ завершить горутину (runtime.Goexit()) работает только изнутри нее самой.

Причина техническая. Убийство извне означало бы бросить чужой стек в произвольной точке: с захваченными мьютексами, недописанными структурами, невыполненными defer. Даже вытеснение, как мы видели, остается просьбой остановиться в безопасной точке, а не приказом умереть. Единственный рабочий подход кооперативный: context.Context, канал done, флаг. Горутина должна сама согласиться уйти.

Сюда же примыкает грабля с recover. Списки паник и отложенных вызовов (_panic, _defer) лежат внутри структуры g, у каждой горутины свои. Поэтому recover() в main никогда не поймает панику из другой горутины: go func() { panic("boom") }() роняет всю программу, сколько бы defer ни стояло вокруг. Ловить панику нужно внутри той горутины, где она может случиться.

Блокировка - это дешево: каналы и мьютексы под капотом

Осталось разобрать последний слой: что происходит между gopark и пробуждением, на примере канала. Заодно закроем цифру -99.88%, подвешенную с главы про Go 1.5.

Два разных сна

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

Сон горутины: gopark, смена статуса, наносекунды, поток свободен и работает дальше. Сон потока: когда планировщику совсем нечего дать потоку, тот паркуется настоящим системным вызовом (на Linux это futex), уходит в ядро и не тратит процессор. Первый сон дешев и случается миллионы раз в секунду. Второй дорог и случается, только когда работы нет вообще.

Все, что ты зовешь «блокировкой» в Go-коде (канал, sync.Mutex, WaitGroup.Wait, time.Sleep), использует первый сон. Отсюда и ответ, почему в Go пишут простой синхронный код там, где другие языки городят колбэки и async/await. Блокировка, которая стоит как вызов функции, не нуждается в обходных маневрах.

Два разных сна: горутины и потока
Два разных сна: горутины и потока

Канал: структура с замком, а не труба в ядре

Новички часто думают, что канал устроен как что-то системное, вроде пайпа между процессами. Смотрим в исходники (chan.go:34):

// src/runtime/chan.go:34 (Go 1.27), сокращено
type hchan struct {
	qcount   uint           // сколько значений лежит сейчас
	dataqsiz uint           // емкость буфера (0 у небуферизованного)
	buf      unsafe.Pointer // кольцевой буфер значений
	closed   uint32
	sendq    waitq // очередь ждущих отправителей
	recvq    waitq // очередь ждущих получателей
	lock     mutex
}

Обычная структура в памяти процесса: буфер, два счетчика, две очереди ждущих и мьютекс, который все это защищает. Ядро ОС про твои каналы не знает ничего. Каждая операция начинается со взятия lock: поэтому канал потокобезопасен из коробки и поэтому же он не бесплатен.

В очередях sendq и recvq лежат не сами горутины, а маленькие структуры-посредники sudog (талоны ожидания). Талон хранит ссылку на горутину и адрес, куда положить или откуда взять значение. Зачем посредник? Одна горутина через select может стоять в очередях нескольких каналов сразу, а у структуры g только одно поле для очереди: в две очереди ее саму не поставишь, а талонов можно выписать сколько угодно.

Канал изнутри: hchan и талоны sudog
Канал изнутри: hchan и талоны sudog

Отправка: три исхода, и буфер лишь второй

Что делает ch <- v? Под локом проверяются ровно три варианта, по порядку. Первый: в recvq уже спит получатель. Тогда значение передается ему напрямую, минуя буфер, и получателя будят. Прямо-таки буквально напрямую (chan.go:382):

Sends and receives on unbuffered or empty-buffered channels are the only operations where one running goroutine writes to the stack of another running goroutine.

Значение копируется одним memmove из стека отправителя прямо в стек спящего получателя, по адресу из его талона. Это единственное место во всем Go, где одна горутина пишет в стек другой: ради него рантайму пришлось отдельно договариваться со сборщиком мусора. Небуферизованный канал не «буфер размера ноль». Это рукопожатие: значение перепрыгивает из стека в стек за одну копию.

Второй исход: получателя нет, но в буфере есть место. Значение уходит в буфер, отправитель бежит дальше. И только третий исход отправляет спать: получателя нет, буфер полон или отсутствует. Отправитель выписывает себе талон, встает в sendq и уходит в знакомый gopark. Комментарий в chansend формулирует философию этого сна одной строкой (chan.go:257):

Block on the channel. Some receiver will complete our operation for us.

«Какой-нибудь получатель завершит нашу операцию за нас». Спящий не делает вообще ничего, всю оставшуюся работу (скопировать значение, разбудить) доделывает за него встречная сторона. Прием из канала зеркален: ждущий отправитель, потом буфер, потом сон в recvq.

Отправка в канал: три исхода по порядку
Отправка в канал: три исхода по порядку

Пробуждение: без очереди

Теперь пробуждение, и здесь развязка истории про -99.88%. Горутина, доставившая значение спящему, зовет goready: статус спящего меняется на _Grunnable, и он ставится в очередь. Вопрос: в какое место очереди? В хвост честно, но дорого: в очереди могут стоять сотни горутин, и разбуженный получит процессор через целый круг.

Рантайм поступает иначе. Разбуженная горутина кладется в особый слот runnext на P будильщика и выполнится следующей, вне очереди, унаследовав остаток его кванта. Комментарий к слоту объясняет мотив (runtime2.go:808):

If a set of goroutines is locked in a communicate-and-wait pattern, this schedules that set as a unit and eliminates the (potentially large) scheduling latency.

Набор горутин, общающихся в стиле «передал и жду», планируется как единое целое. Этот слот и появился в Go 1.5. Две горутины, играющие в пинг-понг через канал, перестали после каждого удара вставать в хвост общей очереди, и бенчмарк BenchmarkPingPongHog ускорился с 684 287 нс до 825 нс на операцию. Те самые минус 99.88%. И наш замер из первой части (103 нс на переключение через канал) существует благодаря этому же слоту.

Пробуждение без очереди: слот runnext
Пробуждение без очереди: слот runnext

Мьютекс: тот же сон, вид сбоку

sync.Mutex в спокойной жизни - один атомарный процессорный примитив, сравнить-и-заменить, вообще без рантайма. Интересное начинается при борьбе за замок. Новичок из C++ или Java ждет, что проигравший поток заблокируется в ядре. В Go проигравшая горутина сначала немного крутится на месте (максимум четыре захода, и только когда рядом работают другие ядра: есть шанс, что владелец отпустит замок прямо сейчас), а потом идет спать через тот же gopark, что и канал, с причиной sync.Mutex.Lock для дампа. Поток, как обычно, свободен и берет другую работу.

Очередь ждущих при этом живет не в мьютексе (он всего 8 байт, туда ничего не влезает), а в глобальной таблице семафоров рантайма, и в ней лежат все те же талоны sudog. Канал и мьютекс усыпляют горутину одним механизмом, различается только вывеска. Для полноты: select тоже не открывает ничего нового, он выписывает по талону на каждый канал, встает во все очереди сразу и засыпает одним gopark; никакого опроса каналов по кругу не происходит.

Под этой вывеской осталось еще два механизма, которым в статье не нашлось места. Мьютекс, прождавший дольше миллисекунды, переходит в режим голодания: замок больше не разыгрывается между всеми, а вручается строго первому в очереди. А select перед сном обходит каналы дважды. Сначала случайным порядком, иначе первый case в исходнике побеждал бы всегда. Потом отсортированным по адресу, иначе два select на одних каналах встали бы в дедлок на замках. Разобрал оба механизма в канале.

Вопрос на предсказание: молчаливый дедлок

Финальный вопрос части. Возьми программу: main пишет в канал, который никто не читает. Она мгновенно падает со знаменитым fatal error: all goroutines are asleep - deadlock!.

А теперь представь прод: HTTP-сервер, сотня горутин намертво зависла на канале, который никто никогда не закроет. Программа упадет с тем же fatal error? Подумай, прежде чем читать дальше.

Не упадет. Она будет молча работать с сотней зависших горутин до перезапуска. Разгадка в том, как устроен детектор: checkdead() не анализирует граф ожиданий, а проверяет счетчик «остались ли в программе работающие потоки» (комментарий в proc.go:6416, сама проверка в proc.go:6447):

// src/runtime/proc.go:6416 и :6447 (Go 1.27), checkdead
// The check is based on number of running M's, if 0 -> deadlock.
run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
if run > run0 {
	return
}

Пока жив хоть один работающий поток (сервер слушает порт, где-то тикает time.Ticker, крутится любая живая горутина), детектор выходит на первой же проверке, и сотня зависших невидима. Даже одинокий time.Sleep в любой горутине подавляет проверку: таймеры тоже считаются признаком жизни. Учебный hello-world падает только потому, что в нем заснули действительно все.

Отсюда практический вывод: на детектор дедлоков нельзя полагаться нигде, кроме учебных примеров. Зависшие горутины в проде ищут иначе: /debug/pprof/goroutine?debug=2 показывает все горутины с теми самыми [chan receive, 10 minutes], и читать этот список ты теперь умеешь. А в свежих версиях (эксперимент в 1.26, релиз в 1.27) у рантайма появился профиль утечек горутин, про него в третьей части.

Что в итоге

Сюжет части поместится в четыре строки. Стек горутины научился переезжать (1.3), похудел до 2 КБ (1.4) и стал сжиматься сборщиком мусора; все это возможно, потому что Go знает, где в памяти указатели. Вытеснение получило оружие: sysmon стреляет сигналом SIGURG, обработчик подделывает вызов функции, и с Go 1.14 неубиваемых горутин нет. Горутина рождается с подмененным адресом возврата, спит через смену статуса и переиспользуется из пула после смерти. И любая блокировка в твоем коде (канал, мьютекс, select) - это дешевый сон горутины, при котором поток продолжает работать.

Дамп паники после этой части должен читаться как обычный текст: номер горутины (выданный пачкой по 16), статус или причина ожидания в скобках, минуты зависания, created by с местом рождения. Это самый практичный навык из всех, что здесь были.

Заглянем в третью часть. Горутина уходит в системный вызов, и sysmon отбирает у ее потока право исполнять Go: как именно, и почему сокет при этом не занимает поток, а обычный файл занимает? Почему у Go-программы с GOMAXPROCS=8 в htop бывает 40 потоков, и на каком числе рантайм скажет «хватит»? Как планировщик ищет работу (полный разбор findRunnable с лестницей из восьми шагов) и что значит каждое поле в выводе GODEBUG=schedtrace? Плюс все свежее: GOMAXPROCS в контейнерах, детектор утечек горутин и testing/synctest. Финал серии: рантайм целиком.

Выйдет она через неделю. Если не хочешь ловить ее в ленте Хабра, анонс будет в tg’шке: туда же уехали разборы механизмов, которые в серию не влезли.