voidlearn
 

2.1Горутины и планировщик

Разберём, как горутина попадает на поток ОС и почему число goroutine не равно числу потоков. Затем проследим блокировки и роль P в исполнении Go-кода.

Почему потоков ОС не хватает

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

1. Поток ОС - то, что реально исполняет код, и его дорого плодить

За словом «дорого» стоят три конкретные вещи. Память: каждому потоку ядро отводит собственный стек фиксированного размера, обычно 1–8 МБ. Резервируется он виртуально, физические страницы подтягиваются по мере обращения, но само адресное пространство расходуется сразу, и на миллион потоков это терабайты. Создание: 10–100 микросекунд, потому что надо сходить в ядро и завести служебные структуры. Существование: чем больше потоков, тем больше работы у планировщика ядра при каждом выборе. Отсюда практический потолок: десятки тысяч потоков уже много, миллион не поднять.

2. Переключение контекста - сохранить одно состояние и загрузить другое

Между потоками ОС контекст переключает ядро, и обходится оно примерно в 1–2 микросекунды. Дорого не копирование регистров само по себе, а сопутствующее: переход в режим ядра и обратно, а главное, холодные кэши процессора. Новый поток лезет в свои данные, которых в кэше нет, и первые тысячи обращений идут в оперативную память вместо кэша.

3. Системный вызов - просьба к ядру, на время которой поток встаёт

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

4. Планировщик - тот, кто решает, кому работать сейчас

У ядра ОС планировщик свой, он работает с потоками и снимает их с процессора без их согласия, когда истёк квант времени (обычно единицы миллисекунд). Планировщик Go начинал как кооперативный, а настоящее вытеснение получил только в 1.14.

Зачем Go понадобился свой планировщик

Go построен на идее «на каждую задачу по исполнителю»: на каждое соединение, на каждый запрос, на каждую стадию конвейера. Это десятки и сотни тысяч одновременных задач, и подавляющее большинство из них в любой момент просто чего-то ждёт. Потоков ОС столько не завести: упрёмся в память и в стоимость создания (пункт 1), а каждое ожидание в системном вызове будет выводить из игры целый поток (пункт 3).

Поэтому Go завёл второй уровень планирования, поверх ядерного. Рантайм создаёт немного потоков ОС и сам раскладывает по ним много горутин, переключая их в пользовательском пространстве, вообще не обращаясь к ядру. Это модель M:N.

Что даёт эта надстройка и чего она стоит

Даёт три вещи: дешёвое создание задачи (не идём в ядро), дешёвое переключение (не идём в ядро) и, главное, рантайм знает семантику ожидания. Он видит, что горутина встала на канале, и мгновенно ставит на её место другую, вместо того чтобы отдавать остаток кванта обратно ядру. А стоит это того, что рантайму приходится самому решать всё, что решает настоящий планировщик: как разложить горутины по потокам, как не дать одной захватить процессор навсегда и что делать, когда горутина всё-таки ушла в блокирующий системный вызов и утащила поток с собой. Ответ Go на все три вопроса и есть GMP.

Что такое горутина физически

Горутина живёт в памяти как значение типа runtime.g: структура в куче рантайма, где лежат указатель на её стек, сохранённый контекст выполнения (gobuf: SP, PC, указатель на саму g), статус, ссылка на m, на котором она сейчас исполняется, поля для defer/panic-цепочек, для профилировщика и для планировщика. Сам объект g занимает порядка 450 байт (456 на Go 1.27, в прошлых версиях чуть меньше); главный расход всё равно стек, он стартует от 2 КБ.

Оператор go f(x) компилируется в вызов runtime.newproc: рантайм берёт свободную g из кэша (gfree у P или глобального списка) либо создаёт новую, копирует аргументы в её стек, выставляет gobuf.pc на тело функции, переводит в статус _Grunnable и кладёт в слот runnext текущего P. Никакого обращения к ядру: запуск горутины стоит десятки-сотни наносекунд, а не микросекунды, как у pthread_create.

ПроцессПоток ОСГорутина
Кто создаётядро (fork)ядро (clone)рантайм Go, в user space
Кто планируетпланировщик ядрапланировщик ядра, вытесняющийпланировщик Go + сигнал для вытеснения
Адресное пространствосвоё, изолированноеобщее с процессомобщее с процессом
Стексвоя память целикомфиксированный, 1–8 МБ, резервируется виртуальноот 2 КБ, растёт и сжимается копированием
Стоимость созданиямиллисекунды~10–100 мкс~0,1–0,3 мкс
Переключениесмена таблиц страниц, TLB flush~1–2 мкс, вход в ядро~50–200 нс, сохранить 3 регистра в gobuf
Сколько потянет машинатысячитысячи–десятки тысячсотни тысяч–миллионы
Есть ли idPIDTIDесть goid, но намеренно не выставлен в API
Готовая формулировка

«Горутина - это задача, а не поток. Она мультиплексируется на потоки ОС в пропорции M:N. Дёшева она по трём причинам: маленький растущий стек вместо фиксированного мегабайтного, переключение без входа в ядро, и планировщик, который знает точки, где горутину безопасно снять, потому что сам их и расставил.»

Три буквы: G, M, P

  • G (goroutine) - задача. Их заводят миллионами.
  • M (machine) - поток ОС. Только M реально исполняет код на ядре процессора. Число M плавает, лимит по умолчанию 10 000 (debug.SetMaxThreads).
  • P (processor) - право исполнять Go-код плюс всё, что нужно для локальной работы без глобальных локов: локальная очередь runnable-горутин на 256 слотов, слот runnext, кэш аллокатора mcache, кэш свободных g, кэш таймеров, буфер GC-работы. Число P фиксировано и равно GOMAXPROCS.

Чтобы выполнить горутину, M должен владеть P. Значит, одновременно исполняется ровно GOMAXPROCS горутин, сколько бы потоков ни было создано. P появился в Go 1.1 именно ради локальных очередей: до этого была одна глобальная очередь под одним мьютексом, и планировщик не масштабировался.

Глобальная очередь runnable-горутин sched.runq - одна на процесс, под глобальным локом проверяется раз в 61 тик - чтобы очередь не голодала P0 локальная очередь, 256 слотов runnext G G G G M0 поток ОС G7 выполняется P1 очередь заполнена runnext G G G G G G M1 поток ОС G12 выполняется P2 очередь пуста M2 ищет работу простаивает work stealing: пустой P крадёт половину очереди у случайного соседа GOMAXPROCS = число P. Одновременно исполняется ровно столько горутин, сколько P, а не сколько M: P - это право исполнять Go-код. Горутин может быть миллион, M - сотни (потолок 10 000), P - обычно по числу ядер. Это и есть модель M:N.
GMP. Каждый P держит свою очередь и работает без глобальных локов; глобальная очередь работает только как «перелив» и балансир. Простаивающий P не ждёт, а идёт красть работу, и неравномерная нагрузка сама собой размазывается по ядрам.

Как планировщик выбирает следующую горутину

Цикл schedule() вызывает findRunnable(), и порядок поиска там строго определён. На собесе его просят назвать по шагам:

  1. Каждый 61-й тик планировщика: заглянуть в глобальную очередь и взять оттуда одну горутину. Магическое число нужно, чтобы эта очередь не голодала, пока локальная бесконечно пополняет сама себя.
  2. runnext текущего P, слот на одну горутину. Туда попадает та, которую только что породил go или разбудил канал: расчёт на локальность кэша, «мы только что её создали, данные горячие».
  3. Локальная очередь P (runqget), lock-free кольцевой буфер на 256 элементов.
  4. Глобальная очередь: берём сразу пачку (len/GOMAXPROCS + 1), чтобы не ходить под глобальный лок на каждую горутину.
  5. netpoll в неблокирующем режиме: забрать горутины, чьи сокеты стали готовы (подробности ниже, в разделе про блокировки).
  6. Work stealing: до четырёх попыток украсть у случайно выбранного P половину его локальной очереди. На последней попытке разрешено красть и runnext, и таймеры.
  7. Не нашлось ничего: M отдаёт P в список простаивающих и паркуется (stopm), пока его кто-нибудь не разбудит.
sysmon - поток без P

Отдельный M, который крутится вне модели GMP (ему P не нужен) и спит от 20 мкс до 10 мс. Он делает четыре вещи: retake отбирает P у потока, застрявшего в syscall дольше 20 мкс, и помечает на вытеснение горутину, которая крутится дольше 10 мс; netpoll, если сеть никто не опрашивал больше 10 мс; forcegc, если GC не запускался 2 минуты; и будит scavenger. Без sysmon Go-программа с одной бесконечно считающей горутиной вешала бы GC.

Блокировки: syscall, netpoller, gopark

Спрашивают это так: «горутина заблокировалась, что с потоком?». Ответ зависит от вида блокировки, и вариантов ровно три.

Что случилось Что делает рантайм Поток M Контекст P syscall read() файл, cgo, exec entersyscall помечает G как _Gsyscall; sysmon через 20 мкс отбирает у неё P заблокирован в ядре вместе с горутиной отцепляется и уходит к другому M - работа идёт conn.Read() сокет не готов fd неблокирующий, EAGAIN: gopark(G) и регистрация в netpoller (epoll/kqueue/IOCP) не блокируется - берёт следующую G остаётся при своём M, простоя нет <-ch, mu.Lock() данных нет gopark: G снимается с M и кладётся в очередь ожидания канала/семафора - без ядра переключается на другую G за десятки наносекунд остаётся при своём M, простоя нет Блокировка горутины не равна блокировке потока. Поток теряется только на настоящем блокирующем syscall и в cgo - поэтому 100 000 горутин на сети живут на десятке потоков, а 100 000 горутин на чтении файлов породят сотни потоков и съедят память.
Три вида блокировки. Только первая строка стоит потока ОС. Вторая держится на netpoller: Go переводит все сетевые дескрипторы в неблокирующий режим и сам мультиплексирует их через epoll/kqueue, пряча это за синхронным API.

netpoller тонко оборачивает epoll (Linux), kqueue (BSD/macOS), IOCP (Windows) и их аналоги на других ОС; io_uring рантайм Go не использует. Когда ты открываешь сокет через net, рантайм ставит дескриптору O_NONBLOCK и регистрирует его в поллере. Твой conn.Read(buf) выглядит синхронным, но внутри: попытка read → EAGAIN → gopark горутины со ссылкой на её g в структуре pollDesc. Когда epoll_wait (его дёргает планировщик в findRunnable и sysmon) сообщает о готовности, netpollready достаёт g и переводит её в _Grunnable. Это и есть «асинхронный I/O с синхронным кодом», ради которого в других языках пишут async/await.

Что netpoller не покрывает

Обычные файлы на Linux. epoll для регулярных файлов бесполезен: они «всегда готовы», а ждём мы на самом деле дисковый I/O внутри ядра. Поэтому os.File.Read уходит в честный блокирующий syscall, и тысяча горутин, читающих тысячу файлов, породит близко к тысяче потоков ОС. То же самое с cgo-вызовами и os/exec. Если на собесе спросят «почему у сервиса 800 потоков при GOMAXPROCS=8», ответ почти всегда «блокирующие syscall или cgo».

Померить это стало проще: в Go 1.26 в runtime/metrics завезли метрики планировщика: /sched/threads/total:threads (сколько потоков ОС реально держит рантайм) и разбивку горутин по состояниям /sched/goroutines/running, /runnable, /waiting, /not-in-go. Последняя как раз и считает горутины, застрявшие в системном вызове или в cgo, то есть ровно те, что оттягивают на себя потоки. Раньше это приходилось выводить из pprof/threadcreate и дампа горутин.

Вытеснение: кооперативное и асинхронное

До Go 1.14 планировщик был кооперативным: снять горутину можно было только в точке, где она сама заглядывает к рантайму. Такими точками были вызовы функций (в прологе есть проверка стека), операции с каналами, аллокации, runtime.Gosched(), syscall. Механика: планировщик выставлял g.stackguard0 = stackPreempt, заведомо «переполненное» значение; пролог функции из-за него уходил в morestack, а тот видел флаг и передавал управление планировщику.

Дырка была очевидная: цикл без вызовов функций и без аллокаций вытеснить нельзя.

func main() {
    runtime.GOMAXPROCS(1)
    go func() { for {} }()      // до Go 1.14: этот цикл навсегда захватывал единственный P
    time.Sleep(time.Millisecond)
    fmt.Println("этой строки в Go 1.13 не будет, а в 1.14+ будет")
}

// запуск на go1.27:
// этой строки в Go 1.13 не будет, а в 1.14+ будет

В Go 1.14 появилось асинхронное вытеснение (proposal 24543). Sysmon видит, что горутина крутится дольше 10 мс, и посылает потоку сигнал SIGURG. Обработчик сигнала проверяет, что горутина стоит в «асинхронно безопасной точке» (нет незавершённой работы с указателями, не в критической секции рантайма), и подменяет адрес возврата на asyncPreempt: та сохраняет все регистры (поэтому кадр у неё большой) и уходит в планировщик. Почему именно SIGURG: он редко используется и не конфликтует с debuggers и библиотеками.

Ответ на «может ли горутина захватить процессор»

«С Go 1.14 практически нет: через 10 мс придёт SIGURG и её вытеснят. Но есть исключения, где вытеснение невозможно: код внутри cgo-вызова, участки рантайма с //go:nosplit, отключённые сигналы, а также случай, когда все P заняты и вытеснять некуда. И вытеснение не спасает от логической монополии: горутина, которая держит мьютекс и считает миллиард итераций, вытеснится, но лок не отпустит.»

GOMAXPROCS и контейнеры

GOMAXPROCS задаёт число P, то есть максимальное число горутин, исполняющих Go-код одновременно. По умолчанию до Go 1.25 это runtime.NumCPU(), то есть количество логических ядер, видимых процессу. В контейнере это ядра всей ноды, а не лимит cgroup.

Отсюда классическая проблема Kubernetes: под с limits.cpu: "1" на 64-ядерной ноде получает GOMAXPROCS=64. Go честно запускает 64 горутины параллельно, cgroup выдаёт им суммарно одно ядро, и всё это упирается в CFS throttling: планировщик ядра замораживает контейнер до конца периода (обычно 100 мс). В итоге рваные p99-латенси, раздутый time-to-first-byte и «непонятные» паузы GC, потому что GC тоже раскладывается на GOMAXPROCS воркеров.

// До Go 1.25 делали так:
import _ "go.uber.org/automaxprocs"   // читает cgroup-лимит и вызывает runtime.GOMAXPROCS

// Или вручную:
runtime.GOMAXPROCS(4)                  // но это статика, при смене лимита не обновится

// В Go 1.25 (при go >= 1.25 в go.mod) из коробки:
//  * дефолт = min(NumCPU, ceil(cgroup CPU limit)), но не меньше 2
//  * значение пересчитывается при смене лимита (проверка примерно раз в секунду)
//  * runtime.GOMAXPROCS(0) теперь просто читает значение, не фиксируя его
//  * отключается: GODEBUG=containermaxprocs=0,updatemaxprocs=0 или явный GOMAXPROCS=N
Практика

Никогда не крути GOMAXPROCS «на глаз» ради производительности: на CPU-bound задачах это почти всегда деградация. Оправданных случаев три: попасть в cgroup-лимит (теперь это решает сама версия Go), детерминизм в тестах (GOMAXPROCS=1 иногда прячет гонки, а иногда, наоборот, делает их воспроизводимыми) и обход багов в чужих библиотеках. И помни: GOMAXPROCS не ограничивает число потоков, их ограничивает debug.SetMaxThreads, по умолчанию 10 000, при превышении процесс падает.

Стек горутины: 2 КБ и как он растёт

Горутина стартует со стеком не меньше 2 КБ (константа stackMin, такова она с Go 1.4). С Go 1.19 это только нижняя граница: на каждом GC рантайм считает средний размер стеков, и новые горутины стартуют сразу с него, округлённого до степени двойки. До Go 1.3 использовались сегментированные стеки: при нехватке места выделялся новый сегмент и связывался со старым. От этого отказались из-за «hot split»: если граница сегмента попадала внутрь горячего цикла, каждая итерация выделяла и освобождала сегмент, и скорость падала в разы. С Go 1.3 стеки копирующие.

потолок 1 ГБ на 64 бит (250 МБ на 32) - дальше fatal error: stack overflow 2 КБ стек старт горутины 4 КБ стек 1-е переполнение 8 КБ стек 2-е 16 КБ … стек каждый раз ×2 morestack copystack copystack shrinkstack во время GC: ужать вдвое, если занято < 1/4 Стек растёт не расширением, а переездом: рантайм выделяет вдвое больший блок, копирует туда старый и правит все указатели, которые смотрели внутрь стека - для этого компилятор и хранит точные карты указателей. Поэтому адрес переменной на стеке нестабилен.
Рост стека. Пролог каждой функции сравнивает SP с g.stackguard0; если места не хватает, уходит в morestack и copystack. Тот же механизм приспособили под кооперативное вытеснение: планировщик просто пишет в stackguard0 значение stackPreempt.

Вопросы

12
Суть: горутина - структура runtime.g в куче плюс маленький растущий стек; её планирует рантайм Go в user space и мультиплексирует на потоки ОС в модели M:N.

У процесса своё изолированное адресное пространство и набор ресурсов ядра. Внутри процесса планируются потоки: у каждого свой стек и регистры, а память общая; переключение между потоками стоит входа в ядро и порядка микросекунды. Горутину планирует сам рантайм, и ядро о ней не знает вообще.

Три источника дешевизны

  1. Стек. Поток резервирует фиксированный (1–8 МБ виртуально, страницы подтягиваются по обращению). Горутина стартует от 2 КБ реальной памяти и растёт копированием. Миллион потоков занимает терабайты виртуального адресного пространства, а миллион горутин обходится несколькими гигабайтами реальной памяти.
  2. Переключение. Сменить горутину значит сохранить SP, PC и указатель на g в gobuf и загрузить другие. Не нужно менять привилегии, не нужно сбрасывать TLB, кэши остаются тёплыми. ~50–200 нс против 1–2 мкс.
  3. Кто планирует. Планировщик Go знает семантику: он видит, что горутина встала на канале, и переключает мгновенно, а не ждёт истечения кванта. Ядро такого не знает.

Чем горутина хуже потока

  • Нет приоритетов, нет affinity к ядру, нет real-time гарантий.
  • Нельзя убить извне (см. отдельный вопрос).
  • Настоящий блокирующий syscall всё равно займёт целый поток ОС.
  • Нет изоляции: паника в любой горутине без recover роняет весь процесс.
Добивка про goid

У горутины есть числовой goid, его видно в панике и в runtime.Stack. Но в публичном API его нет намеренно: команда Go боялась, что появятся goroutine-local storage и «привязка контекста к id», как с ThreadLocal в Java, а это ломает всю модель явной передачи контекста. Достают его грязным парсингом runtime.Stack, но на ревью такое не проходит.

Суть: 2 КБ на старте (с Go 1.19 это нижняя граница: рантайм подстраивает старт под средний стек горутин); при нехватке рантайм выделяет вдвое больший блок, копирует туда содержимое и правит указатели внутрь стека. Потолок - 1 ГБ на 64 бит.

Механика

Компилятор вставляет в пролог почти каждой функции сравнение указателя стека с g.stackguard0. Если для кадра места не хватает, управление уходит в runtime.morestack, оттуда в newstack и copystack: выделить блок вдвое больше, скопировать байты, обойти все указатели, которые смотрели внутрь старого стека, и сдвинуть их на разницу адресов. Для этого компилятор хранит точные карты указателей на каждый safe-point: тот же механизм обслуживает и точный GC.

Обратное делает shrinkstack: во время GC, если горутина использует меньше четверти своего стека, он ужимается вдвое.

Историческая часть, которую любят спрашивать

До Go 1.3 стеки были сегментированными: новый кусок связывался со старым списком. Проблема называлась «hot split»: если граница сегмента попадала внутрь горячего цикла, каждая итерация выделяла и освобождала сегмент, и профиль проваливался на порядок. В 1.3–1.4 перешли на копирующие стеки, а начальный размер снизили с 8 КБ до 2 КБ.

// Показать рост стека можно так:
func deep(n int) {
    var pad [512]byte           // чтобы кадр был заметным
    _ = pad
    if n > 0 { deep(n - 1) }
}
// gctrace рост стека не покажет. Его видно в CPU-профиле как runtime.morestack
// и runtime.copystack: go test -run TestDeep -cpuprofile cpu.out
Практическое следствие

Адрес переменной на стеке нестабилен: после copystack он изменится. Поэтому нельзя хранить uintptr на стековые данные, нельзя передавать указатель на стековую переменную в C (без cgo.Handle или runtime.Pinner), и поэтому же переменная, чей адрес утекает наружу, обычно уезжает в кучу. Ещё следствие: бесконечная рекурсия падает не сразу, а когда стек дорастёт до 1 ГБ, и падает с fatal error: stack overflow, которую recover не ловит.

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

Конкурентная программа остаётся конкурентной на одном ядре: GOMAXPROCS=1 не мешает запустить тысячу горутин, они просто будут чередоваться. Параллелизм появляется, когда ядер больше одного и GOMAXPROCS > 1. Отсюда несимметричность: конкурентность даёт возможность параллелизма, но не наоборот.

Пример, который лучше мантры

HTTP-сервер, который на каждый запрос ходит в базу и в стороннее API. На одном ядре он всё равно обслужит тысячу одновременных соединений: пока одна горутина ждёт ответа сети, ядро свободно, и на нём работает другая. Это чистая конкурентность без параллелизма, и выигрыш тут не от ядер, а от того, что ожидание не блокирует.

Теперь обратный случай: посчитать SHA-256 от тысячи файлов. Это CPU-bound работа, и конкурентность сама по себе не ускорит ничего: на одном ядре тысяча горутин посчитает ровно столько же, сколько один цикл, только с накладными расходами на переключения. Ускорит именно параллелизм: восемь ядер дадут примерно восьмикратный выигрыш.

КонкурентностьПараллелизм
Что этодизайн: задачи независимыисполнение: задачи идут одновременно
Нужно ли много ядернетда
Что даётотзывчивость, утилизацию ожиданийпропускную способность на CPU
Где выигрываетI/O-bound: сеть, диск, БДCPU-bound: хеши, сжатие, матрицы
В Go этогорутины, каналы, selectGOMAXPROCS и число ядер
Как это звучит на собесе

Цитата Роба Пайка «Concurrency is not parallelism» сама по себе ничего не показывает, её называют все. Показывает продолжение: «конкурентность про то, как разложить задачу на независимо продвигающиеся части; параллелизм про то, как эти части исполнить. Go даёт первое как языковую конструкцию, второе как настройку рантайма.»

Суть: G - горутина (задача), M - поток ОС (исполнитель), P - контекст с локальной очередью и кэшами (право исполнять Go-код). Для работы нужна связка M+P; число P фиксировано и равно GOMAXPROCS.

Зачем понадобился P

В Go 1.0 модель была GM: одна глобальная очередь под одним мьютексом. На многоядерных машинах это упиралось в contention, а ещё убивало локальность: горутина могла уехать на любой поток, и кэш процессора обнулялся. В Go 1.1 Дмитрий Вьюков добавил P: теперь у каждого P своя очередь на 256 слотов (lock-free кольцевой буфер) и свой mcache, так что обычные аллокации и планирование идут без единого атомарного конфликта.

Порядок поиска работы (findRunnable)

  1. раз в 61 тик: одна горутина из глобальной очереди (чтобы та не голодала);
  2. runnext: слот на одну «только что созданную/разбуженную» горутину, ради локальности кэша;
  3. локальная очередь P;
  4. глобальная очередь: пачкой len/GOMAXPROCS + 1;
  5. неблокирующий netpoll;
  6. work stealing: до 4 попыток украсть половину очереди у случайного P;
  7. ничего нет, и тогда P в idle-список, M паркуется.

Где рождается горутина

go f() кладёт новую G в runnext текущего P, а прежнюю горутину из этого слота сдвигает в локальную очередь. Если та переполнена (256 штук), половина её вместе с выбитой из runnext горутиной уезжает в глобальную очередь одним батчем. Это и есть «перелив»: одному P не дают накопить всё.

Тонкость про runnext

runnext ломает FIFO: последняя созданная горутина запускается первой. Размен осознанный: локальность кэша против справедливости. Горутина из runnext «наследует» квант: донашивает те же 10 мс, что достались текущей, и пара, которая перебрасывается сообщениями, не заморит остальную очередь. А если следующий go займёт слот раньше, прежняя горутина уедет в хвост локальной очереди. И красть runnext у соседа разрешено только на последней попытке стилинга, да и то с задержкой в 3 мкс, чтобы дать паре «отправитель-получатель» доработать.

Суть: M:N - много горутин на мало потоков. Потоки на ядра раскладывает уже планировщик ОС, Go на это не влияет.

Цепочка такая: G → (P) → M → поток ОС → ядро. Горутина попадает в очередь P; M, владеющий этим P, снимает её и исполняет; поток ОС планируется ядром на физическое ядро по своим правилам. Go не управляет affinity и не может «прибить» горутину к ядру: ближайшее, что есть, это runtime.LockOSThread: она прибивает горутину к конкретному потоку (нужно для OpenGL, некоторых C-библиотек и Linux namespace).

Количественно

  • G: сколько запустил, столько и есть, упираешься только в память.
  • P: ровно GOMAXPROCS, фиксировано на время работы (в 1.25 может меняться при смене cgroup-лимита).
  • M: динамически. Рантайм создаёт новый M, когда текущий уходит в блокирующий syscall и P нужно отдать другому. Потолок 10 000.

Поэтому в проде число потоков работает как диагностика. GOMAXPROCS=8 и 12 потоков это норма. GOMAXPROCS=8 и 600 потоков значат, что где-то массово блокирующие syscall или cgo, и это стоит памяти (у каждого M ещё и свой системный стек) и переключений контекста.

// сколько потоков у процесса
// Linux:  cat /proc/<pid>/status | grep Threads
// изнутри программы:
import "runtime/pprof"
p := pprof.Lookup("threadcreate")   // сколько потоков вообще создавалось
fmt.Println(p.Count())              // 5 на пустой программе с GOMAXPROCS=14.
                                    // Число зависит от машины и от истории процесса,
                                    // а не от текущего числа живых потоков
Суть: на блокирующем syscall M застревает вместе с горутиной, но P у него отбирают и отдают другому потоку. Сетевой I/O под это вообще не попадает: дескрипторы неблокирующие, ожидание живёт в netpoller (epoll/kqueue/IOCP), горутина паркуется без потока.

Блокирующий syscall

  1. Перед вызовом рантайм зовёт entersyscall: сохраняет контекст горутины и переводит её в состояние _Gsyscall (до Go 1.26 ещё и P помечали как _Psyscall).
  2. Если syscall возвращается быстро, exitsyscall подхватывает тот же P: дешёвый быстрый путь.
  3. Если нет, sysmon через ~20 мкс видит, что горутина всё ещё в _Gsyscall, и делает handoffp: отбирает P и отдаёт другому M (взяв свободный или создав новый). Работа на этом ядре продолжается.
  4. Когда syscall наконец вернётся, «осиротевший» M попробует получить любой свободный P; если не выйдет, положит горутину в глобальную очередь и припаркуется в пуле потоков.

netpoller

Всё, что открыто через пакет net (и os.Pipe, и таймеры на части платформ), рантайм переводит в O_NONBLOCK и регистрирует в поллере. Вызов conn.Read внутри выглядит так: попытка read → EAGAIN → gopark горутины со ссылкой на её g в pollDesc. Горутина снята с потока, поток пошёл выполнять следующую.

Поллер дёргают в трёх местах: в findRunnable (неблокирующий netpoll), перед парковкой последнего M (блокирующий, иначе некому будет проснуться) и в sysmon, если сеть не опрашивали больше 10 мс. Готовые горутины переводят в _Grunnable и кладут в очередь.

На что ловят
  • Файловый I/O через netpoller не идёт (на Linux epoll для обычных файлов бесполезен). Под os.File.Read лежит честный блокирующий syscall.
  • cgo-вызов ведёт себя так же: занимает поток на всё время, вытеснить нельзя, и вдобавок стоит ~50–100 нс на переход туда-обратно.
  • Отсюда рецепт: файловые операции и cgo гоняем через ограниченный пул (семафор на буферизированном канале), а не «горутина на каждый файл».
Суть: до 1.14 вытеснение было только в safe-точках (вызов функции, аллокация, операция с каналом); с 1.14 sysmon шлёт потоку SIGURG и вытесняет горутину почти в любой точке. «Захватить» ядро сейчас практически нельзя, но исключения есть.

Кооперативное вытеснение

Планировщик записывает в g.stackguard0 специальное значение stackPreempt. Пролог следующей вызванной функции сравнивает SP с этим полем, «видит переполнение», уходит в morestack, а тот распознаёт флаг и передаёт управление планировщику. Механизм роста стека переиспользован тут красиво, но работает только там, где есть вызов функции.

// Go 1.13 и раньше: программа зависала намертво
func main() {
    runtime.GOMAXPROCS(1)
    go func() { for { } }()     // ни вызовов, ни аллокаций: вытеснить негде
    time.Sleep(time.Second)
    fmt.Println("недостижимо в 1.13")
}

// запуск на go1.27: строка печатается через секунду, асинхронное вытеснение работает.
// недостижимо в 1.13

Асинхронное вытеснение (Go 1.14)

Sysmon замечает горутину, работающую дольше 10 мс, и шлёт её потоку сигнал SIGURG. Обработчик проверяет, что точка «асинхронно безопасна» (в ней нет «спрятанных» указателей и незавершённой работы с ними, мы не в критической секции рантайма, не в //go:nosplit-функции), и подменяет адрес возврата на runtime.asyncPreempt: та сохраняет весь регистровый файл и уходит в планировщик. SIGURG выбрали потому, что он реально не используется и не мешает отладчикам.

Где всё ещё можно «захватить» процессор

  • Внутри cgo-вызова: сигнал не поможет, чужой код мы не контролируем.
  • В функциях рантайма с //go:nosplit и с отключёнными сигналами.
  • При GODEBUG=asyncpreemptoff=1: его иногда включают для отладки, потому что асинхронное вытеснение мешает воспроизводить некоторые баги.
  • Логически: горутина держит мьютекс и молотит цикл. Её вытеснят, но лок она не отдаст, и все ожидающие всё равно стоят. Вытеснение спасает планировщик, а не твой дизайн.
Зачем это вообще делали

Затевали это не ради справедливости, а ради GC. Сборщику нужно остановить все горутины на stop-the-world; если хоть одна не доходит до safe-точки, STW-пауза тянется до бесконечности. Именно поэтому асинхронное вытеснение приземлили вместе с работой над суб-миллисекундными паузами.

Суть: это число P, то есть предел параллельного исполнения Go-кода. До 1.25 дефолт - число ядер ноды, а не cgroup-лимит, отсюда throttling в Kubernetes. В Go 1.25 рантайм стал container-aware и ещё умеет обновлять значение на лету.

Что именно ограничивает

Не число потоков и не число горутин, а количество контекстов P. Одновременно исполняется Go-код максимум в GOMAXPROCS горутинах. Потоков при этом может быть сколько угодно: те, что сидят в блокирующих syscall, P не держат.

Проблема контейнеров

Под с limits.cpu: "1" на 64-ядерной ноде до Go 1.25 получал GOMAXPROCS=64, потому что runtime.NumCPU() читает маску affinity, а не cgroup-квоту. Дальше включается CFS throttling: cgroup выдаёт квоту на период 100 мс, 64 потока выжирают её за первые единицы миллисекунд, и остаток периода контейнер заморожен. Симптомы: рваные p99, «непонятные» лаги, метрика container_cpu_cfs_throttled_seconds_total ползёт вверх. GC тоже страдает: он масштабируется по GOMAXPROCS и создаёт лишних воркеров.

Что делать

// До Go 1.25 стандарт де-факто:
import _ "go.uber.org/automaxprocs"    // при старте читает cgroup v1/v2 и ставит GOMAXPROCS

// В Go 1.25 (нужно go >= 1.25 в go.mod) это в рантайме:
//   default = min(runtime.NumCPU(), ceil(cgroup CPU limit)), минимум 2
//   значение обновляется само при смене лимита (опрос ~раз в секунду)
//   GOMAXPROCS(0) больше не «замораживает» значение, а просто читает
//   выключить: GODEBUG=containermaxprocs=0,updatemaxprocs=0
//   переопределить: переменная окружения GOMAXPROCS=N или runtime.GOMAXPROCS(n)
Практика
  • Лимит в 1 CPU для Go-сервиса плох сам по себе: рантайму нужны ядра под GC и sysmon. Реалистичный минимум это 2.
  • Не выставляй GOMAXPROCS руками «для скорости»: на CPU-bound это почти всегда деградация.
  • Потоки ограничивает debug.SetMaxThreads (дефолт 10 000), а не GOMAXPROCS. Упёрлись, и процесс падает с fatal error: thread exhaustion.
Суть: технически нормально - это примерно 2–4 ГБ памяти и работающая программа. Плохо становится, когда каждая горутина держит ресурс (соединение, буфер, файловый дескриптор) или когда они дерутся за один лок.

Считаем стоимость

  • Стек 2 КБ минимум, но после первого же вызова с локальными массивами это 4–8 КБ. Миллион → 2–8 ГБ.
  • Структура g весит сотни байт, плюс место в очередях.
  • Работа GC: каждый стек это корень для сканирования. Миллион корней ощутимо удлиняет фазу сканирования и STW-моменты.
  • Планировщик: миллион элементов в очередях, стилинг чаще, локальность хуже.
// Это отработает, но посмотри на RSS процесса
var wg sync.WaitGroup
for i := 0; i < 1_000_000; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); time.Sleep(time.Second) }()
}
fmt.Println(runtime.NumGoroutine())   // 1000001, а не 1000000:
                                      // NumGoroutine считает и саму main-горутину
wg.Wait()

Когда ок

  • Горутина «лёгкая»: держит только сокет и маленький буфер (классика: сервер на сотни тысяч WebSocket-соединений).
  • Работа I/O-bound, горутины почти всегда спят в netpoller.
  • Нет общего узкого места: общего мьютекса, общей мапы, одного канала-бутылочного горлышка.

Когда нет

  • Каждая горутина открывает соединение к БД: пул на 20 коннектов, миллион ожидающих это очередь, а не параллелизм. Ограничивать надо на входе.
  • Каждая держит буфер на 32 КБ, вот и 32 ГБ.
  • CPU-bound задачи: больше GOMAXPROCS считающих горутин пользы не даёт, только оверхед на переключения.
  • Горутина на каждый элемент коллекции, типичный антипаттерн: дешевле пул из GOMAXPROCS воркеров.
Как отвечать

«Горутины дёшевы, но не бесплатны, и главное, узкое место почти никогда не в них. Ограничивать нужно то, за что они борются: коннекты к БД, память под буферы, RPS к стороннему API. Отсюда worker pool и семафор вместо неограниченного go в цикле.»

Суть: нельзя. В Go нет kill, Thread.stop и аналогов - горутина завершается только сама, вернувшись из своей функции. Остановка всегда кооперативная: сигнал через ctx.Done() или done-канал.

Почему так спроектировано

Принудительно убивать поток давно считается ошибкой дизайна (в Java Thread.stop deprecated с 1.2). Убитая горутина оставила бы за собой захваченные мьютексы, недописанные структуры данных, незакрытые файлы и невыполненные defer. Go выбрал единственный безопасный вариант: горутина сама решает, где ей корректно закончиться.

Правильный шаблон

func worker(ctx context.Context, in <-chan Job, out chan<- Result) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()                 // выходим явно
        case job, ok := <-in:
            if !ok {
                return nil                   // источник закрыт, штатно завершаемся
            }
            res, err := handle(ctx, job)     // ctx прокидываем внутрь, иначе отмена не дойдёт
            if err != nil {
                return err
            }
            select {                          // отправка тоже должна отменяться
            case out <- res:
            case <-ctx.Done():
                return ctx.Err()
            }
        }
    }
}

Три частые ошибки

  • Отмену проверяем, а отправку нет. Классическая утечка: горутина увидела ctx.Done(), но зависла на out <- res, потому что читателя уже нет. Каждая блокирующая операция должна быть в select с ctx.Done().
  • Долгая работа без точек проверки. Если внутри цикл на минуту, проверяй ctx.Err() внутри цикла, а не только снаружи.
  • ctx не прокинут в вызываемый код. Отдавай ctx в db.QueryContext и в http.NewRequestWithContext, иначе отмена «застрянет» на первом же блокирующем вызове.
Что нельзя остановить вообще

Горутину, застрявшую в syscall без таймаута, в cgo-вызове или в time.Sleep(time.Hour). Отмена контекста её не разбудит. Поэтому: Sleep заменяем на select с time.After или ctx.Done(), сетевым операциям ставим SetDeadline, а на cgo-вызовы вешаем внешний таймаут по процессу.

Суть: Gosched() - добровольно уступить процессор (горутина остаётся runnable и уходит в глобальную очередь); NumGoroutine() - текущее число живых горутин, главная дешёвая метрика для поиска утечек.

runtime.Gosched()

Вызывает mcall(gosched_m): горутину переводят из _Grunning в _Grunnable, кладут в глобальную очередь (важно: не в локальную, чтобы дать шанс другим P), и M идёт выбирать следующую. Горутина не блокируется и не спит, она просто пропускает ход.

После Go 1.14 нужда в нём почти отпала: асинхронное вытеснение справляется само. Остались редкие случаи: бенчмарки и тесты, где нужно спровоцировать переключение; очень длинные вычислительные циклы, где хочется отдать управление раньше 10 мс; спин-ожидание в низкоуровневом коде.

// Gosched не годится для синхронизации: он не барьер и не даёт happens-before
var ready bool
go func() { data = compute(); ready = true }()
for !ready { runtime.Gosched() }   // гонка: компилятор вправе вынести !ready из цикла
// правильно: канал, sync.WaitGroup или atomic.Bool

runtime.NumGoroutine()

Возвращает gcount(): число горутин в состояниях, отличных от «мертва» и «системная». Стоит это несколько атомарных чтений, так что вешать на /metrics можно спокойно, хоть каждую секунду.

// Эта метрика нужна любому Go-сервису: по ней ловят утечку горутин
goroutines := prometheus.NewGaugeFunc(
    prometheus.GaugeOpts{Name: "go_goroutines_current"},
    func() float64 { return float64(runtime.NumGoroutine()) },
)
// Растущий без насыщения график = утечка. Дальше:
//   curl localhost:6060/debug/pprof/goroutine?debug=2
// и смотрим, на какой строке они все стоят.
Соседи по пакету, которые тоже спросят
  • runtime.NumCPU(): логические ядра, доступные процессу (маска affinity).
  • runtime.GOMAXPROCS(n): установить или прочитать число P.
  • runtime.Gosched(): уступить ход.
  • runtime.Goexit(): завершить текущую горутину, выполнив все её defer. Вызовешь в main: остальные горутины доработают, а потом процесс упадёт с fatal error: no goroutines ... - deadlock, и recover это не ловит.
  • runtime.LockOSThread(): прибить горутину к потоку (OpenGL, namespaces).
  • runtime.Stack(buf, all): снять дамп стеков всех горутин.
Суть: четыре рабочих способа - sync.WaitGroup, канал-сигнал, errgroup и (для одного значения) канал результата. time.Sleep - не способ.

1. sync.WaitGroup - когда горутин много и результат не нужен

var wg sync.WaitGroup
for _, u := range urls {
    wg.Add(1)                          // Add до go, не внутри горутины
    go func() {                        // Go 1.22+: u уже своя на каждой итерации
        defer wg.Done()                // Done в defer сработает и при панике
        fetch(u)
    }()
}
wg.Wait()

// В Go 1.25 то же короче, и Add/Done уже не забудешь:
var wg sync.WaitGroup
for _, u := range urls {
    wg.Go(func() { fetch(u) })
}
wg.Wait()

2. Канал-сигнал - когда горутина одна

done := make(chan struct{})
go func() { defer close(done); work() }()
<-done                                 // ждём

// с таймаутом:
select {
case <-done:
case <-time.After(5 * time.Second):
    return errors.New("timeout")       // горутина продолжит работать, см. предупреждение ниже
}

3. errgroup - когда нужны ошибки и отмена

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)                          // не больше 8 одновременно
for _, u := range urls {
    g.Go(func() error { return fetch(ctx, u) })
}
if err := g.Wait(); err != nil {       // вернёт первую ошибку и отменит ctx для остальных
    return err
}

4. Канал результата - когда нужно одно значение

res := make(chan Result, 1)            // буфер 1, чтобы отправитель не завис, если мы ушли
go func() { res <- compute() }()
r := <-res
Два подвоха
  • Таймаут на ожидании не останавливает горутину. Ты перестал ждать, а она продолжает работать и жрать ресурсы. Отменяет по-настоящему только context, прокинутый внутрь.
  • time.Sleep вместо синхронизации остаётся самым частым красным флагом на собесе. Это не гарантия, а угадывание; на загруженной машине или под -race тайминги плывут, и тест начинает мигать.

Что стоит запомнить

  • G описывает goroutine, M поток ОС, P право исполнять Go-код.
  • Блокирующий syscall может отдать P другой M.
  • GOMAXPROCS ограничивает параллельное исполнение Go-кода.

Частые ошибки

  • Планировщик не обещает порядок исполнения.
  • Большое число goroutine всё равно потребляет память и ресурсы.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи выполнение

Одна функция записывает число в небуферизированный канал, другая читает его. Объясни, почему отправитель не проходит send до получения.

func f(ch chan int) { ch <- 7; fmt.Println("sent") }
func main() { ch := make(chan int); go f(ch); fmt.Println(<-ch) }
Ответ и разбор

main получает 7, после rendezvous обе goroutine продолжают выполнение. Порядок печати 7 и sent не гарантирован, а main может завершиться до печати sent. Чтобы гарантировать обе строки, синхронизируй завершение sender через WaitGroup или второй канал; порядок вывода всё равно задай явно, если он важен.

Код-ревью

2. Найди проблему и исправь

Автор утверждает: «GOMAXPROCS(1) запускает только одну goroutine». Исправь комментарий, не меняя код.

runtime.GOMAXPROCS(1) // автор считает, что запущена только одна goroutine
Ответ и разбор

Это утверждение неверно: GOMAXPROCS(1) не ограничивает число созданных или запущенных goroutine. Он задаёт максимум в одно P, на котором одновременно исполняется Go-код; scheduler может переключать много goroutine на этом P. Чтобы ограничить число задач или запросов in-flight, нужен worker pool или semaphore.

Применение

3. Разбери рабочий сценарий

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

Ответ и разбор

Создай очередь задач и ровно 20 worker goroutine, каждая делает вызов и отправляет результат в канал с ограниченной буферизацией. Закрой очередь после последней отправки, дождись worker через WaitGroup, затем закрой results. Передавай context и прекращай получение/вызов при отмене. GOMAXPROCS ограничивает параллелизм Go-кода, но не заменяет лимит внешних запросов.

Применение

4. Разбери граничный сценарий

Поток выполнения ждёт в blocking syscall. Что происходит с P и как это отличается от лимита числа сетевых запросов?

Ответ и разбор

Runtime может освободить P от заблокированного M, чтобы другой M продолжил исполнять Go-код. Это помогает планировщику использовать доступные P, но не ограничивает число внешних запросов; для этого нужен прикладной semaphore или worker pool.