Перейти к содержимому

Runtime и планировщик

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

Модель, которую нужно держать в голове, — GMP. G — горутина: структура с состоянием, указателем на растущий стек (стартует с 8 КБ, растёт копированием через morestack) и сохранёнными регистрами. M — machine, реальный поток ОС, созданный через clone/pthread_create. P — processor, логический процессор: контекст-разрешение исполнять Go-код, владелец локальной очереди готовых горутин и локальных кэшей аллокатора (mcache). Число P фиксировано и равно GOMAXPROCS. Чтобы выполнить Go-код, поток M обязан держать P; отсюда ключевое следствие: одновременно Go-код исполняют не более GOMAXPROCS потоков, но самих потоков в процессе может быть в разы больше — те, кто сидит в блокирующем системном вызове или в C-коде через cgo, P не держат.

Очередей готовых горутин несколько: у каждого P есть слот runnext (одна горутина, «выполнить следующей» — оптимизация для только что разбуженного собеседника по каналу), локальная кольцевая очередь на 256 элементов и одна глобальная очередь на весь процесс под sched.lock. Планировщик в цикле schedule() берёт работу в порядке: каждый 61-й тик — из глобальной (чтобы она не голодала), затем runnext, затем локальная, затем пачка из глобальной, затем netpoll, затем воровство половины очереди у случайного другого P (work stealing). Если работы нет нигде — P уходит в idle-список, а M паркуется на футексе, а не крутит CPU.

Вытеснение в Go гибридное. Исторически (до 1.14) оно было чисто кооперативным: горутина отдавала управление только в «точках безопасности» — при вызове функции с проверкой стека, на канальных операциях, аллокациях, системных вызовах. Цикл for {} без вызовов функций мог намертво заблокировать P и повесить stop-the-world сборки мусора. С Go 1.14 добавлено асинхронное вытеснение: фоновый поток sysmon (он работает без P) замечает горутину, крутящуюся дольше 10 мс, и посылает потоку сигнал SIGURG; обработчик сигнала «доклеивает» вызов asyncPreempt, и горутина отправляется в глобальную очередь. Тот же sysmon отбирает P у потоков, застрявших в системном вызове дольше ~20 мкс, раз в 10 мс дёргает netpoll, и раз в 2 минуты форсирует GC.

Последний столп — асинхронный ввод-вывод. Все сетевые дескрипторы рантайм переводит в неблокирующий режим и регистрирует в epoll (Linux) / kqueue (BSD, macOS) / IOCP (Windows). «Блокирующий» conn.Read на самом деле паркует горутину, освобождая поток, а разбудит её netpoller, когда придут данные. Именно поэтому сервер на Go держит сотни тысяч соединений на десятке потоков. Исключение — обычные файлы на диске и большинство «настоящих» syscall’ов: там поток реально блокируется, и рантайм компенсирует это созданием нового M.

за какое время выполнятся все запросы при GOMAXPROCS=1?

Заголовок раздела «за какое время выполнятся все запросы при GOMAXPROCS=1?»

Коротко. Вопрос всегда идёт продолжением задачи с кодом, и правильный ответ зависит от природы работы: если запросы упираются в сеть/диск (I/O-bound), при GOMAXPROCS=1 они всё равно выполняются конкурентно и общее время ≈ времени самого долгого запроса, а не сумме; если запросы CPU-bound, параллелизма нет и время ≈ сумме всех.

Глубже. GOMAXPROCS=1 убирает параллелизм, но не конкурентность. Горутина, ждущая ответа от сервера, паркуется в netpoller и освобождает единственный P, так что 100 HTTP-запросов по 100 мс каждый на одном P завершатся примерно за 100 мс плюс накладные расходы на планирование, а не за 10 секунд. На собеседовании стоит проговорить вслух три уточнения: чем заняты горутины (ожидание или счёт), есть ли между ними синхронизация (sync.WaitGroup, общий мьютекс, небуферизованный канал сериализуют работу и делают GOMAXPROCS неважным) и есть ли внешний лимит (пул соединений, rate limiter на стороне сервера). Отдельная ловушка вопроса: при GOMAXPROCS=1 порядок выполнения всё равно недетерминирован — это не «однопоточный последовательный режим».

// I/O-bound: при GOMAXPROCS=1 время ≈ 100 мс, а не 10 с
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(100 * time.Millisecond) // имитация сетевого запроса
}()
}
wg.Wait()

Коротко. Netpoller — встроенная в рантайм подсистема асинхронного ввода-вывода: она держит все сетевые дескрипторы в неблокирующем режиме и регистрирует их в epoll/kqueue/IOCP, а горутину, которая «блокируется» на Read/Write/Accept, паркует и будит только тогда, когда дескриптор готов. Благодаря этому блокирующий по виду сетевой код не занимает поток ОС.

Глубже. Реализация лежит в runtime/netpoll.go плюс платформенные файлы: netpoll_epoll.go (Linux), netpoll_kqueue.go (BSD/macOS), netpoll_windows.go (IOCP), netpoll_solaris.go (event ports). Когда net.Conn создаётся, файловый дескриптор регистрируется через internal/poll.FD; при попытке чтения без данных вызывается runtime_pollWaitgopark, горутина уходит в состояние waiting, M берёт следующую работу. Функция netpoll() дёргается из findRunnable() (перед тем как воровать работу), из sysmon (если netpoll не опрашивали больше 10 мс) и при простое P; она возвращает список готовых горутин, которые ставятся в очередь через injectglist. Важные оговорки для собеседования: netpoller работает с тем, что умеет epoll — сокеты, пайпы, tty, os/exec; обычные файлы на диске он не покрывает, поэтому файловый I/O — это реальный блокирующий syscall, съедающий поток. Таймеры (time.Sleep, time.After) с Go 1.14+ живут в отдельных кучах таймеров у каждого P и учитываются как дедлайн для epoll_wait, поэтому спящая горутина тоже не держит поток.

Что такое кооперативная многозадачность?

Заголовок раздела «Что такое кооперативная многозадачность?»

Коротко. Кооперативная многозадачность — модель, в которой задача сама отдаёт управление планировщику в специальных точках, а планировщик не может отобрать процессор принудительно; противоположность — вытесняющая (preemptive) многозадачность, где вытеснение делается по таймеру/сигналу независимо от желания задачи.

Глубже. Планировщик Go до версии 1.14 был именно кооперативным: точками переключения были вызов функции (в прологе проверяется stackguard0, куда планировщик записывает магическое stackPreempt), канальные операции, select, системные вызовы, аллокации, runtime.Gosched(). Отсюда классический баг: for { i++ } без вызовов функций не имел ни одной точки безопасности, поэтому мог держать P вечно и подвесить stop-the-world фазу GC до полного зависания программы. С Go 1.14 добавлено асинхронное вытеснение (SIGURG + asyncPreempt), так что сегодня Go — гибрид: в основном кооперативный, с вытеснением по таймеру через sysmon для «плохих» горутин. Полностью вытесняющим он не стал: асинхронная точка вытеснения требует, чтобы регистры были в состоянии, понятном GC, — в некоторых небезопасных для этого участках (//go:nosplit, критические секции рантайма) вытеснение по-прежнему откладывается.

Коротко. Рантайм сам создаёт и переиспользует потоки ОС (M): столько, сколько нужно, чтобы всегда было GOMAXPROCS потоков, реально исполняющих Go-код. Потоки создаются, когда есть работа и свободный P, но нет свободного M (в частности, когда M ушёл в блокирующий syscall или cgo), простаивающие потоки паркуются на футексе и лежат в списке sched.midle до следующей надобности.

Глубже. Ключевые механизмы: startm/newm — взять из idle-списка или создать новый поток через clone(2); stopm — припарковать M на note (futex) до появления работы; handoffp — передать P другому M, когда текущий надолго ушёл в syscall; «спиннинг»-потоки — ограниченное число M, которые перед парковкой некоторое время активно ищут работу (воруют у других P, дёргают netpoll), чтобы не платить каждый раз за пробуждение через futex. Отдельно живёт sysmon — поток без P, который никогда не паркуется надолго и выполняет надзорные функции. Потоки почти никогда не уничтожаются: структура m не освобождается, поток обычно переиспользуется (исключения — потоки, залоченные через runtime.LockOSThread, и завершение по mexit). Верхний предел числа потоков — debug.SetMaxThreads, по умолчанию 10000; при превышении рантайм падает с fatal error: thread exhaustion.

Как значение переменной GOMAXPROCS соотносится с архитектурой процессора? По умолчанию она равна количеству физических или виртуальных ядер?

Заголовок раздела «Как значение переменной GOMAXPROCS соотносится с архитектурой процессора? По умолчанию она равна количеству физических или виртуальных ядер?»

Коротко. По умолчанию GOMAXPROCS равна runtime.NumCPU() — числу логических (виртуальных) процессоров, доступных процессу, то есть с учётом Hyper-Threading/SMT: на 4 физических ядрах с HT это 8. Физические ядра рантайм не различает вообще.

Глубже. На Linux NumCPU() берётся не из «сколько CPU в системе», а из маски привязки процесса — sched_getaffinity(2), поэтому taskset -c 0,1 ./app даст GOMAXPROCS=2. А вот CPU-квота cgroup (cpu.max, лимиты Kubernetes) исторически не учитывалась: в контейнере с лимитом 500m на 64-ядерной ноде программа до Go 1.24 включительно поднимала 64 P, что приводило к троттлингу, лишним переключениям контекста и раздутому потреблению памяти под mcache/стеки — отсюда практика ставить GOMAXPROCS руками или через uber-go/automaxprocs. В Go 1.25 значение по умолчанию сделали контейнерно-осведомлённым: рантайм учитывает лимит CPU из cgroup и умеет обновлять значение на лету, а поведение регулируется через GODEBUG=containermaxprocs/updatemaxprocs. Практический вывод для собеседования: значение по умолчанию — это «сколько логических CPU видит процесс», и в контейнерах на старых версиях Go его надо выставлять явно.

Коротко. Вопрос про личный опыт — отвечайте конкретным кейсом: где применяли reflect, почему это нельзя было решить дженериками или кодогенерацией, и чем заплатили. Хороший каркас: задача → что именно делали через reflect → альтернативы → цена (производительность, потеря типобезопасности, паники в рантайме).

Глубже. Интервьюер хочет услышать, что вы понимаете границы применения. Типичные честные кейсы: разбор struct-тегов при собственной сериализации или валидации (validate:"required"), маппинг строк БД в структуры, DI-контейнер, сравнение произвольных значений в тестах, обход конфигурационной структуры для заполнения из env. Технический минимум, который нужно проговорить: reflect.TypeOf/reflect.ValueOf, три «закона рефлексии» (из интерфейса в reflect-объект, обратно, и «чтобы менять значение, Value должен быть settable» — то есть получен через указатель и экспортирован), Kind против Type, паники вместо ошибок компиляции, стоимость — аллокации при боксинге в interface{} и отсутствие инлайна. Обязательно скажите, что с Go 1.18+ значительная часть задач, где раньше брали рефлексию (типизированные контейнеры, Map/Filter, ключи мапы), решается дженериками, а горячий путь лучше закрывать кодогенерацией (easyjson, sqlc, stringer) или кэшировать результат разбора типа один раз в sync.Map по reflect.Type. Плохой ответ — «нет, не работал» без продолжения, и «использую рефлексию везде, это удобно».

Коротко. Зелёный поток (green thread) — это поток пользовательского пространства, которым управляет рантайм языка, а не ядро ОС; системным потоком он не является. Горутина — как раз такой зелёный поток: она мультиплексируется на настоящие потоки ОС по схеме M:N.

Глубже. Отличия по пунктам: создание и переключение зелёного потока не требует системного вызова и смены привилегий (переключение горутины — это сохранение нескольких регистров и PC/SP, единицы наносекунд против ~1–2 мкс на context switch ядра); стек горутины стартует с 8 КБ и растёт копированием, тогда как поток ОС резервирует фиксированные 1–8 МБ виртуальной памяти; ядро о горутинах ничего не знает и планирует только M. Обратная сторона: если зелёный поток делает настоящий блокирующий syscall, он блокирует несущий поток ОС — в Go это компенсируется netpoller’ом для сети и передачей P другому потоку (handoffp) для остальных случаев. Стоит упомянуть, что бывают и другие модели: 1:1 (Java-потоки до Loom, std::thread в Rust) и M:N (Go, Erlang-процессы, Java virtual threads с Loom).

Will each application have its own iteration (of the Go runtime)? Or will there be a common iteration for all applications?

Заголовок раздела «Will each application have its own iteration (of the Go runtime)? Or will there be a common iteration for all applications?»

Коротко. У каждого приложения свой экземпляр рантайма: он статически влинкован в бинарник, живёт внутри процесса и ни с кем не разделяется. Общего рантайма/виртуальной машины/демона на все Go-программы, как JVM или CLR в некоторых конфигурациях, не существует.

Глубже. Практические следствия: у каждого процесса своя куча, свой GC со своим GOGC/GOMEMLIMIT, свой набор P и своё значение GOMAXPROCS, свой sysmon, свои счётчики runtime.MemStats. Обновление версии Go требует пересборки каждого бинарника — «обновить рантайм на машине» нельзя. Размер минимального бинарника (порядка 1–2 МБ) — это в основном рантайм и метаданные типов; статическая линковка рантайма и есть причина, почему Go-бинарник запускается мгновенно и не нуждается в установленной среде исполнения. Два Go-процесса на одной машине конкурируют только за ресурсы ядра (CPU, память), но не координируют свои планировщики — типичная ошибка в контейнерах: десять процессов, каждый с GOMAXPROCS=64, суммарно создают на порядок больше потоков, чем есть ядер.

Можно ли на Go писать драйверы для ядра Linux? Почему да или нет?

Заголовок раздела «Можно ли на Go писать драйверы для ядра Linux? Почему да или нет?»

Коротко. Модули ядра Linux на Go писать нельзя. Ядро — это freestanding-среда без libc, без пользовательских потоков, без mmap для рантайма и с собственными правилами работы со стеком и прерываниями, а Go-программе обязательно нужен свой рантайм: сборщик мусора, планировщик, растущие стеки, обработчики сигналов. Ядро поддерживает C и (с 6.1) Rust — языки без GC и без обязательного рантайма.

Глубже. Разберём по причинам. (1) GC: сборщик требует stop-the-world фаз, барьеров записи и собственных потоков — в контексте прерывания или спинлока это неприемлемо, а детерминированную задержку в драйвере GC ломает. (2) Планировщик: горутины опираются на потоки ОС, futex, сигналы (SIGURG для вытеснения) — в ядре этих абстракций в нужном виде нет. (3) Стеки: Go растит стек копированием через morestack, ядро же даёт задаче фиксированный стек (обычно 8–16 КБ) и никакого перемещения не допускает. (4) ABI и аллокатор: рантайм хочет mmap больших областей виртуальной памяти и arena-аллокатор, ядро — kmalloc/vmalloc и свои правила. (5) Рантайм Go предполагает наличие ОС под собой — порт GOOS=linux работает поверх системных вызовов, которых в ядре нет.

Что при этом на Go делать можно и нужно: драйверы и сетевые стеки в пространстве пользователя поверх UIO/VFIO (DPDK-подобный подход), файловые системы через FUSE, сетевые устройства через TUN/TAP, работа с железом через /dev/mem, /sys, i2c-dev, spidev, gpiod; загрузка и управление eBPF-программами (cilium/ebpf, libbpfgo) — при этом сама eBPF-программа пишется на C/Rust и компилируется в байткод eBPF, а на Go остаётся userspace-часть. Для голого железа без ОС существует TinyGo (свой минимальный рантайм, GC-стратегии вроде leaking/conservative), но это не про модули ядра Linux. Хороший ответ на собеседовании обязательно включает «а вот так делают вместо этого».

Каким образом получается, что горутины выстраиваются в одну очередь выполнения?

Заголовок раздела «Каким образом получается, что горутины выстраиваются в одну очередь выполнения?»

Коротко. Строго говоря, очередь не одна: у каждого P есть слот runnext и локальная кольцевая очередь на 256 горутин, плюс на весь процесс одна глобальная очередь под общим мьютексом. Именно многоуровневость очередей позволяет большинству операций постановки/снятия горутины идти вообще без блокировок.

Глубже. Новая горутина из go f() попадает в runnext текущего P (вытесняя оттуда предыдущего кандидата в конец локальной очереди) — это делает пару «создал и сразу ждёшь результат» дешёвой по кэшу. Если локальная очередь переполнена, runqput сбрасывает половину её содержимого (128 элементов) вместе с новой горутиной в глобальную очередь одним батчем. Локальная очередь — lock-free кольцо с атомарными head/tail: владелец P кладёт и берёт с одного конца без атомарных CAS в общем случае, а воры (другие P) забирают с другого конца через CAS. Глобальная очередь — обычный связный список gQueue под sched.lock. Горутины, разбуженные netpoller’ом или таймерами, вкидываются через injectglist. Так что фраза «одна очередь выполнения» — упрощение: на собеседовании стоит нарисовать три уровня (runnext → локальная → глобальная) и объяснить, зачем такая иерархия: локальность кэша, отсутствие contention на общем локе и справедливость за счёт периодического обращения к глобальной очереди.

Коротко. Количество потоков ОС в Go-процессе не равно GOMAXPROCS. GOMAXPROCS ограничивает только число потоков, одновременно исполняющих Go-код; сверх этого рантайм создаёт потоки под каждый заблокированный системный вызов, под cgo-вызовы, под LockOSThread, плюс служебные (sysmon, обработчик сигналов, при необходимости — потоки, созданные C-библиотеками).

Глубже. Основные драйверы роста числа потоков: (1) блокирующие syscall’ы, которые не покрывает netpoller — файловый I/O, getaddrinfo через cgo-resolver, fsync, вызовы вроде os.Stat на медленной FS; каждый застрявший вызов через ~20 мкс приводит к handoffp и созданию/пробуждению нового M; (2) cgo — на время вызова C-кода поток отдаёт P, а сам занят; (3) runtime.LockOSThread (нужен для OpenGL, некоторых syscall’ов вроде setns, unshare) — такой поток не переиспользуется под другие горутины и уничтожается при выходе горутины, если UnlockOSThread не вызван. Потолок задаётся debug.SetMaxThreads (по умолчанию 10000) — превышение это fatal error: thread exhaustion, и обычно оно означает утечку на блокирующих вызовах. Наблюдать реальное число можно через runtime.NumGoroutine() (это горутины) и pprof.Lookup("threadcreate") или /proc/<pid>/status (Threads:). Важная деталь: рантайм потоки не сокращает при спаде нагрузки — они остаются припаркованными в idle-списке, поэтому «пик потоков» после всплеска файлового I/O останется в метриках.

Есть процессор, у него 4 ядра и 8 потоков. Представим, что все 8 потоков доступны для нашей программы. Создаем 100 горутин и в каждой из них бесконечный for. Что будет?

Заголовок раздела «Есть процессор, у него 4 ядра и 8 потоков. Представим, что все 8 потоков доступны для нашей программы. Создаем 100 горутин и в каждой из них бесконечный for. Что будет?»

Коротко. GOMAXPROCS по умолчанию будет 8, поэтому в каждый момент времени крутятся 8 горутин, а остальные 92 ждут в очередях. Начиная с Go 1.14 работает асинхронное вытеснение: sysmon через ~10 мс каждой горутине шлёт SIGURG, она уходит в глобальную очередь, на её место встаёт следующая — программа не виснет, все 100 горутин по кругу получают процессорное время, утилизация CPU ≈ 800 % (top покажет ~800 %), реальной работы при этом ноль.

Глубже. До Go 1.14 ответ был другим и его любят спрашивать: пустой for {} не содержит вызовов функций, значит не содержит точек вытеснения; 8 горутин навсегда занимали бы все P, остальные 92 не стартовали бы никогда, а первая же попытка GC (или runtime.GC(), или любой stop-the-world) подвесила бы процесс целиком, потому что STW ждёт, пока все горутины дойдут до safe-point. Сегодня STW работает, GC проходит, но latency всей программы страдает: любая полезная горутина конкурирует со 100 «жрунами» и получает процессор примерно раз в 100×10 мс. Дополнительные нюансы, которые хорошо назвать: 4 физических ядра и 8 гипертредов — это не 8× производительности, SMT даёт обычно +20–40 % на смешанной нагрузке и почти ничего на чисто ALU-bound цикле; компилятор пустой for {} не выбрасывает; вытеснение не сработает, если цикл находится в //go:nosplit-функции или в редких asm-участках без safe-point.

Коротко. GOMAXPROCS — это максимальное количество потоков ОС, которые одновременно могут исполнять Go-код, то есть число логических процессоров P в планировщике. Задаётся переменной окружения GOMAXPROCS или вызовом runtime.GOMAXPROCS(n), по умолчанию равно числу доступных процессу логических CPU.

Глубже. Формально в документации runtime: «GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously». Важно, что это про Go-код: горутины в системных вызовах и cgo из-под этого лимита выпадают. Технически это длина массива allp — изменение значения через runtime.GOMAXPROCS(n) с n > 0 вызывает stopTheWorld, пересоздаёт набор P и запускает мир заново, поэтому дёргать её в горячем коде нельзя. Вызов runtime.GOMAXPROCS(0) ничего не меняет и просто возвращает текущее значение.

Коротко. runtime.Gosched() добровольно уступает процессор: текущая горутина не блокируется и остаётся runnable, но помещается в глобальную очередь, а планировщик выбирает следующую горутину. Это кооперативный yield, а не sleep и не завершение.

Глубже. Внутри это mcall(gosched_m)goschedImpl: статус горутины меняется с _Grunning на _Grunnable, она отвязывается от M и уходит именно в глобальную очередь через globrunqput — то есть в конец «общей» очереди, а не в свою локальную; это сделано, чтобы yield реально давал шанс другим. С появлением асинхронного вытеснения в Go 1.14 практическая нужда в Gosched() почти исчезла: раньше её вставляли в длинные вычислительные циклы, чтобы не подвесить планировщик. Сегодня легитимные применения редки — микро-бенчмарки, спин-ожидание, тесты на гонки, попытка «подтолкнуть» другую горутину. Не путайте с соседями: runtime.Goexit() завершает текущую горутину, выполнив её defer (и вызывает fatal error, если это main-горутина); time.Sleep(0) тоже приводит к переключению, но идёт через таймерный путь; runtime.Gosched() не является средством синхронизации — код, «работающий благодаря Gosched()», содержит гонку.

go func() {
for i := 0; ; i++ {
if i%1000 == 0 {
runtime.Gosched() // уступить процессор другим горутинам
}
}
}()

Чем отличается процесс от системного потока?

Заголовок раздела «Чем отличается процесс от системного потока?»

Коротко. Процесс — это единица владения ресурсами: своё виртуальное адресное пространство, таблица дескрипторов, PID, права. Поток — единица планирования внутри процесса: у него свои стек, регистры и TID, но адресное пространство, файловые дескрипторы и обработчики сигналов он делит с остальными потоками процесса.

Глубже. В Linux и то и другое — это task_struct, разница только в флагах clone(2): CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD даёт поток (общий tgid = PID процесса), их отсутствие — процесс (fork — это clone без общих ресурсов, с copy-on-write памятью). Практические следствия: переключение между потоками одного процесса дешевле, чем между процессами, потому что не надо менять CR3 и сбрасывать TLB (частично помогает PCID); падение потока (segfault) убивает весь процесс, падение процесса соседей не трогает; общение между потоками — просто общая память плюс синхронизация, между процессами — IPC (пайпы, сокеты, shared memory, сигналы). Для Go эта иерархия трёхуровневая: процесс → потоки ОС (M) → горутины, где горутины планируются рантаймом, а потоки — ядром. Полезно добавить про изоляцию: намного дешевле создать поток (~10–100 мкс), чем процесс, и на порядки дешевле — горутину (~1–2 мкс и 8 КБ памяти).

Коротко. См. выше «GOMAXPROCS это?» — это число P, то есть верхняя граница параллелизма Go-кода. Нужна она, чтобы согласовать степень параллелизма с реально доступным CPU: слишком большое значение даёт лишние переключения контекста, разрастание mcache и рост нагрузки на GC, слишком маленькое — недоиспользование ядер.

Глубже. Практические причины трогать значение: (1) контейнер с CPU-лимитом на версиях Go до 1.25 — там нужно выставлять GOMAXPROCS по квоте (automaxprocs или вручную), иначе CFS-троттлинг будет резать программу пачками по 100 мс и ломать хвостовые задержки; (2) latency-критичные сервисы иногда выигрывают от GOMAXPROCS, чуть меньшего, чем число ядер, чтобы оставить запас под сторонние процессы; (3) тесты и бенчмарки — GOMAXPROCS=1 для воспроизводимости или, наоборот, -cpu=1,2,4 в go test; (4) отладка гонок: гонка, не воспроизводящаяся на одном P, часто проявляется на восьми. Чего GOMAXPROCS не делает: не ограничивает число горутин, не ограничивает число потоков ОС и не является жёстким лимитом потребления CPU процессом (см. следующий вопрос).

Может ли приложение с GOMAXPROCS=4 потреблять больше 4-eх ядер CPU?

Заголовок раздела «Может ли приложение с GOMAXPROCS=4 потреблять больше 4-eх ядер CPU?»

Коротко. Да. GOMAXPROCS ограничивает только число потоков, одновременно исполняющих Go-код; потоки, сидящие в системных вызовах и в C-коде через cgo, а также служебные потоки рантайма под этот лимит не попадают, поэтому top вполне может показать больше 400 %.

Глубже. Конкретные источники «лишнего» CPU: (1) блокирующие syscall’ы — время в ядре считается на процесс, и десять потоков, копирующих файлы, дадут заметный sys-time; (2) cgo — C-код исполняется потоком, не держащим P, и может хоть сам порождать потоки (например, OpenSSL, CUDA, движки БД); (3) sysmon — постоянный, хоть и лёгкий, фоновый поток; (4) обработка сигналов и LockOSThread-потоки. При этом чисто Go-шный CPU (фоновые воркеры GC, обработка каналов, mark-фаза) как раз укладывается в GOMAXPROCS, потому что GC-воркеры занимают P. Правильный вывод на собеседовании: если нужен жёсткий лимит на потребление CPU — это делается средствами ОС (cgroup cpu.max, taskset, systemd CPUQuota), а GOMAXPROCS — это подсказка планировщику Go, а не квота.

Что такое переменная GOMAXPROCS в Go и зачем она нужна?

Заголовок раздела «Что такое переменная GOMAXPROCS в Go и зачем она нужна?»

Коротко. См. два предыдущих вопроса: это переменная окружения (и парная функция runtime.GOMAXPROCS), задающая число логических процессоров P планировщика. Отличие формулировки — здесь спрашивают именно про переменную окружения: она читается один раз при старте рантайма, до main, и её изменение внутри процесса через os.Setenv уже ни на что не влияет.

Глубже. Порядок приоритетов: значение по умолчанию (число доступных логических CPU, с Go 1.25 — с учётом cgroup-лимита) → переменная окружения GOMAXPROCS → явный вызов runtime.GOMAXPROCS(n) в коде. Некорректное или неположительное значение переменной окружения игнорируется. В Go 1.25 добавили runtime.SetDefaultGOMAXPROCS(), возвращающий значение к автоматически вычисленному, и GODEBUG-флаги containermaxprocs/updatemaxprocs для отката к старому поведению — это стоит упомянуть, если разговор про Kubernetes.

fmt.Println(runtime.NumCPU()) // логических CPU, доступных процессу
fmt.Println(runtime.GOMAXPROCS(0)) // текущее значение, без изменения
prev := runtime.GOMAXPROCS(4) // установить 4, вернуть предыдущее (STW!)
_ = prev

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

Заголовок раздела «Предположим, какая-то из горутин выполняет тяжелую вычислительную задачу и работает очень долго. Что произойдет с этой горутиной и с другими горутинами, которые стоят за ней в очереди?»

Коротко. Начиная с Go 1.14 sysmon заметит, что горутина крутится дольше 10 мс, и вытеснит её асинхронно через SIGURG: она станет _Grunnable и уйдёт в глобальную очередь, а P возьмёт следующую. Остальные горутины не голодают, но и справедливости уровня «каждому поровну» нет — планировщик Go не реализует полноценные приоритеты и кванты, только защиту от монополизации.

Глубже. До 1.14 (и сегодня — в редких участках без safe-point) такая горутина держала бы P бесконечно, и хуже того — блокировала бы stop-the-world фазу GC для всей программы. Сейчас механизм такой: sysmon в retake() сравнивает schedtick и время начала выполнения; если прошло больше forcePreemptNS = 10 мс, вызывается preemptone — выставляется preempt-флаг и stackguard0 = stackPreempt (кооперативный путь), а также посылается сигнал SIGURG потоку (асинхронный путь). Обработчик сигнала подменяет точку возврата на asyncPreempt, который сохраняет регистры и уходит в планировщик. Важные оговорки: если тяжёлая горутина не только считает, но и активно аллоцирует, она дополнительно «оплачивает» ассистирование GC (mark assist), что замедлит именно её; если таких горутин ровно GOMAXPROCS, latency остальных вырастет пропорционально — лечится ограничением пула CPU-bound воркеров, а не надеждой на планировщик.

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

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

Коротко. Нет: сработает work stealing — P, оставшийся без работы, сначала заглянет в глобальную очередь и netpoller, а затем начнёт воровать половину локальной очереди у случайно выбранных других P. Простаивать он начнёт только тогда, когда работы нет нигде во всём процессе.

Глубже. Алгоритм в findRunnable() (runtime/proc.go): проверить локальный runnext и локальную очередь → каждый 61-й тик или при пустой локальной — глобальную (globrunqget) → неблокирующий netpoll() → четыре раунда обхода всех P в случайном порядке (stealWork, порядок задаётся randomOrder, чтобы не создавать «поездов»), забирая половину чужой очереди (runqsteal); на последнем раунде разрешено красть также runnext жертвы и её готовые таймеры → проверить работу GC (idle mark worker) → если ничего нет, P кладётся в pidle, а M перед парковкой ещё раз перепроверяет очереди и делает блокирующий netpoll, чтобы не проспать событие ввода-вывода. Гонка «положили горутину как раз в момент парковки» закрыта протоколом со счётчиком спиннинг-потоков nmspinning: минимум один поток всегда остаётся в состоянии активного поиска, пока в системе есть непустые очереди.

Приложение работало на одном ядре CPU, добавили второе, но оно все равно использует только одно. Почему?

Заголовок раздела «Приложение работало на одном ядре CPU, добавили второе, но оно все равно использует только одно. Почему?»

Коротко. Две группы причин: либо рантайм по-прежнему видит одно ядро (GOMAXPROCS вычислен при старте процесса и не пересчитывается, стоит переменная окружения, ограничение cgroup/taskset, вызов runtime.GOMAXPROCS(1) в коде), либо в самой программе нет параллелизма — работа выполняется одной горутиной или сериализована мьютексом/каналом/sync.Once.

Глубже. Чек-лист диагностики: (1) вывести runtime.NumCPU() и runtime.GOMAXPROCS(0) при старте — часто этого достаточно; (2) проверить переменную окружения и cgroup: в контейнере лимит cpu.max до Go 1.25 не влиял на GOMAXPROCS, зато влиял на реальное исполнение; наоборот, cpuset.cpus/taskset режут маску affinity, и NumCPU() честно вернёт 1; (3) значение по умолчанию считается один раз при инициализации рантайма — «горячее» добавление CPU в VM без рестарта процесса на старых версиях Go не подхватится; (4) архитектура приложения: если все запросы проходят через один глобальный мьютекс, единственный воркер или небуферизованный канал-«воронку», второе ядро физически нечем занять — тут поможет профиль go tool pprof (block/mutex профили и runtime/trace, где сразу видно, сколько P заняты). Отдельная тонкость: GOMAXPROCS > 1 при единственной активной горутине даст ровно одно занятое ядро — параллелизм не появляется сам по себе.

Как блокирующиеся системные вызовы влияют на работу планировщика?

Заголовок раздела «Как блокирующиеся системные вызовы влияют на работу планировщика?»

Коротко. Перед syscall горутина вызывает entersyscall: P переводится в состояние _Psyscall и формально остаётся привязанным к потоку. Если вызов затянулся (больше ~20 мкс — это замечает sysmon в retake), P отбирается и отдаётся другому потоку через handoffp, так что параллелизм Go-кода не деградирует. На выходе exitsyscall пытается вернуть свой P; если не получилось — горутина ставится в глобальную очередь, а поток паркуется.

Глубже. Различают быстрые и заведомо долгие вызовы: для вторых рантайм сразу вызывает entersyscallblock, который отдаёт P немедленно, не дожидаясь sysmon. Сетевые операции в этот путь обычно не попадают вовсе — их перехватывает netpoller и вместо syscall делает gopark. Проблема остаётся с тем, что epoll не умеет: обычные файлы, fsync, DNS через cgo-резолвер, вызовы вроде os.ReadDir на сетевой ФС. Каждый такой застрявший вызов стоит одного потока ОС, и при массовом файловом I/O число потоков растёт до сотен — отсюда рекомендация ограничивать конкурентность дискового I/O семафором. Стоимость перехода тоже не нулевая: entersyscall/exitsyscall — это десятки наносекунд плюс возможный handoffp с созданием потока, поэтому очень частые мелкие syscall’ы (например, write по одному байту) заметно бьют по производительности; лечится буферизацией (bufio). Полезно упомянуть GODEBUG=schedtrace=1000,scheddetail=1 — там видно состояние каждого P, включая syscall.

ответить на ряд вопросов по многопоточной работе с это чередью и особенностях работы рантайма.

Заголовок раздела «ответить на ряд вопросов по многопоточной работе с это чередью и особенностях работы рантайма.»

Коротко. Обрывок исходника, вопрос не восстанавливается — это фрагмент рассказа кандидата о ходе собеседования, а не сам вопрос.

Глубже. По контексту речь шла о блоке вопросов «очередь + многопоточность + рантайм»: устройство локальных и глобальной очередей планировщика, безопасная конкурентная работа с собственной очередью (мьютекс против канала против lock-free кольца), и как это ложится на GMP. Ответы на все эти темы — в соседних вопросах этого конспекта.

Глобальная очередь, как из нее забираются горутины?

Заголовок раздела «Глобальная очередь, как из нее забираются горутины?»

Коротко. Из глобальной очереди берут через globrunqget() под sched.lock, причём не по одной горутине, а батчем: примерно len(глобальной)/GOMAXPROCS + 1 штук, но не больше половины ёмкости локальной очереди (то есть не больше 128). Одна горутина выполняется сразу, остальные складываются в локальную очередь P.

Глубже. Обращаются к глобальной очереди в двух случаях: каждый 61-й тик планировщика (schedtick%61 == 0) — специально, чтобы горутины в глобальной очереди не голодали, пока P крутит свою локальную; и когда локальная очередь опустела, до этапа воровства. Батчинг нужен, чтобы амортизировать стоимость sched.lock — глобальная очередь единственная на процесс и является потенциальной точкой contention. Попадают в неё горутины: вытесненные по Gosched()/SIGURG, «перелившиеся» из переполненной локальной очереди (сброс половины + новая), возвращённые из syscall, когда своего P не досталось, и (через injectglist) разбуженные netpoller’ом, если некуда положить локально. Магическое число 61 — простое число, выбранное, чтобы период проверки не резонировал с другими периодичностями в рантайме; это часто спрашивают как «а почему 61?».

Что происходит, если у процессора закончились горутины?

Заголовок раздела «Что происходит, если у процессора закончились горутины?»

Коротко. См. выше про work stealing: P последовательно пробует глобальную очередь, netpoller, воровство у других P, работу GC; если везде пусто — P уходит в список pidle, а поток сначала недолго «спиннит» (ищет работу активно), потом паркуется на футексе через stopm и не потребляет CPU.

Глубже. Отличие этого вопроса от предыдущего варианта — акцент на состоянии простоя. Число одновременно спиннящих потоков ограничено (примерно половиной GOMAXPROCS), чтобы холостой поиск не съедал CPU; спиннинг существует ради латентности: разбудить припаркованный на futex поток стоит порядка микросекунд, что дорого для коротких задач. Пробуждение происходит из wakep() — его дёргает тот, кто положил горутину в очередь (go f(), close(ch), отправка в канал, готовность в netpoll). Важно, что при полном простое Go-программа действительно потребляет почти ноль CPU, кроме sysmon, который сам увеличивает свой интервал сна до 10 мс при отсутствии активности, и периодических пробуждений таймеров и GC (принудительный цикл раз в 2 минуты).

Коротко. Планировщик мультиплексирует горутины на потоки ОС: распределяет G по очередям P, выбирает следующую горутину для исполнения, балансирует нагрузку воровством работы, управляет жизненным циклом потоков (создание, парковка, передача P при syscall), обеспечивает вытеснение долгоиграющих горутин и координируется с GC и netpoller’ом.

Глубже. Развёрнутый список для устного ответа: (1) создание горутины и её постановка в runnext/локальную/глобальную очередь; (2) schedule()/findRunnable() — выбор следующей G с учётом справедливости (61-й тик) и локальности; (3) execute()/gogo — переключение контекста (сохранение и восстановление SP, PC, нескольких регистров); (4) парковка и пробуждение горутин (gopark/goready) — это на нём построены каналы, мьютексы, WaitGroup, таймеры и сетевой I/O; (5) балансировка через work stealing; (6) управление M: startm, stopm, handoffp, спиннинг, лимит SetMaxThreads; (7) вытеснение: кооперативные safe-point’ы и асинхронный SIGURG через sysmon; (8) интеграция с netpoller и таймерами (P-локальные кучи таймеров с Go 1.14); (9) взаимодействие с GC: остановка мира (stopTheWorld через safe-point’ы всех P), запуск фоновых mark-воркеров на P, mark assist для аллоцирующих горутин; (10) обработка LockOSThread, сигналов и специальных потоков; (11) диагностика — GODEBUG=schedtrace, runtime/trace. Полезно завершить одной фразой про философию: планировщик Go оптимизирован под пропускную способность и дешёвое переключение, а не под жёсткие гарантии реального времени — приоритетов у горутин нет.

  • Путать GOMAXPROCS с числом потоков или с лимитом CPU: говорить «GOMAXPROCS=4 значит четыре потока и максимум 400 % CPU». На самом деле это только число P, а потоков может быть сотни (syscall, cgo, LockOSThread).
  • Утверждать, что планировщик Go «вытесняющий с квантами по 10 мс» или, наоборот, «чисто кооперативный». Правильно: гибрид — кооперативные safe-point’ы плюс асинхронное вытеснение через SIGURG начиная с Go 1.14, и 10 мс — это порог sysmon, а не гарантированный квант.
  • Считать, что «блокирующий» файловый ввод-вывод обрабатывается netpoller’ом так же, как сетевой. Epoll не работает с обычными файлами: дисковый I/O занимает реальный поток ОС.
  • Отвечать, что при GOMAXPROCS=1 конкурентности нет и всё выполняется строго последовательно и детерминированно. Конкурентность есть, порядок недетерминирован, I/O-bound задачи по-прежнему перекрываются.
  • Говорить «горутина — это лёгкий поток ОС» или «горутина = 2 КБ». Горутина — сущность рантайма, ядро о ней не знает; минимальный стек с Go 1.4 равен 8 КБ (2 КБ — устаревшая цифра, встречающаяся в старых статьях, и она относится к другим версиям/архитектурам).
  • Забывать про глобальную очередь и про механизм справедливости (каждый 61-й тик), описывая планировщик только как «у каждого P своя очередь + воровство».
  • Обещать, что «в контейнере Go сам подстроится под CPU-лимит». До Go 1.25 не подстраивался — нужен automaxprocs или ручная настройка; с 1.25 значение по умолчанию учитывает cgroup.
  • На вопрос про драйверы отвечать просто «нет, потому что GC», не назвав альтернатив (UIO/VFIO, FUSE, TUN/TAP, eBPF, TinyGo) — интервьюер обычно ждёт именно продолжения.