Горутины изнутри, часть 3
- четверг, 3 сентября 2026 г. в 00:00:19
Финал серии про горутины и рантайм Go. Здесь то, что происходит за пределами уютного мира G/M/P: системные вызовы, сеть, сборщик мусора. Плюс полный круг планировщика, после которого вывод GODEBUG=schedtrace читается как обычный лог, и все свежее из Go 1.22-1.27: рантайм, который начал делать работу за программиста.
План серии:
syscall, netpoll, полный круг планировщика и свежий Go.
Короткий пересказ для тех, кто пришел сразу сюда. Горутина: дешевая структура со стеком в куче, ее исполняет поток ОС (M), взявший право P, которых ровно GOMAXPROCS. Блокировка на канале или мьютексе стоит наносекунды: горутина паркуется, поток немедленно берет другую. За порядком следит sysmon, поток-надзиратель вне правил GMP: он вытесняет жадных сигналом SIGURG и отбирает P у застрявших.
Большую часть пути серия шла по таймлайну релизов, но «полная картина рантайма» в хронологию не влезает: netpoll приехал в 1.1, таймеры переехали на P в 1.14, модель syscall переписали в 1.26. Дальше я описываю сегодняшнее устройство (Go 1.27), а версии оставляю якорями в скобках.
TL;DR для сканирующих: короткий syscall бесплатен, долгий запускает цепочку «sysmon отбирает P, рантайм растит потоки» с потолком в 10 000. Сеть ждет без потока (netpoll), обычный файл занимает поток целиком. Планировщик ищет работу лестницей от дешевого к дорогому, а простаивающие потоки либо крутятся (spinning), либо спят. GC существует за счет вытеснения и берет с аллоцирующих горутин налог. С 1.25 GOMAXPROCS читает лимиты контейнера, с 1.27 рантайм умеет находить утекшие горутины.

Замеры, как и раньше, с одной машины: Intel Core i9-14900KF, linux/amd64, 32 потока, Go 1.27.0. Код в репозитории dsbasko/sandbox-gorutines: каталог там называется так же, как демо в тексте. У каждой цитаты из исходников стоит ссылка на файл и строку, и ведет она на тег go1.27.0, а не на главную ветку. Внутри тега номера строк неизменны.
Вопрос на предсказание, единственный в этой части, зато большой. Две программы: одна запускает 10 000 горутин, читающих из 10 000 сетевых соединений, вторая запускает 10 000 горутин, читающих 10 000 файлов с диска. Сколько потоков ОС создаст каждая? Одинаково? Прикинь, ответ будет к середине главы.
Начнем с того, что происходит при любом системном вызове. Горутина зовет ядро (прочитать, записать, открыть), и на это время ее поток потерян для Go: он исполняет код ядра, никакие флаги вытеснения не проверяет, в планировщик не заходит. Функция entersyscall перед уходом делает минимум: сохраняет регистры и помечает горутину статусом _Gsyscall. Комментарий над входом в syscall формулирует смысл коротко (proc.go:4618):
The goroutine g is about to enter a system call. Record that it’s not using the cpu anymore.
Записать, что горутина больше не использует процессор. Заметь, чего здесь нет: P никому не передается, он остается при потоке. Расчет простой: большинство системных вызовов возвращаются за микросекунды, а передача P другому потоку (сходить под глобальный замок, разбудить кого-то) может стоить дороже самого вызова. Короткий syscall в Go бесплатен: ушел, вернулся, побежал дальше, ни одного замка по дороге.
А если вызов завис? Диск задумался, NFS ушел в себя, труба пуста. Поток сидит в ядре, P при нем, и этот P не исполняет никого: одна тридцать вторая всех мощностей программы (при GOMAXPROCS=32) заморожена. Сам поток проблему не решит, он спит в ядре. Решает знакомый по второй части sysmon: его функция retake обходит все P и находит зависших. Комментарий в коде честен насчет порога (proc.go:6743):
Retake the P if it’s there for more than 1 sysmon tick (at least 20us).
Отобрать P, если тот в системном вызове дольше одного тика sysmon, минимум 20 микросекунд. «Минимум» здесь важное слово: sysmon спит от 20 мкс до 10 мс, так что реальная задержка плавает. И есть льгота: если очередь этого P пуста, а в системе и так хватает свободных рук (простаивающие P или потоки в поиске работы), его не трогают первые 10 мс: спешить некуда.
Дальше в дело вступает handoffp: отобранный P передается другому потоку. Контракт функции записан первой строкой ее тела (proc.go:3147): она обязана запустить рабочий поток в любой ситуации, где у этого P нашлась бы работа. Здесь, кстати, окончательный ответ на вопрос из первой части, зачем в схеме отдельная буква P. Очередь нельзя привязать к потоку: потоки застревают в ядре и плодятся, а право исполнять Go-код должно оставаться в обороте. P и есть то, что передают из рук в руки.
А наш застрявший поток? Его никто не спасает, он досиживает в ядре. Когда вызов наконец вернется, exitsyscall разберет три варианта по цене: P все еще мой (быстрый путь, продолжаем бежать), P отобрали, но есть свободный (взять его), свободных нет (горутина едет в глобальную очередь, поток паркуется).
Врезка-предупреждение для тех, кто читал другие разборы. Почти все статьи и схемы рисуют P в особом состоянии Psyscall. С Go 1.26 такого состояния нет: в коде остался только могильный камень Psyscall_unused с подписью «a now-defunct state» (runtime2.go:143). Теперь P остается обычным _Prunning, а «в вызове или нет» рантайм определяет по статусу горутины. Схемы со стрелочкой «P -> syscall» устарели на уровне модели.

Кому handoffp отдает P, если все потоки заняты? Никому: приходится делать новый. Цепочка startm -> newm -> newosproc заканчивается тем самым clone(2) из первой части. Сначала, правда, рантайм ищет спящий поток в запасе: отработавшие потоки не умирают, а паркуются и переиспользуются, потому что создание потока в десятки раз дороже создания горутины (390 наносекунд на горутину против 13 микросекунд на создание потока с уборкой). Но когда запас пуст, растет число настоящих потоков ОС.
Отсюда практическое следствие, которое удивляет всех: у Go-программы с GOMAXPROCS=8 в htop может быть 40 потоков, и это норма. GOMAXPROCS ограничивает потоки, исполняющие Go-код; потоки, застрявшие в системных вызовах, в лимит не входят, о чем документация говорит прямо. Каждый блокирующий вызов уносит поток, рантайм молча заводит новые, чтобы остальные горутины не встали.
Проверяется одной программой (демо blocking_threads). Запускаю N горутин, каждая садится на syscall.Read из пустой трубы, и смотрю метрику /sched/threads/total:
go build -o /tmp/blocking_threads ./blocking_threads GOMAXPROCS=8 /tmp/blocking_threads 40
GOMAXPROCS = 8, потоков на старте = 5 горутин в блокирующем syscall = 40 потоков ОС стало = 43
Сорок залипших горутин, сорок три потока при GOMAXPROCS=8. Соотношение держится и дальше: сто горутин дают 103 потока. Потолок здесь один, та самая константа maxmcount = 10 000 (proc.go:876), и до нее мы еще дойдем.
Молча, но не бесконечно. Помнишь константу-предохранитель из первой части? sched.maxmcount = 10000, и комментарий объясняет ее назначение (proc.go:982): защита от случайной форк-бомбы, «millions of goroutines blocking on system calls, causing the runtime to create millions of threads». Проверим предохранитель демо thread_exhaustion: лимит опущен до 12, чтобы не ждать, и 50 горутин уходят в блокирующее чтение из трубы, в которую никто не пишет:
// thread_exhaustion/main.go (фрагмент) debug.SetMaxThreads(12) for i := 0; i < 50; i++ { go func() { buf := make([]byte, 1) syscall.Read(fds[0], buf) // в трубу никто не пишет }() }
лимит потоков: 12, запускаю 50 горутин с блокирующим syscall.Read runtime: program exceeds 12-thread limit fatal error: thread exhaustion
Вот граница магии. Миллион горутин на каналах: норма, 2.6 ГБ памяти. Десять тысяч с лишним горутин, одновременно залипших в блокирующих системных вызовах: смерть процесса. Лимитируются не горутины, а потоки под ними.

Теперь главный фокус рантайма. По логике выше сервер на 10 000 соединений должен был умереть от thread exhaustion, ведь каждое conn.Read() выглядит как блокирующий вызов. Не умирает, и вот почему.
Каждый сокет в Go создается сразу неблокирующим (на Linux это флаг SOCK_NONBLOCK прямо в вызове socket, на macOS то же делают отдельным вызовом), и дескриптор тут же регистрируется в системном поллере (epoll на Linux, kqueue на macOS), том самом механизме «ядро, скажи, на каком из этих сокетов появились данные». Когда горутина читает из пустого сокета, вызов мгновенно возвращает ошибку EAGAIN («данных нет, приходи позже»), поток не застревает ни на такт. Рантайм ловит EAGAIN и паркует горутину знакомым gopark с причиной IO wait. Все: ждущее соединение стоит программе одной записи в epoll и одной спящей структуры g. Ни потока, ни P.
Кто разбудит? Отдельного «потока сети» в Go нет, и это регулярно ломает интуицию. Опрос ядра встроен в цикл поиска работы: любой поток, ищущий себе горутину, между делом спрашивает netpoll «а не готов ли кто из сокетов» (в исходниках этот шаг подписан «This netpoll is only an optimization before we resort to stealing», proc.go:3502). Если работы нет вообще, последний бодрствующий поток засыпает прямо внутри netpoll и просыпается от события сети. А на случай, когда все потоки заняты вычислениями и в поиск работы никто не заходит, страхует sysmon: он проверяет, что сеть опрашивали не позже 10 мс назад.
И вот развязка вопроса-предсказания. С файлами фокус не работает. os.OpenFile честно пытается зарегистрировать файл в поллере, но epoll на Linux не принимает обычные дисковые файлы, и комментарий в исходниках называет вещи своими именами (os/file_unix.go:212):
An error here indicates a failure to register with the netpoll system. That can happen for a file descriptor that is not supported by epoll/kqueue; for example, disk files on Linux systems.
Регистрация падает, дескриптор переводится в блокирующий режим, и каждое чтение с диска становится настоящим блокирующим вызовом: поток занят до конца операции, спасает только цепочка retake и новые потоки. Ответ на предсказание: программа с 10 000 сетевых соединений обойдется горсткой потоков, программа с 10 000 одновременно висящих чтений с диска попытается создать тысячи потоков и упрется в лимиты. «В Go весь I/O асинхронный» оказывается мифом: асинхронна сеть, а диск занимает потоки.
Для полноты картины три коротких факта. time.Sleep потока не занимает: горутина паркуется, а таймер ложится в кучу таймеров своего P (с 1.14 таймеры живут на P, а не в общем массиве из 64 корзин), и будит ее планировщик, проверяющий таймеры в начале каждого поиска работы. Вызовы C через cgo для планировщика неотличимы от syscall: поток потерян на время вызова. А runtime.LockOSThread прибивает горутину к потоку намертво, это нужно библиотекам с состоянием на поток вроде GUI и OpenGL.

Все, что мы разобрали за три части, сходится в одной функции. Пора пройти ее целиком и закрыть обещание из первой части: научиться читать schedtrace.
Планировщик Go не сущность, а функция schedule(), которую каждый поток крутит на своем служебном стеке g0. Она делает три вещи: находит готовую горутину (findRunnable), помечает ее бегущей и прыгает в нее (execute). У schedule в комментарии написано «Never returns» (proc.go:4149), и это не фигура речи: из execute управление не возвращается никогда. Круг замыкается с другой стороны, и обе стороны мы видели во второй части: доработавшая горутина через goexit приходит в schedule(), заблокировавшаяся через gopark приходит туда же. Вечное колесо без главного колесика.

Самое интересное внутри findRunnable: где именно поток ищет работу. В статьях этот список называют «восемь шагов планировщика»; честности ради, в коде Go 1.27 я насчитал около тринадцати проверок с перезаходами, но главный путь действительно укладывается в восемь ступеней. Вот они, в порядке из исходников (proc.go:3404):
Проверить таймеры своего P (вдруг чей-то Sleep истек).
Уступить воркеру сборщика мусора, если идет маркировка.
Каждый 61-й тик: одна горутина из глобальной очереди.
Своя локальная очередь.
Глобальная очередь, пачкой.
Спросить netpoll: не готов ли кто из сокетов.
Украсть у соседей (work stealing).
Сдать P и уснуть.
Порядок не случаен: это лестница стоимости. Своя очередь читается вообще без замков, глобальная уже требует взять sched.lock. Netpoll ходит к ядру. Воровство обходит чужие P с атомарными операциями, а сон стоит целого системного вызова парковки.
Поменяй ступени местами, и программа либо затормозит (глобальный замок раньше своей очереди), либо сожжет процессор (воровство раньше своей очереди). Кстати, вот и ответ, зачем глобальная очередь вообще осталась после появления P: туда стекается то, чему некуда деться, переполнение локальных очередей и пробуждения без P.

Пара деталей на ступенях, мимо которых жалко пройти. Пачка из глобальной очереди (ступень 5) в коде 1.27 считается формулой min(128, все что есть, размер/GOMAXPROCS+1) (proc.go:7351): возьми свою долю, оставь другим. Правило 61-го тика знакомо по первой части, но с тонкостью, которой нет почти нигде: тик растет не на каждой горутине. Горутина, взятая из слота runnext (личный слот P на одну только что разбуженную горутину, подробности во второй части), наследует квант предшественника и тик не двигает, поэтому цепочка «передал в канал, разбудил, передал» считается за одно переключение, и sysmon вытесняет ее целиком как одну долгую горутину.
Воровство (ступень 7) устроено аккуратнее, чем то «укради половину у случайного соседа», которым я отделался в первой части. Вор делает четыре прохода по чужим P в псевдослучайном порядке (порядок строится на взаимно простых числах, чтобы обойти всех ровно по разу и не толпиться у первого P, proc.go:3843). Берет n - n/2 горутин (proc.go:7718): не половину, а потолок половины, чтобы единственная горутина у спящего соседа тоже уезжала. Жертва не блокируется и о краже не узнает: очередь устроена как кольцо с двумя счетчиками, владелец двигает хвост, вор двигает голову атомарной операцией «сравнить и заменить», а конфликт двух воров разрешается тем, что операция удается лишь одному. А чужой слот runnext трогают только на последнем, четвертом проходе, и перед этим вор обычно выжидает 3 микросекунды: вдруг владелец сам запустит свою горутину. Комментарий объясняет число с инженерной прямотой (proc.go:7734):
A sync chan send/recv takes ~50ns as of time of writing, so 3us gives ~50x overshoot.

Осталась последняя загадка планировщика: что делает поток, у которого прямо сейчас нет работы. Заснуть? Пробуждение стоит системного вызова, и если через микросекунду появится горутина, платить придется сполна. Не спать? Жечь процессор впустую. Этому конфликту в шапке proc.go посвящен самый длинный комментарий файла, «Worker thread parking/unparking» (proc.go:37), с перечислением трех отвергнутых подходов:
We need to balance between keeping enough running worker threads to utilize available hardware parallelism and parking excessive running worker threads to conserve CPU resources and power.
Балансировать между «держать достаточно работающих потоков, чтобы занять железо» и «парковать лишние, чтобы беречь процессор и энергию». Ответ Go называется spinning. Поток, не нашедший работу с первого захода, не засыпает, а крутится: обходит чужие очереди, проверяет таймеры, спрашивает netpoll. Он жжет процессор, но зато подхватит новую работу мгновенно, без пробуждения.
Ограничений два. Первое: спиннеров не больше половины занятых P, иначе на машине с GOMAXPROCS=64 и одной горутиной 63 потока крутились бы вхолостую. Второе: правило дежурства. Когда в программе появляется работа, рантайм будит новый поток только если ни один спиннер сейчас не крутится (тот и так найдет). А чтобы система не осталась без дежурного, спиннер, нашедший работу, обязан разбудить себе смену.
Дежурных может быть и несколько, потолок из первого правила никуда не девается; важно, что пост не бросают пустым и не плодят лишних. Так рантайм не тормозит и не жжет CPU одновременно.
Переход «крутился и решил заснуть» считается самым опасным местом планировщика: между «решил» и «заснул» другой поток может положить работу, увидеть спиннера и никого не будить. Работа лежит, все спят. Против этой гонки в коде выстроен протокол с барьерами памяти и повторной проверкой всех источников, подписанный «Delicate dance» (деликатный танец, proc.go:3637). Цена ошибки в комментарии названа прямо: вплоть до полного дедлока. Если захочешь потрогать редкий кусок инженерии, где каждая строчка защищает от невоспроизводимого зависания, начни с этого комментария. Я прошел по этому танцу шаг за шагом в отдельном посте. Что проверяется после решения заснуть, в каком порядке и почему пропуск любого шага вешает программу навсегда.
Теперь обещанное. GODEBUG=schedtrace=200 заставляет sysmon раз в 200 мс печатать состояние планировщика. Запускаю демо sched: горутин по сорок на каждый P (на этой машине 1280), каждая долго считает и в конце спит.
go build -o /tmp/schedlab ./sched GODEBUG=schedtrace=200 /tmp/schedlab
SCHED 201ms: gomaxprocs=32 idleprocs=0 threads=33 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=674 [ 206 14 4 6 9 9 6 6 7 5 5 10 8 8 13 10 12 12 11 19 22 21 18 20 17 17 16 15 15 4 15 14 ] schedticks=[ 14 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 13 ]
(в выводе это одна длинная строка, здесь она разбита на четыре)
Читаем по полям, и каждое теперь знакомо:
gomaxprocs=32: тридцать два P
idleprocs=0: все заняты, программа упирается в процессор
threads=33: на один больше, чем P, тридцать третий это sysmon; вырасти это число сильно, мы бы заподозрили горутины в syscall
spinningthreads=0 и needspinning=1: свободных дежурных нет, и планировщик уже заказал следующего
runqueue=674: длина глобальной очереди
в квадратных скобках: локальные очереди каждого из тридцати двух P; снимок мгновенный и неровный (первому P только что перепала пачка на 206 горутин), воровство выровняет
schedticks: счетчик переключений каждого P, тот самый, по модулю 61 которого планировщик заглядывает в глобальную очередь
Одна строка, и в ней видна вся модель: P с личными очередями, глобальный запасник, спиннеры, потоки поверх. Добавь scheddetail=1, и рантайм распечатает состояние каждого G, M и P построчно, со статусами и причинами ожидания из второй части. Обещание из первой части закрыто.
Сборщик мусора заслуживает своей серии, но три его пересечения с горутинами доскажут наш сюжет. Первое пересечение развязывает историю, которая тянется с Go 1.2.
Каждый цикл сборки дважды останавливает мир: короткая пауза в начале и короткая в конце. Как рантайм «останавливает мир»? Перекличкой. Функция stopTheWorld (proc.go:1545) выставляет счетчик «сколько P должны отчитаться», рассылает всем бегущим горутинам запрос на вытеснение и ждет. Запрос остается просьбой, а не приказом: функция preemptall честно подписана «purely best-effort» (proc.go:6890). Поэтому ожидание устроено циклом: поспать 100 микросекунд, разослать просьбы еще раз, и так пока счетчик не дойдет до нуля (proc.go:1705).
Теперь соедини это с историей вытеснения из первых двух частей. До Go 1.14 горутина с циклом for { i++ } игнорировала просьбы вечно, счетчик не доходил до нуля никогда, и STW висел вместе со всей программой: именно это показывало демо preempt с выключенными сигналами. Вытеснение в Go существует в первую очередь не ради справедливости, а потому что без него сборщик мусора не может даже начаться.
Дальше тоньше. Между двумя паузами мир работает, а сборщик конкурентно размечает память, и стеки горутин он сканирует по одной: остановил через suspendG (preempt.go:106, знакомый механизм вытеснения на новой службе), повесил на статус бит «стек занят сборщиком», просканировал, отпустил. Мир не стоит, но твою горутину могут на мгновение придержать, даже если она ничего не аллоцирует.
А вот горутине, которая аллоцирует, приходится хуже, и это самое неожиданное место всей связки. У каждой горутины есть счет gcAssistBytes. Пока идет маркировка, каждый make и append списывает с него размер объекта. Когда счет уходит в минус, горутина не получает память сразу. Ее разворачивают внутрь сборщика, и она своими руками сканирует чужие объекты, отрабатывая долг: с запасом, минимум 64 КБ скан-работы за раз (mgcpacer.go:56), чтобы не дергаться на каждой мелочи.
Называется это mark assist, и вот перевод на язык симптомов: твой сервис тормозит во время GC не потому, что «пауза», паузы короткие. Тормозит функция, которая быстро аллоцирует, потому что прямо внутри ее append рантайм заставил ее поработать сборщиком. Логика жесткая, но честная: если аллоцируешь быстрее, чем фоновые воркеры размечают, куча растет бесконечно; налог делает скорость аллокации саморегулируемой. Фоновым воркерам, к слову, забронирована четверть процессора (константа 0.25 в исходниках, mgcpacer.go:39), и воркер сборщика стоит в лестнице findRunnable выше твоей локальной очереди: вот кто вклинивается перед твоими горутинами на ступени 2.
Увидеть оба налога можно одной переменной: GODEBUG=gctrace=1 печатает строку на каждую сборку, где CPU-время маркировки разложено на три слагаемых, и первое из них assist, доля, выбитая из твоих аллоцирующих горутин. А GODEBUG=gcstoptheworld=1 отключает конкурентную сборку, превращая каждую в полную остановку мира: машина времени в мир без всей этой механики.

Последнее пересечение переворачивает интуицию «в Go есть GC, значит утечек нет». Сборщик считает стек каждой горутины корнем достижимости, наравне с глобальными переменными (в коде подписано: «regular GC — scan every goroutine», mgcmark.go:159). Горутина, навсегда зависшая на канале, для GC неотличима от той, что проснется через секунду: ее стек, ее замыкания, ее буферы считаются живыми. Утечку горутин сборщик не чинит по определению, единственную из всех: горутина стоит не под сборщиком, а над ним, она сама источник корней. Каждая забытая горутина держит минимум 2 КБ навсегда, и что с этим делать, расскажет следующая глава: у рантайма наконец появился ответ.
Финишная прямая: что рантайм научился делать за тебя в последних релизах. Подбор пристрастный, только то, что новичок реально потрогает.
Во второй части я обещал вернуться к самому массовому багу в истории Go. Вот он:
for i := 0; i < 3; i++ { go func() { fmt.Println(i) }() }
До Go 1.22 этот код почти всегда печатал 3 3 3. Все три замыкания ловили одну и ту же переменную i (аргументов у функции нет, вычислять при запуске нечего, замыкание хранит ссылку), а горутины стартовали после конца цикла, когда i уже равнялась трем. Поколения гоферов заучивали заклинания i := i и go func(i int) {...}(i).
В Go 1.22 поменяли спецификацию языка: каждая итерация цикла объявляет собственную переменную. Тот же код печатает 0 1 2 в каком-то порядке, заклинания не нужны. Одна ловушка осталась: новое поведение включается директивой go 1.22 (или выше) в файле go.mod, а не версией компилятора. Собираешь старый проект новым Go, и баг на месте, пока не поднимешь директиву.
testing/synctest (эксперимент в 1.24, стабильный в 1.25) закрыл вечную боль: тесты конкурентного кода, увешанные time.Sleep(100 * time.Millisecond), медленные и мигающие. Тест запускается в «пузыре» с фейковыми часами, стартующими в полночь 2000-01-01. Время продвигается только когда все горутины пузыря durably blocked: заблокированы так, что разбудить их может лишь другая горутина пузыря. Тогда часы прыгают сразу к моменту ближайшего пробуждения. Насколько это меняет цену теста, видно на паре одинаковых тестов с секундным таймаутом (демо synctest_bubble):
--- PASS: TestRealClock (1.00s) реальное время: 1.000233495s --- PASS: TestFakeClock (0.00s) реальное время: 27.821µs
Секунда против двадцати восьми микросекунд, разница в тридцать шесть тысяч раз. Сутки модельного времени внутри пузыря обходятся в 4 микросекунды реального: длина таймаута перестает влиять на длительность теста вообще. А если все заблокированы и время никого не разбудит, synctest паникует: это доказанный дедлок. Пузырь не надстройка над библиотекой, рантайм знает о нем в самом сердце планировщика: во второй части мы видели список причин ожидания, так вот, для пузырей там завели отдельные, с пометкой «(durable)» (runtime2.go:1266).
sync.WaitGroup.Go (1.25) сжал в одну строку тройку wg.Add(1) / go func() / defer wg.Done(), в которой новички стабильно путали место для Add. В 1.25 весь метод состоял из семи строк, а в 1.26 его переписали, и интереснее всего в новой версии обработка паники (sync/waitgroup.go:236): если функция паникует, Done сознательно не вызывается. Иначе Wait в main разблокировался бы, программа могла бы выйти раньше, чем паника допечатается, и упавший тест выглядел бы зеленым.
Самое практичное изменение для всех, кто катает Go в Kubernetes. Классическая грабля выглядела так: нода с 64 ядрами, лимит контейнера 2 CPU, GOMAXPROCS по старинке равен 64, рантайм пытается исполнять код в 64 потока, контейнер вылетает за квоту, и оркестратор замораживает его до конца периода. CPU-троттлинг на ровном месте, лечили сторонней библиотекой uber-go/automaxprocs.
С Go 1.25 рантайм читает cgroup сам: GOMAXPROCS = минимум из числа доступных CPU и лимита контейнера, причем дробная квота округляется вверх, а ниже двух рантайм не опускается (под с limit 500m получит GOMAXPROCS=2, запас оставлен намеренно). Больше того, sysmon перечитывает лимит на лету: поменяли квоту живому контейнеру, GOMAXPROCS подстроится без перезапуска.
Потрогать это можно без докера, одним systemd-скоупом. Демо cgroup_gomaxprocs печатает три числа: сколько ядер видит рантайм, сколько P он завел и что лежит в cpu.max его cgroup.
go build -o /tmp/gmp ./cgroup_gomaxprocs systemd-run --user --scope -q -p CPUQuota=250% /tmp/gmp
NumCPU = 32 GOMAXPROCS = 3 cpu.max = 250000 100000
Две с половиной квоты превратились в три P: дробь округлилась вверх. А NumCPU остался честными тридцатью двумя, он по-прежнему показывает ядра машины, а не контейнера.
Как и у переменной цикла, у новинки есть ловушка с go.mod: контейнерное поведение включается только для модулей с директивой go 1.25 и выше, старый go.mod живет по-старому. Отсюда два следствия. Библиотека automaxprocs больше не нужна, как только go.mod дорос до 1.25. И старый совет «поставь runtime.GOMAXPROCS(runtime.NumCPU()) в начале main» стал вредным: первый же ручной вызов ставит флаг и выключает всю автоматику насовсем, включая слежение за cgroup.
runtime.NumGoroutine() отвечает одним числом, по которому не понять, все ли хорошо. В 1.26 появились метрики /sched/goroutines/*, раскладывающие горутины по состояниям из второй части. Демо sched_metrics: 100 спящих горутин плюс считающие, которых берем вдвое больше, чем ядер (на 32 потоках это 64):
GOMAXPROCS = 32, считающих горутин = 64, спящих = 100 /sched/goroutines/running:goroutines = 32 /sched/goroutines/runnable:goroutines = 33 /sched/goroutines/waiting:goroutines = 105 /sched/goroutines/not-in-go:goroutines = 0 /sched/goroutines-created:goroutines = 170 /sched/threads/total:threads = 33
Читается мгновенно: исполняются 32 (ровно GOMAXPROCS, больше физически нельзя), еще 33 готовы и ждут очереди, 105 спят: сто наших на канале плюс служебные горутины рантайма. runnable - главное число здесь: если оно стабильно больше нуля, горутинам не хватает процессора, и никакой NumGoroutine этого не покажет. Монотонно растущий waiting - датчик утечки. А not-in-go - те самые застрявшие в syscall из начала этой части.
И финальный аккорд эволюции: детектор утечек горутин (эксперимент в 1.26, стабильный профиль в 1.27, вклад Vlad Saioc из Uber). Идея красиво выворачивает механику GC, которую мы разобрали главой выше. Обычная сборка считает корнями все горутины. Специальная сборка детектора берет в корни только выполнимые: те, что бегут или могут побежать. Если по итогам разметки канал, на котором висит спящая горутина, оказался недостижим ни из одной живой, значит, записать в него физически некому, ожидание вечно, утечка доказана. Такая горутина получает статус _Gleaked (runtime2.go:94) и попадает в профиль.
Проверим на паре демо из репозитория. В 1.27 профиль включен по умолчанию, никакой GOEXPERIMENT не нужен: собираю обычным go build и запускаю с GOMAXPROCS=1 для воспроизводимости:
go build -o /tmp/leak1 ./leak_detected GOMAXPROCS=1 /tmp/leak1
Первое демо, leak_detected: функция запускает горутину-отправителя и выходит по ошибке раньше, чем прочитает из канала:
goroutineleak profile: total 1 1 @ 0x47d82a 0x41421c 0x413e17 0x4de954 0x483561 # 0x4de953 main.earlyReturn.func1+0x33 leak_detected/main.go:12
Пойман, с точной строкой рождения. Второе демо, leak_missed, почти такое же: пять отправителей, прочитано одно значение, четыре горутины зависли навсегда:
живых горутин: 5 goroutineleak profile: total 0
Ноль. А строка живых горутин: 5 это main плюс четыре честно зависших отправителя. Release notes описывают ограничение мягко: детектор может пропустить утечку, если примитив достижим из глобальной переменной или из живой горутины. Здесь он промахнулся на почти учебном случае, и вывести точную причину из описания не выйдет: считай слепые зоны недокументированными. Вывод для практики: находка детектора это доказанная утечка, а ноль в профиле не доказывает ничего; профиль /debug/pprof/goroutine?debug=2 и метрика waiting никуда не деваются. И учти цену: каждое чтение профиля goroutineleak запускает полноценную сборку мусора.

Совсем короткой строкой еще два пункта. Green Tea, новый алгоритм маркировки GC (по умолчанию с 1.26), снизил накладные расходы сборки на заявленные 10-40%: меньше сборщика, меньше и налога на горутины. А под iter.Pull (в языке с 1.23) живет механизм coro, приехавший в рантайм еще в 1.22, «extra concurrency without extra parallelism» (coro.go:12): переключение горутин в обход планировщика; в коде его сравнивают с каналом, на котором всегда заблокирована одна горутина. Вторая модель конкурентности внутри Go, о которой мало кто знает.
Пройдем напоследок весь путь одной горутины через машину, которую мы собирали три части. go f() компилируется в вызов newproc; структура g берется из пула мертвых, ей достается готовый стек из кучи и место в слоте runnext. Поток, крутящий schedule, достает ее из очереди, execute привязывает ее к потоку, gogo восстанавливает сохраненный контекст из gobuf. Она бежит, пока не заблокируется на канале (gopark, поток немедленно берет следующую) или не досчитается до вытеснения (sysmon, 10 мс, SIGURG). Ждет она без потока: сокет (записью в epoll) или таймер (в куче своего P). Доработав, она «возвращается» в goexit, которого никто не вызывал, и ложится в пул ждать следующего go f().
Семантика этих двух символов не изменилась с 2009 года ни разу. Все под ними переписали трижды.

Соберем и мифы, которые серия разобрала по исходникам:
На собеседовании скажут | А в proc.go |
|---|---|
«Горутина - это легкий поток» | Структура g со стеком в куче; исполняет ее любой свободный M |
«Перед go func() в цикле нужно i := i» | С 1.22 переменная своя на каждой итерации (следи за go.mod) |
«В main надо ставить GOMAXPROCS(NumCPU())» | Вредно с 1.25: ручной вызов выключает чтение cgroup |
«Планировщик Go кооперативный» | С 1.14 два механизма: флаг в прологе плюс сигнал SIGURG |
«В Go весь I/O асинхронный» | Сеть через netpoll, файлы занимают поток целиком |
«GC есть, значит утечек нет» | Утекшая горутина - сама корень GC, ее не соберут никогда |
И инструменты, которые ты теперь умеешь читать: runtime.NumGoroutine() как первый датчик; метрики /sched/goroutines/* как разложение по состояниям; /debug/pprof/goroutine?debug=2 со стеками и причинами ожидания; GODEBUG=schedtrace=200 как окно в GMP; GODEBUG=gctrace=1 как счет за сборку мусора. Плюс два, которых серия не разбирала, но без них список неполон: go tool trace рисует ленту «какая горутина на каком P в какую микросекунду», а go test -race ловит гонки по данным.
Куда копать дальше, если зацепило. Три дизайн-дока, с которых все началось: go11sched Вьюкова про GMP, contigstacks про переезжающие стеки, go15gomaxprocs Кокса. Два доклада: Вьюков на Hydra 2019 про планировщик из первых рук и Остин Клементс на GopherCon 2020 про вытеснение сигналом. И deep dive nghiant3223, самый глубокий разбор планировщика из существующих, когда захочется на уровень ниже. А главный источник у тебя уже есть: исходники рантайма читаются как хорошая книга, теперь ты знаешь с какой страницы начинать.
Надеюсь теперь ты стал лучше разбираться в том что такое горутинки. Если да, то подписывайся на tg’шку.