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 |
| Сколько потянет машина | тысячи | тысячи–десятки тысяч | сотни тысяч–миллионы |
| Есть ли id | PID | TID | есть 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 именно ради локальных очередей: до этого была одна глобальная очередь
под одним мьютексом, и планировщик не масштабировался.
Как планировщик выбирает следующую горутину
Цикл schedule() вызывает findRunnable(), и порядок поиска там строго
определён. На собесе его просят назвать по шагам:
- Каждый 61-й тик планировщика: заглянуть в глобальную очередь и взять оттуда одну горутину. Магическое число нужно, чтобы эта очередь не голодала, пока локальная бесконечно пополняет сама себя.
runnextтекущего P, слот на одну горутину. Туда попадает та, которую только что породилgoили разбудил канал: расчёт на локальность кэша, «мы только что её создали, данные горячие».- Локальная очередь P (
runqget), lock-free кольцевой буфер на 256 элементов. - Глобальная очередь: берём сразу пачку (
len/GOMAXPROCS + 1), чтобы не ходить под глобальный лок на каждую горутину. - netpoll в неблокирующем режиме: забрать горутины, чьи сокеты стали готовы (подробности ниже, в разделе про блокировки).
- Work stealing: до четырёх попыток украсть у случайно выбранного P
половину его локальной очереди. На последней попытке разрешено красть и
runnext, и таймеры. - Не нашлось ничего: M отдаёт P в список простаивающих и паркуется (
stopm), пока его кто-нибудь не разбудит.
Отдельный M, который крутится вне модели GMP (ему P не нужен) и спит от 20 мкс до 10 мс. Он делает четыре вещи: retake отбирает P у потока, застрявшего в syscall дольше 20 мкс, и помечает на вытеснение горутину, которая крутится дольше 10 мс; netpoll, если сеть никто не опрашивал больше 10 мс; forcegc, если GC не запускался 2 минуты; и будит scavenger. Без sysmon Go-программа с одной бесконечно считающей горутиной вешала бы GC.
Блокировки: syscall, netpoller, gopark
Спрашивают это так: «горутина заблокировалась, что с потоком?». Ответ зависит от вида блокировки, и вариантов ровно три.
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.
Обычные файлы на 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 стеки копирующие.
g.stackguard0;
если места не хватает, уходит в morestack и copystack. Тот же механизм
приспособили под кооперативное вытеснение: планировщик просто пишет в
stackguard0 значение stackPreempt.Вопросы
12runtime.g в куче плюс маленький
растущий стек; её планирует рантайм Go в user space и мультиплексирует на потоки ОС в модели M:N.У процесса своё изолированное адресное пространство и набор ресурсов ядра. Внутри процесса планируются потоки: у каждого свой стек и регистры, а память общая; переключение между потоками стоит входа в ядро и порядка микросекунды. Горутину планирует сам рантайм, и ядро о ней не знает вообще.
Три источника дешевизны
- Стек. Поток резервирует фиксированный (1–8 МБ виртуально, страницы подтягиваются по обращению). Горутина стартует от 2 КБ реальной памяти и растёт копированием. Миллион потоков занимает терабайты виртуального адресного пространства, а миллион горутин обходится несколькими гигабайтами реальной памяти.
- Переключение. Сменить горутину значит сохранить SP, PC и указатель на
gвgobufи загрузить другие. Не нужно менять привилегии, не нужно сбрасывать TLB, кэши остаются тёплыми. ~50–200 нс против 1–2 мкс. - Кто планирует. Планировщик Go знает семантику: он видит, что горутина встала на канале, и переключает мгновенно, а не ждёт истечения кванта. Ядро такого не знает.
Чем горутина хуже потока
- Нет приоритетов, нет affinity к ядру, нет real-time гарантий.
- Нельзя убить извне (см. отдельный вопрос).
- Настоящий блокирующий syscall всё равно займёт целый поток ОС.
- Нет изоляции: паника в любой горутине без
recoverроняет весь процесс.
У горутины есть числовой goid, его видно в панике и в
runtime.Stack. Но в публичном API его нет намеренно: команда Go боялась, что
появятся goroutine-local storage и «привязка контекста к id», как с ThreadLocal в Java,
а это ломает всю модель явной передачи контекста. Достают его грязным парсингом
runtime.Stack, но на ревью такое не проходит.
Механика
Компилятор вставляет в пролог почти каждой функции сравнение указателя стека с
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 это | горутины, каналы, select | GOMAXPROCS и число ядер |
Цитата Роба Пайка «Concurrency is not parallelism» сама по себе ничего не показывает, её называют все. Показывает продолжение: «конкурентность про то, как разложить задачу на независимо продвигающиеся части; параллелизм про то, как эти части исполнить. Go даёт первое как языковую конструкцию, второе как настройку рантайма.»
GOMAXPROCS.Зачем понадобился P
В Go 1.0 модель была GM: одна глобальная очередь под одним мьютексом. На многоядерных
машинах это упиралось в contention, а ещё убивало локальность: горутина могла уехать на
любой поток, и кэш процессора обнулялся. В Go 1.1 Дмитрий Вьюков добавил P: теперь у каждого
P своя очередь на 256 слотов (lock-free кольцевой буфер) и свой mcache, так что
обычные аллокации и планирование идут без единого атомарного конфликта.
Порядок поиска работы (findRunnable)
- раз в 61 тик: одна горутина из глобальной очереди (чтобы та не голодала);
runnext: слот на одну «только что созданную/разбуженную» горутину, ради локальности кэша;- локальная очередь P;
- глобальная очередь: пачкой
len/GOMAXPROCS + 1; - неблокирующий
netpoll; - work stealing: до 4 попыток украсть половину очереди у случайного P;
- ничего нет, и тогда P в idle-список, M паркуется.
Где рождается горутина
go f() кладёт новую G в runnext текущего P, а прежнюю горутину из этого слота сдвигает в локальную очередь. Если та переполнена (256 штук), половина её вместе с выбитой из runnext горутиной уезжает в глобальную очередь одним батчем. Это и есть «перелив»: одному P не дают накопить всё.
runnext ломает FIFO: последняя созданная горутина запускается первой.
Размен осознанный: локальность кэша против справедливости. Горутина из runnext «наследует» квант: донашивает те же 10 мс, что достались текущей, и пара, которая перебрасывается сообщениями, не заморит остальную очередь. А если следующий go займёт слот раньше, прежняя горутина уедет в хвост локальной очереди.
И красть runnext у соседа разрешено только на последней попытке стилинга,
да и то с задержкой в 3 мкс, чтобы дать паре «отправитель-получатель» доработать.
Цепочка такая: 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
- Перед вызовом рантайм зовёт
entersyscall: сохраняет контекст горутины и переводит её в состояние_Gsyscall(до Go 1.26 ещё и P помечали как_Psyscall). - Если syscall возвращается быстро,
exitsyscallподхватывает тот же P: дешёвый быстрый путь. - Если нет, sysmon через ~20 мкс видит, что горутина всё ещё в
_Gsyscall, и делаетhandoffp: отбирает P и отдаёт другому M (взяв свободный или создав новый). Работа на этом ядре продолжается. - Когда 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 гоняем через ограниченный пул (семафор на буферизированном канале), а не «горутина на каждый файл».
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-код максимум в 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–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 в цикле.»
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.