09. Планировщик и GMP модель

«Планировщик — это сердце параллельной системы. Если он справедлив и быстр, программа поёт; если неуклюж — процессор задыхается в ожидании».
— Брайан Керниган


Архитектура планировщика: модель GMP, Work Stealing и системная координация

Планировщик Go реализует модель M:N мультиплексирования: $M$ горутин (пользовательских легковесных потоков) распределяются по $N$ потокам операционной системы с использованием $P$ логических процессоров (контекстов выполнения).

Физическая аналогия (Mechanical Sympathy):

  • G (Goroutine): Технологическая карта / бланк производственного задания. Весит всего пару страниц (начальный стек 2 КБ), содержит инструкцию и текущий прогресс. Таких бланков на предприятии могут быть миллионы.
  • P (Processor): Логический станок или рабочий стол со своей локальной очередью на 256 заказов. Количество станков строго зафиксировано переменной GOMAXPROCS (обычно равно количеству физических ядер CPU).
  • M (Machine / OS Thread): Живой рабочий-оператор. Рабочий садится за свободный станок $P$ и непрерывно выполняет бланки $G$. Если задание требует долгой доставки деталей со склада (дисковый ввод-вывод или блокирующий syscall в ОС), рабочий уходит с бланком $G$ в подсобку (поток ОС блокируется в ядре), а станок $P$ мгновенно передаётся другому свободному рабочему, чтобы производство не простаивало ни микросекунды (Handoff P).
flowchart TD
    classDef idle    fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef runnable fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef running  fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
    classDef waiting  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef syscall  fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef dead     fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc

    Idle["_Gidle (выделена структура g)"]:::idle -->|"runtime.newproc"| Runnable["_Grunnable (в очереди выполнения P)"]:::runnable
    Runnable -->|"Выбрана M (execute)"| Running["_Grunning (исполняется на ядре CPU)"]:::running
    Running -->|"Блокировка (chan, mutex, netpoll)"| Waiting["_Gwaiting (припаркована, gopark)"]:::waiting
    Waiting -->|"Событие готово (goready)"| Runnable
    Running -->|"Системный вызов (entersyscall)"| Syscall["_Gsyscall (поток M заблокирован)"]:::syscall
    Syscall -->|"Возврат из syscall (exitsyscall)"| Running
    Running -->|"Вытеснение (sysmon / preemption)"| Runnable
    Running -->|"Завершение функции (goexit)"| Dead["_Gdead (помещена в пул gfput)"]:::dead
  
flowchart TD
    classDef check   fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
    classDef steal   fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc
    classDef idle    fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc

    Start["Поток M освободился (findrunnable)"]:::check --> S0{"Каждые 61 тик:<br/>Проверить Global Run Queue?"}:::check
    S0 -->|"Да"| GrabGlobal0["Взять G из глобальной очереди"]:::success
    S0 -->|"Нет"| S1{"Есть ли задача в runnext текущего P?"}:::check
    S1 -->|"Да"| GrabRunnext["Выполнить runnext (кэш-горячая G)"]:::success
    S1 -->|"Нет"| S2{"Есть ли задачи в локальной очереди P (256 слотов)?"}:::check
    S2 -->|"Да"| GrabLocal["Забрать G из локальной очереди"]:::success
    S2 -->|"Нет"| S3{"Есть ли задачи в глобальной очереди (sched.runq)?"}:::check
    S3 -->|"Да"| GrabGlobal["Забрать N задач в локальную очередь"]:::success
    S3 -->|"Нет"| S4{"Проверить Netpoller (сетевые сокеты)?"}:::check
    S4 -->|"Найдены готовые сокеты"| GrabNet["Разбудить сетевую G"]:::success
    S4 -->|"Пусто"| Steal["Work Stealing: случайный выбор другого P<br/>Украсть 50% задач из его очереди"]:::steal
    Steal -->|"Успешно украдено"| RunStolen["Выполнить украденную G"]:::success
    Steal -->|"Все очереди пусты"| Park["Перевести M в режим spinning или сон (notesleep)"]:::idle
  
flowchart TD
    classDef normal fill:#1e3a5f,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef detach fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc
    classDef kernel fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc

    subgraph Before["1. До блокировки: M1 работает на P1"]
        M1["Поток M1"]:::normal --- P1["Контекст P1"]:::normal
        P1 --- G1["Горутина G1"]:::normal
    end

    subgraph Syscall["2. Вызов entersyscall() (блокировка в ядре)"]
        G1 -->|"syscall.Read(disk_fd)"| Kernel["Ядро Linux (блокирующий I/O)"]:::kernel
        M1 -->|"M1 заблокирован в ядре"| Kernel
    end

    subgraph Handoff["3. Механизм Handoff P: передача станка P1"]
        P1 -.->|"Отвязка P1 от заблокированного M1"| M2["Свободный поток M2"]:::detach
        M2 --- P1
        P1 --> GOthers["Выполнение остальных горутин из очереди (G2, G3...)"]:::detach
    end
  

  1. Что такое горутина (goroutine)?
  2. Чем горутина отличается от потока ОС (thread)?
  3. Что такое G (Goroutine)?
  4. Что такое M (Machine)?
  5. Что такое P (Processor)?
  6. Как работает планировщик (scheduler)?
  7. Что такое work stealing?
  8. Как обрабатываются системные вызовы (syscalls)?
  9. Что происходит при блокирующем системном вызове (blocking syscall)?
  10. Что такое вытеснение (preemption)?
  11. Что такое кооперативная многозадачность (cooperative scheduling)?
  12. Что такое асинхронное вытеснение (async preemption)?
  13. Как работает runtime.GOMAXPROCS?
  14. Может ли горутина бесконечно занимать поток?
  15. Что такое "локальные" и "глобальные" очереди выполнения?
  16. Как происходит рождение и завершение горутины?
  17. Что такое "sysmon" и как он связан с планировщиком?
  18. Как планировщик обрабатывает сетевые вызовы (netpoller)?
  19. Что такое «привязка» горутины к машине (M)?
  20. Как runtime.LockOSThread() влияет на планировщик?
  21. Что происходит с параллелизмом, если GOMAXPROCS=1?
  22. Как планировщик выбирает следующий G для выполнения?
  23. Что такое "spinning" M?
  24. Как планировщик балансирует нагрузку между P?
  25. Как долго горутина может выполняться без переключения?
  26. Как профилировать работу планировщика (scheduler trace)?
  27. Как go func() превращается в горутину внутри?
  28. Как происходит "handoff" P при блокирующем вызове?
  29. Как работает GOMAXPROCS в контейнерной среде?
  30. Как устроена блокировка в канале с точки зрения планировщика?
  31. Как планировщик взаимодействует со сборщиком мусора?
  32. Что такое "findrunnable" и как он ищет работу?
  33. Как runtime.Gosched реализован на уровне планировщика?
  34. Что такое "idle P" и как он находит работу?
  35. Как планировщик обрабатывает таймеры и time.AfterFunc?
  36. Как работает "глобальная очередь" и когда горутины туда попадают?
  37. Что такое "P's runnext" и как это ускоряет выполнение?
  38. Как планировщик решает, красть ли ему работу у другого P?
  39. Как wakeup спящей горутины работает на уровне планировщика?
  40. Что такое "schedtick" и "syscalltick"?
  41. Как принудительно ограничить количество используемых потоков ОС?
  42. Как работает runtime.NumGoroutine() и что он показывает?
  43. В чем разница между "Scheduler Trace" и "CPU Profile"?
  44. Как gomaxprocs влияет на производительность сетевого сервера?
  45. Как планировщик обрабатывает долгие вызовы cgo?
  46. Как работает "thread parking" и "unparking"?
  47. Что такое "goexit" и как он освобождает ресурсы горутины?
  48. Как планировщик Go эволюционировал от версии к версии?
  49. Может ли одна горутина быть запланирована на нескольких P одновременно?
  50. Как работает канал semacquire и semrelease для синхронизации с семафорами в планировщике?
  51. Что такое Container-Aware GOMAXPROCS в Go 1.25?

Что такое горутина (goroutine)?

Краткий ответ

Горутина — это легковесный поток выполнения, управляемый рантаймом Go, а не операционной системой. Она представляет собой функцию, запущенную с ключевым словом go, и обладает собственным стеком, который динамически растёт и уменьшается. Горутины мультиплексируются на небольшое количество потоков ОС, что позволяет создавать миллионы горутин с минимальными накладными расходами.

Развёрнутый ответ

Горутина — фундаментальная абстракция конкурентности в Go. В отличие от классических потоков, её жизненным циклом полностью управляет планировщик Go в рантайме. При вызове go f() создаётся новый объект G (структура g в рантайме), инициализируется стек (обычно 2-4 КБ), и горутина помещается в очередь выполнения. Стек растёт по мере необходимости (с помощью копирования стека, а не сегментации, начиная с Go 1.4), что позволяет миллионам горутин мирно сосуществовать.

Горутины чрезвычайно дёшевы:

  • Создание занимает несколько десятков наносекунд.
  • Начальный стек в 2 КБ против 1-8 МБ у потока.
  • Переключение контекста происходит в userspace, не требует перехода в ядро и обходится в десятки-сотни тактов ЦП.

Пример запуска:

Go
go func() {
    // конкурентная работа
}()

Горутина не имеет прямого мьютекса или сигнала — она общается через каналы и примитивы синхронизации. Планировщик Go (GMP-модель) распределяет тысячи G по нескольким M (потокам ОС), привязанным к P (виртуальным процессорам). Это даёт высокий параллелизм и масштабируемость.

Под капотом (internals)

В рантайме горутина представлена структурой runtime.g (файл runtime/runtime2.go). Ключевые поля:

  • stack — границы стека (stack.lo, stack.hi),
  • sched — сохранённые регистры (PC, SP) для возобновления,
  • goid — уникальный идентификатор,
  • m — текущий M, на котором выполняется,
  • atomicstatus — статус (Gidle, Grunnable, Grunning, Gwaiting и др.),
  • gopc — PC инструкции go, полезно при профилировании.

При создании go func() компилятор вставляет вызов runtime.newproc, который аллоцирует G, инициализирует стек и помещает G в очередь P (локальную runq). Если локальная очередь заполнена (256 элементов), G попадает в глобальную. Позже планировщик запускает её на свободном M.

Рост стека происходит при нехватке места: через runtime.morestack -> runtime.newstack, где стек копируется в новый увеличенный участок памяти (удвоение до 1 ГБ). Старый стек освобождается после того, как все горутины, ссылающиеся на него, завершат копирование. Это полностью прозрачно для пользователя.

Типичные ошибки кандидатов

  1. Считать горутину потоком ОС.
    На собеседовании такое сразу вызовет вопрос о глубине понимания. Горутина — это не поток, она не имеет прямой связи с ядром, управляется планировщиком Go. Смешивать понятия нельзя, это разные уровни абстракции.

  2. Игнорировать утечку горутин.
    Часто кандидаты запускают горутину без механизма завершения (например, канал-отмена или context.Context). Если горутина блокируется навсегда, она остаётся в памяти, увеличивая потребление и мешая сборщику мусора. Всегда нужно предусматривать выход: defer close(done) или select с ctx.Done().

  3. Полагать, что стек горутины бесконечен.
    Максимальный размер стека ~1 ГБ в Go 1.22. При переполнении — фатальная паника runtime: goroutine stack exceeds 1000000000-byte limit. Нельзя рекурсивно вызывать функции без ограничений.

Возможные дополнительные вопросы интервьюера

  • Что такое goroutine leak и как его обнаружить?
    Утечка — горутина, которая никогда не завершается, часто из-за блокировки на канале. Обнаруживается профилировщиком горутин (pprof.Lookup("goroutine")) или мониторингом runtime.NumGoroutine() с течением времени.
  • Может ли горутина выполняться параллельно с другими на одном ядре?
    На одном M в каждый момент выполняется только одна горутина. Параллелизм достигается при GOMAXPROCS > 1, когда на разных ядрах работают разные M с разными P.
  • Чем горутина отличается от корутины в других языках?
    Горутина — это вытесняемая корутина с собственным стеком, встроенным в язык и рантайм, с прозрачным планированием и мультиплексированием на потоки. В отличие от async/await, она не требует асинхронной раскраски функций.

Чем горутина отличается от потока ОС (thread)?

Краткий ответ

Главные отличия: горутина управляется рантаймом Go в пространстве пользователя, поток — ядром ОС; переключение горутин обходится в десятки наносекунд без системных вызовов, у потоков — микросекунды с переключением в режим ядра; стартовый стек горутины ~2 КБ против 1-8 МБ у потока; миллионы горутин могут сосуществовать, тогда как тысячи потоков уже вызывают деградацию.

Развёрнутый ответ

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

Сравнение:

Характеристика Горутина Поток ОС
Управление Рантайм Go (GMP) Ядро ОС
Создание ~40-60 нс, структура g + stack ~10-20 мкс, syscall clone
Переключение контекста ~50-100 нс, сохранение PC/SP ~1-5 мкс, переход в kernel
Размер стека начальный 2-8 КБ, динамический рост 1-8 МБ, фиксированный (или mmap)
Количество (практическое) Миллионы Тысячи
Синхронизация основная Каналы, atomic, mutex на горутинах Mutex, condvar, futex
Вытеснение Async preemption (сигналы) Таймеры ядра

Планировщик Go мультиплексирует N горутин на M потоков (модель M:N). Это позволяет использовать все ядра CPU без накладных расходов на тысячи потоков. Более того, горутина может быть дешевле, чем даже стек потока, поэтому можно писать в стиле «по горутине на соединение».

Под капотом (internals)

Планировщик Go использует поток ОС как «рабочую лошадку» (M, machine). Структура runtime.m описывает поток: содержит указатель на текущую выполняемую горутину curg, поле g0 (стековая горутина для планировщика), сигнальные маски и т.д. Переключение с горутины на горутину на том же M происходит через вызов runtime.gogo или runtime.gosave для сохранения контекста в g.sched и загрузки нового. При этом потоку не нужно заходить в ядро: переключение реализовано через ассемблерные инструкции сохранения/восстановления регистров. Поток продолжает выполняться, но меняет пользовательский контекст.

Системные вызовы, которые могут заблокировать поток, обрабатываются специальным образом: P освобождается и может обслуживать другую горутину на другом M, а текущий M с блокированной горутиной приостанавливается. Таким образом горутина блокируется, но не блокирует весь P.

Типичные ошибки кандидатов

  1. Прямое сравнение горутин и потоков без учёта контекста.
    «Горутина — это поток внутри потока» — неверное упрощение. Горутина — отдельная абстракция, которая не зависит от 1:1 связи с ядром. Лучше говорить о легковесном потоке выполнения, мультиплексируемом поверх потоков.

  2. Предположение, что горутина гарантированно даёт выигрыш в производительности при любом сценарии.
    Если горутина постоянно делает системные вызовы, блокирующие поток, M будет простаивать, а P переходить на другой M. Это может породить избыточное создание потоков. Планировщик по-прежнему эффективен, но важно понимать ограничения.

  3. Незнание, что переключение горутин тоже имеет цену.
    Да, оно дешёвое, но при миллионах переключений в секунду накладные расходы суммируются. Поэтому важно не злоупотреблять каналами без необходимости.

Возможные дополнительные вопросы интервьюера

  • Почему тогда не отказаться полностью от потоков?
    Рантайму всё равно нужны потоки для выполнения на разных ядрах и выполнения блокирующих системных вызовов, которые нельзя вытеснить в userspace.
  • Можно ли создать горутину без выделения стека?
    Нет, каждая горутина требует хотя бы минимального стека для сохранения контекста вызовов. Но начальный стек очень мал, 2 КБ.
  • Как горутина соотносится с async/await в C# или JS?
    Горутина — это вытесняемая корутина общего назначения; async/await — кооперативная модель с раскраской функций. Горутины прозрачны и не требуют специального синтаксиса внутри вызываемой функции.

Что такое G (Goroutine)?

Краткий ответ

G — это структура данных в рантайме Go, представляющая одну горутину. Она содержит стек, машинный контекст (сохранённые регистры), идентификатор, состояние и ссылки на M и P. G — центральный элемент планирования, именно экземпляры G помещаются в очереди выполнения и переключаются планировщиком.

Развёрнутый ответ

G — это сокращение от runtime.g, определённого в runtime/runtime2.go. Когда вы пишете go func(){}, создаётся новый объект G, который начинает жить своей жизнью. G хранит всю информацию, необходимую для выполнения функции: границы стека (stack.lo, stack.hi), счётчик команд и указатель стека на момент последнего переключения (g.sched.pc, g.sched.sp), идентификатор goid, статус (atomicstatus), указатель на M (m) и P (lockedm для LockOSThread), ссылку на обработчик паники и defer-ы.

G может находиться в одном из нескольких состояний:

  • _Gidle — только что создана,
  • _Grunnable — готова к выполнению, находится в очереди,
  • _Grunning — выполняется на M,
  • _Gsyscall — заблокирована в системном вызове,
  • _Gwaiting — ожидает (канал, мьютекс, таймер),
  • _Gdead — завершена, стек освобождён.

Планировщик оперирует именно этими состояниями и перемещает G между очередями.

Под капотом (internals)

Структура g в Go 1.22+ выглядит примерно так (упрощённо):

Go
type g struct {
    stack       stack   // {lo, hi uintptr}
    sched       gobuf   // pc, sp, lr, bp, ret...
    goid        int64
    atomicstatus uint32
    m           *m      // текущий M
    lockedm     muintptr // закреплённый M для LockOSThread
    gopc        uintptr // PC инструкции go, создавшей эту горутину
    ...
}

gobuf (runtime.gobuf) содержит регистры, которые сохраняются при переключении. При вызове runtime.gogo восстанавливается контекст из g.sched, и выполнение переходит по сохранённому PC.

G не владеет напрямую примитивами синхронизации; ожидание реализуется через очередь ожидания в канале (runtime.sudog) или в структуре mutext. G помещается в список ожидающих, и когда событие происходит, планировщик делает её Grunnable и помещает в очередь P.

Типичные ошибки кандидатов

  1. Путать G и горутину как функцию.
    G — представление в рантайме, а не сама функция. Функция хранится как адрес в sched.pc и выполняется в контексте G.
  2. Неверное предположение, что G можно использовать напрямую.
    В Go нет публичного API к g, только косвенно через пакет runtime. Попытки манипулировать G извне без unsafe невозможны.
  3. Думать, что G удаляется сразу после выхода из функции.
    G переходит в _Gdead, но её структура может быть переиспользована из пула (gFree), чтобы избежать лишних аллокаций. Стек освобождается только при сборке мусора или при возврате в пул.

Возможные дополнительные вопросы интервьюера

  • Где хранится стек G?
    В куче (heap), динамически аллоцированной, начиная с нескольких килобайт. Рантайм управляет памятью стеков через собственный стек-пул.
  • Как G получает идентификатор и можно ли его использовать?
    goid хранится в G и может быть получен через runtime.GoID(). Его можно использовать для метрик, но не для логики, так как идентификаторы переиспользуются.

Что такое M (Machine)?

Краткий ответ

M — это структура runtime.m, представляющая поток операционной системы, на котором непосредственно исполняются горутины. M соответствует одному потоку ОС, управляет его локальным кешем, сигналами и привязкой к G и P. Количество активных M динамически меняется, но ограничено числом, которое может создать ОС.

Развёрнутый ответ

M обеспечивает реальное исполнение инструкций. Он содержит:

  • Поток ОС (создаётся через clone/pthread_create).
  • Два важных контекста горутин: g0 — особая горутина с системным стеком для выполнения кода планировщика и обработки сигналов; curg — текущая пользовательская горутина.
  • Информацию о планировщике: указатель на текущий P (p), статус (спящий, работающий, поиск и т. д.), семафор для парковки.
  • Кеш аллокаций (mcache), привязанный к P, но доступный через M.
  • Поле id и procid — идентификаторы.

M может находиться в трёх основных состояниях:

  • работающий — выполняет горутину с прикреплённым P,
  • спящий — без P, ждёт пробуждения,
  • в поиске (spinning) — пытается найти работу.

Планировщик создаёт новые M при необходимости (например, при блокирующем системном вызове или недостатке потоков для параллелизма), но не превышает лимит в ~10000 по умолчанию (ограничение runtime.debug.SetMaxThreads). M создаётся довольно дорого: требует системного вызова и выделения стека ОС (обычно 1-8 МБ), поэтому кеширование и переиспользование потоков важны.

Под капотом (internals)

Структура runtime.m в runtime/runtime2.go:

Go
type m struct {
    g0          *g     // горутина для планировщика, на системном стеке
    curg        *g     // текущая пользовательская горутина
    p           puintptr // текущий P
    nextp       puintptr // следующий P для временного хранения
    id          int64
    procid      uint64 // id потока ОС
    mcache      *mcache
    locks       int32  // счётчик блокировок
    spinning    bool   // в режиме активного поиска работы
    ...
}

Создание M происходит в runtime.newm -> runtime.newm1, которая вызывает clone/pthread_create (зависит от платформы). Запускается функция runtime.mstart, которая инициализирует M и переходит в цикл планировщика. Каждый M в цикле вызывает schedule() -> execute() -> gogo.

При парковке M вызывает futexsleep или pthread_cond_wait (для notesleep), освобождая ресурсы ЦП. Пробуждение (notewakeup) выводит M из ожидания, и он снова ищет работу.

Типичные ошибки кандидатов

  1. Путать M с P.
    M — физический поток, P — контекст планирования с локальной очередью. Без P M не может выполнять пользовательский код (только если не работает на g0 для обслуживания).
  2. Полагать, что M всегда столько же, сколько горутин.
    На деле число M значительно меньше, пропорционально параллельной работе и блокировкам. Это суть M:N модели.
  3. Игнорировать накладные расходы на создание M.
    Создание потока ОС дорого, поэтому планировщик старается задействовать спящие M, а не плодить новые.

Возможные дополнительные вопросы интервьюера

  • Что такое g0 и почему он нужен?
    g0 — горутина со стеком, выделенным ОС (большой, фиксированный), на котором выполняется код самого рантайма (планировщик, сборщик мусора). Это гарантирует, что планировщик имеет достаточно места для вызова и не может быть вытеснен.
  • Как узнать количество M?
    runtime.NumGoroutine() только для G. Количество M можно примерно оценить через pprof или через runtime.ThreadCreateProfile, но простого публичного API нет. Можно смотреть метрики prometheus go_threads из runtime/debug.

Что такое P (Processor)?

Краткий ответ

P — это виртуальный процессор, структура runtime.p, которая хранит контекст выполнения для набора горутин, локальную очередь готовых к запуску G, кеши аллокаций и связанные с ним M. P определяет максимальное количество одновременно выполняющихся горутин (логическое ядро). Именно количество P задаётся через GOMAXPROCS.

Развёрнутый ответ

P — сердце GMP-модели. Это ресурс, который связывает G и M: M может выполнять пользовательскую горутину только когда владеет P. P фактически представляет логический процессор, и количество P регламентирует параллелизм: каждая P может обслуживать один M в момент времени. Обычно GOMAXPROCS устанавливают равным числу ядер ЦП.

Ключевые поля P:

  • runq — массив ёмкостью 256, кольцевая очередь готовых горутин. Обеспечивает быстрый доступ без блокировок.
  • runnext — указатель на следующую G для запуска, что позволяет ускорить повторный запуск одной и той же горутины (например, при разблокировке канала).
  • mcache — локальный кеш выделения памяти для этого P, устраняющий contention.
  • gFree — пул освобождённых G для переиспользования.
  • sysmontick — для мониторинга системных вызовов.

P не привязан к конкретному M навсегда. Когда M блокируется в системном вызове, его P открепляется и передаётся свободному M (или создаётся новый M) — это называется handoff. Таким образом, P не простаивает, продолжая выполнять другие горутины. После выхода из syscall поток пытается получить свободный P или приостанавливается.

Под капотом (internals)

Структура runtime.p (runtime/runtime2.go):

Go
type p struct {
    id          int32
    status      uint32  // _Pidle, _Prunning, _Psyscall, _Pdead
    m           muintptr // обратная ссылка на M, владеющий P
    mcache      *mcache
    runqhead    uint32
    runqtail    uint32
    runq        [256]guintptr
    runnext     guintptr
    gFree       struct { ... } // список свободных G
    ...
}

При инициализации Go (runtime.schedinit) создаётся массив allp из GOMAXPROCS структур P. Статус _Pidle означает, что P свободен и его можно занять. Когда M входит в планировщик (schedule), он пытается найти P. _Prunning — P обслуживается M, _Psyscall — M исполняет системный вызов.

Механизм runnext реализован так: при помещении G в runnext, старая G из runnext перемещается в локальную очередь runq. Это уменьшает задержки при последовательной разблокировке (например, ch <- val; val2 := <-ch).

Локальный кеш памяти mcache привязан к P, а не к M или G, что снижает конкуренцию за центральные аллокаторы. Когда M переключает P, он получает mcache нового P, а старый остаётся с P.

Типичные ошибки кандидатов

  1. Считать, что P — это поток.
    P — не поток, это контекст с очередью и кешем, который даёт право M выполнять код. Без P M может только выполнять код runtime на g0.
  2. Устанавливать GOMAXPROCS > числа ядер без профилирования.
    Иногда это может улучшить производительность при большом количестве блокировок на ввод-вывод, но обычно приводит к избыточному переключению контекста. Нужны бенчмарки.
  3. Забывать, что P не разделяется между M.
    Один P в один момент времени может обслуживать только один M. Если M заблокирован в syscall, P освобождается для другого M — это ключ к параллелизму.

Возможные дополнительные вопросы интервьюера

  • Как меняется производительность при изменении количества P?
    Увеличение P может увеличить параллелизм, но также может привести к большему количеству M и contention на общих структурах. Уменьшение — сэкономит ресурсы, но снизит пропускную способность. Оптимально — по числу ядер.
  • Что такое «P-пул» и «idle P»?
    Незанятые P хранятся в списке sched.pidle и могут быть быстро захвачены пробуждающимся M.
  • В чём разница между P и горутинным контекстом?
    P содержит инфраструктуру планирования и кеши, G — состояние функции. P обслуживает G.

Как работает планировщик (scheduler)?

Краткий ответ

Планировщик Go — это userspace-механизм, мультиплексирующий горутины (G) на потоки ОС (M) через виртуальные процессоры (P). Он реализует work stealing, кооперативное и асинхронное вытеснение, обработку системных вызовов с handoff P, интегрируется с сетевым поллером и сборщиком мусора. Цель — эффективное использование CPU при минимальных задержках.

Развёрнутый ответ

Жизненный цикл планирования можно представить как цикл schedule() -> execute -> gogo -> user code -> .... Когда M готов к работе (имеет P), он вызывает schedule(), которая находит готовую горутину. Поиск происходит в порядке: runnext текущего P -> локальная очередь runq -> глобальная очередь -> work stealing от других P -> поллер сети. После выбора G, M выполняет execute(g), которая переключает контекст на эту G (gogo).

Ключевые аспекты:

  • Work stealing: если локальная очередь P пуста, он пытается украсть половину очереди другого случайного P, что балансирует нагрузку.
  • Кооперативное вытеснение: долго работающая горутина может добровольно уступить процессор в точках вызова функций (пролог) или через runtime.Gosched. Начиная с Go 1.14 добавлено асинхронное вытеснение на базе сигналов.
  • Системные вызовы: при входе в блокирующий syscall, P открепляется, и M блокируется. Новый M (или простаивающий) забирает P и продолжает выполнять другие горутины.
  • Сетевой поллер: сетевые операции, которые могут заблокироваться, помещаются в epoll/kqueue. Горутина приостанавливается, а когда сокет готов, поллер делает G снова готовой.
  • Глобальная очередь: используется, когда локальная заполнена, или периодически для предотвращения голодания.

Под капотом (internals)

Файл runtime/proc.go содержит сердце планировщика. Функция schedule() примерно:

  1. Проверяет, нужно ли выполнить GC.
  2. Если есть закреплённая за M горутина (lockedg), запускает её.
  3. Вызывает findrunnable() — основной поиск работы.
  4. После получения G, если G заблокирована на M (LockOSThread), M должен быть тем же; иначе передаёт выполнение в execute.

findrunnable() реализует описанный выше алгоритм с таймаутами и проверкой глобальной очереди каждые 61 раз (константа sched.runqsize для предотвращения монополизации). Для кражи используется функция runqsteal, которая копирует половину элементов из очереди другого P. Всё это работает без блокировок на большей части пути (благодаря lock-free структурам или владению P).

Система сигналов для асинхронного вытеснения: sysmon отправляет сигнал SIGURG потоку, который на планировщике установил обработчик. Обработчик меняет флаг preempt у G, и при следующем расширении стека или проверке безопасной точки происходит переключение.

Типичные ошибки кандидатов

  1. Непонимание асинхронного вытеснения.
    Старое утверждение «Go использует только кооперативную многозадачность» неверно с 1.14. Нужно знать про async preemption.
  2. Путать блокирующие и неблокирующие системные вызовы.
    Например, read из сокета в неблокирующем режиме обрабатывается netpoller’ом, а файловый read без O_NONBLOCK – блокирует M и вызывает handoff.
  3. Игнорировать runtime.GOMAXPROCS при настройке планировщика.
    Для CPU-bound задач увеличение P сверх ядер может дать прирост только при правильном work stealing, но часто приводит к потерям.

Возможные дополнительные вопросы интервьюера

  • Что будет, если все горутины заблокированы в системных вызовах?
    Планировщик будет создавать новые M для обслуживания P (если не достигнут лимит). Если лимит исчерпан, приложение может зависнуть из-за нехватки потоков. Это известная проблема с cgo и блокирующими вызовами.
  • Как планировщик обрабатывает горутину, которая в бесконечном цикле?
    Асинхронное вытеснение прервет её через ~10 мс (активируется sysmon), и она будет снята с исполнения, давая шанс другим горутинам.

Что такое work stealing?

Краткий ответ

Work stealing — это алгоритм балансировки нагрузки, при котором простаивающий виртуальный процессор (P) пытается «украсть» горутины из очереди другого случайного P. Это позволяет равномерно распределять работу по ядрам без централизованного диспетчера, снижая contention и накладные расходы на синхронизацию.

Развёрнутый ответ

В модели GMP каждый P имеет свою локальную очередь готовых горутин (runq). Когда P исчерпывает все свои G (включая runnext и локальную очередь), он переходит в режим поиска работы. Вместо того чтобы сразу идти в глобальную очередь, P пытается украсть горутины у другого P.

Алгоритм поиска в findrunnable:

  1. Проверить runnext.
  2. Проверить локальную runq.
  3. Проверить глобальную очередь (с определённой периодичностью).
  4. Попытаться украсть из очереди случайного P.
  5. Проверить netpoller.
  6. Если работы нет — парковка M.

Кража выполняется функцией runqsteal(p2 *p, stealRunNextG bool) (но обычно используется runqgrab, которая берёт половину очереди). Выбор жертвы — случайный p из массива allp, начиная со случайного индекса. Кража реализована атомарно с помощью atomic-операций над локальной очередью P (которая является lock-free кольцевым буфером). Благодаря этому P-владелец не блокируется при пополнении очереди, а ворующий P справляется без глобальных блокировок.

Work stealing позволяет добиться почти линейной масштабируемости на многопроцессорных системах при большом числе короткоживущих горутин. Это также предотвращает ситуацию, когда один P перегружен, а другие простаивают.

Под капотом (internals)

Очередь runq размером 256, с указателями runqhead и runqtail. Владеющий P добавляет G в хвост атомарно, ворующий P читает из головы. Чтобы оба могли работать без блокировок, операции используют атомарные загрузки/сохранения и синхронизацию через memory barriers. Конкретно runqsteal читает runqhead, вычисляет середину очереди и копирует элементы в свой массив, атомарно обновляя runqhead. Если в очереди меньше двух элементов, воруется runnext (который защищён атомарным CAS).

Код: runtime/proc.go:runqgrab, runqsteal. Вот упрощённый псевдокод:

Go
func runqgrab(p *p, batch *[256]*g) uint32 {
    for {
        h := atomic.Load(&p.runqhead)
        t := atomic.Load(&p.runqtail)
        n := t - h
        if n == 0 {
            return 0
        }
        half := n / 2
        // copy half from p.runq[h%256 ...]
        if atomic.Cas(&p.runqhead, h, h+half) {
            return half
        }
    }
}

Успешная кража возвращает количество украденных G, и P планировщика добавляет их в свой runq.

Типичные ошибки кандидатов

  1. Предположение, что work stealing вызывает много contention.
    Наоборот, это lock-free алгоритм с редкими CAS. Конфликты возникают, только если много P одновременно воруют у одного, что маловероятно благодаря рандомизации жертвы.
  2. Путать глобальную очередь и локальную при краже.
    Крадут всегда из локальной очереди другого P; глобальная очередь периодически проверяется отдельно. Это обеспечивает локальность.
  3. Незнание про runnext при краже.
    runnext может быть украден только если локальная очередь пуста (специальный случай), что оптимизирует повторный запуск.

Возможные дополнительные вопросы интервьюера

  • Почему не используют централизованный планировщик как в старой модели Go (GM)?
    Централизованный приводил к серьёзному contention на мьютексе глобальной очереди и плохой локальности. GMP с work stealing решил это.
  • Как часто P пытается украсть?
    При каждом вызове findrunnable, т.е. когда локальная очередь пуста. Может быть несколько попыток с рандомным выбором.

Как обрабатываются системные вызовы (syscalls)?

Краткий ответ

Системные вызовы в Go разделяются на блокирующие и неблокирующие. Неблокирующие выполняются быстро, и горутина продолжает работу. При блокирующем вызове рантайм открепляет P от M, позволяя P продолжить выполнять другие горутины на другом M. Заблокированный M ожидает завершения syscall, после чего горутина становится готовой, а M пытается вернуть P или паркуется.

Развёрнутый ответ

Go оборачивает системные вызовы в код из пакета syscall или рантайма (через runtime.entersyscall и runtime.exitsyscall). Процесс:

  1. Вход в syscall: горутина вызывает runtime.entersyscall(), который сохраняет состояние, переводит M в syscall-статус и открепляет P от M (если syscall блокирующий). P освобождается и передаётся другому M через handoff.
  2. Исполнение: M выполняет инструкцию syscall в ядре. Это блокирует поток до завершения. Пока M заблокирован, он не потребляет CPU.
  3. Выход: runtime.exitsyscall() проверяет, есть ли у старого P свободный статус. Если P ещё не занят, M забирает его обратно и продолжает выполнять горутину. Если P уже передан другому M, текущий M пытается найти любой свободный P (exitsyscallfast). Если свободных нет, горутина помещается в очередь выполнения, а M паркуется.

Для неблокирующих вызовов (например, read на неблокирующем сокете) путь короче: если вызов возвращает EAGAIN, Go передаёт управление netpoller'у и не блокирует M.

Под капотом (internals)

В runtime/proc.go ключевые функции:

  • entersyscall() — атомарно меняет статус M на _Msyscall, сохраняет oldp.
  • entersyscallblock() — то же, но с обязательным handoff.
  • handoffp(p) — ищет новый M для P, возможно, создавая поток.
  • exitsyscall() — пытается быстро восстановиться (exitsyscallfast), если нет, вызывает exitsyscall0.

M, возвращаясь из syscall, может обнаружить, что его старый P уже занят (p.status == _Prunning). Тогда он ищет другой свободный P в sched.pidle. Если не находит — вызывает stopm() для парковки. Важно: горутина, вышедшая из syscall, становится готовой к выполнению, но может не сразу получить CPU, если P заняты.

Sysmon (монитор) следит за M, которые застряли в syscall дольше определённого времени (10 мс), и принудительно выполняет handoff P, чтобы избежать голодания.

Типичные ошибки кандидатов

  1. Путать блокирующие системные вызовы с сетевыми.
    Сетевые операции в Go по умолчанию неблокирующие, они используют netpoller. Блокировка M происходит только для чистых блокирующих syscall (файловый I/O без O_NONBLOCK, futex, некоторые cgo-вызовы).
  2. Предполагать, что при блокировке syscall M не освобождает P.
    Освобождает обязательно — это основа масштабируемости. Иначе один блокирующий вызов заблокировал бы целый P.
  3. Забывать о возможности нехватки M.
    Если все потоки заняты блокирующими syscall и достигнут максимум, новый handoff не создаст M, и P может остаться без потока, что вызовет зависание. Это нужно учитывать при работе с cgo.

Возможные дополнительные вопросы интервьюера

  • Как планировщик узнаёт, что системный вызов блокирующий?
    Рантайм заранее маркирует обёртки: syscall.Syscall по умолчанию считается блокирующим, syscall.Syscall6 и т.д. Для сетевых вызовов используется netpoll.
  • Что такое sysmon и как часто он работает?
    Специальный поток, запускаемый без P. Он мониторит длительные syscall-ы, сетевые ожидания, перебалансировку и вытеснение. Цикл с переменным сном.

Что происходит при блокирующем системном вызове (blocking syscall)?

Краткий ответ

При блокирующем системном вызове горутина, её M и G временно отдают P, который переходит другому M. Выполняющаяся горутина блокируется в ядре, M ждёт завершения вызова. Как только syscall завершён, горутина снова становится готовой, и M либо возвращает себе старый P, либо получает новый, либо паркуется, а горутина помещается в очередь.

Развёрнутый ответ

Блокирующий системный вызов — это, например, file.Read() на обычный файл без флага O_NONBLOCK. Поток выполнения в ядре может заснуть, ожидая данных. В Go это обрабатывается так:

  1. Перед вызовом entersyscallblock() (или entersyscall) – статус M меняется, P открепляется.
  2. Рантайм вызывает handoffp(p), который находит простаивающий M или создаёт новый, чтобы тот взял P и продолжил работу с другими горутинами. Тем самым параллелизм не страдает.
  3. Заблокированный M останавливается до тех пор, пока syscall не вернёт управление. CPU не расходуется.
  4. При возврате из ядра вызывается exitsyscall. M проверяет, свободен ли его старый P. Если да — забирает и продолжает выполнять ту же горутину без постановки в очередь. Если P занят — горутина становится Grunnable и помещается в очередь (глобальную или локальную, в зависимости от заполненности), а M паркуется, если нет другого P.

Таким образом, блокировка потока не блокирует остальные горутины на этом логическом процессоре, потому что P быстро переназначается.

Под капотом (internals)

Функции:

  • entersyscallblock() сразу делает handoff: handoffp(releasep()).
  • handoffp вызывает startm(p, false) для запуска M с P. Если спящих M нет, создаётся новый через newm.
  • exitsyscall(): быстрый путь — M пытается атомарно забрать старый P (exitsyscallfast). Если P ещё _Psyscall, он забирается. Иначе M вызывает exitsyscall0, который делает G runqput и вызывает stopm для парковки.

В Linux создание потока — clone с CLONE_VM и т.д. В Go ограничение потоков по умолчанию 10000, после чего startm паникует.

Пример кода:

Go
func blockingRead(fd int, buf []byte) (int, error) {
    // syscall.Read обёрнут entersyscall/ exitsyscall
    return syscall.Read(fd, buf)
}

Пользовательский код этого не видит, всё прозрачно.

Типичные ошибки кандидатов

  1. Полагать, что блокирующий вызов в горутине блокирует только эту горутину и никак не влияет на другие.
    Влияет на потоки: если много горутин выполняют блокирующий syscall, может кончиться лимит потоков, и новые не смогут обслужить P — производительность упадёт.
  2. Забывать про возможность открепления P и считать, что горутина на syscall всё ещё занимает P.
    Нет, P сразу передаётся другому M. Это ключевое свойство.
  3. Игнорировать накладные расходы на частые блокирующие вызовы.
    Частый handoff и создание потоков могут ухудшить производительность. Лучше использовать неблокирующий I/O или netpoller.

Возможные дополнительные вопросы интервьюера

  • Что такое handoff и как он реализован?
    Передача P от одного M другому: текущий M вызывает handoffp(p), который пробуждает спящий M или создаёт новый, передаёт ему P и запускает планировщик.
  • Как отследить количество потоков, созданных из-за блокирующих вызовов?
    Метрика go_threads из runtime/debug. Резкий рост — симптом проблемы.

Что такое вытеснение (preemption)?

Краткий ответ

Вытеснение — это механизм, с помощью которого планировщик Go принудительно прерывает выполнение текущей горутины, чтобы передать управление другим, даже если она не вызывает функции, допускающие кооперативную передачу. Начиная с Go 1.14 реализовано асинхронное вытеснение на основе сигналов (async preemption), что гарантирует отзывчивость для tight loops и предотвращает зависания.

Развёрнутый ответ

Изначально Go использовал только кооперативное вытеснение: горутина могла быть вытеснена лишь в определённых точках — вызов функций (с проверкой стека), канальные операции, блокировки. Бесконечный цикл без вызовов мог навсегда монополизировать P. Для решения добавили асинхронное вытеснение.

Механизм:

  • Специальный поток sysmon проверяет, как долго горутина выполняется на каждом P (переменная schedtick). Если больше определённого времени (обычно ~10 мс), он отправляет потоку (M) сигнал SIGURG.
  • Сигнал обрабатывается в рантайме на стеке сигналов, который устанавливает флаг preempt у текущей G.
  • При следующей проверке безопасных точек (например, при входе в функцию, увеличении стека или явной проверке) горутина видит флаг и вызывает runtime.Gosched (точнее, переходит в планировщик через morestack).
  • Так выполнение прерывается, и горутина помещается обратно в очередь, давая шанс другим.

Асинхронное вытеснение не требует модификации существующего кода и прозрачно для пользователя.

Под капотом (internals)

В runtime/signal_unix.go: обработчик sighandler для sigPreempt (_SIGURG). Он вызывает doSigPreempt, которая сохраняет контекст сигнала в g.sigcode0/sigcode1, и устанавливает g.preempt = true. Далее при выполнении кода, если горутина проходит функцию пролога (stack growth), вызывается runtime.morestack. morestack проверяет g.preempt и, если true, вместо расширения стека вызывает runtime.schedule (через goschedImpl). Это переключает горутину.

Проверка флага также внедрена в некоторые длительные операции внутри runtime, например, в циклы memmove или memset, если длина большая. Компилятор также вставляет проверки в некоторые точки (с версии 1.19 улучшено).

Sysmon каждые 10 мкс-1 мс проверяет все P. Если p.schedtick увеличился (горутина всё ещё выполняется) и прошло ~10 мс, вызывается preemptone(p), который отправляет сигнал потоку, если P не в syscall и не idle.

Типичные ошибки кандидатов

  1. Говорить, что Go всё ещё полностью кооперативный.
    Это устаревшая информация. Нужно подчеркнуть async preemption с 1.14+.
  2. Путать вытеснение с системным прерыванием.
    Это userspace-сигнал, не прерывание таймера ядра. Сигнал SIGURG не используется ОС для ошибок.
  3. Полагать, что асинхронное вытеснение может прервать любую инструкцию.
    Оно прерывает в безопасных точках, где состояние горутины согласовано (стек, регистры). Нельзя прервать атомарную операцию или посреди записи в стек.

Возможные дополнительные вопросы интервьюера

  • Влияет ли async preemption на производительность?
    Минимально: сигналы доставляются редко, а обработчик написан эффективно. Для CPU-bound кода может сыграть положительно, улучшив fairness.
  • Можно ли отключить вытеснение?
    Только для отдельных горутин, отключив GC (в unsafe коде) или используя cgo с осторожностью. Но в обычном Go нет способа полностью запретить.

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

Краткий ответ

Кооперативная многозадачность — это подход, при котором горутина сама должна добровольно уступить управление, достигнув определённой точки (вызов функции, операция с каналом, системный вызов и т.д.). В Go, до введения асинхронного вытеснения, это был основной механизм. Сейчас он по-прежнему используется для быстрых переходов без сигналов.

Развёрнутый ответ

В ранних версиях Go (до 1.14) планировщик полагался исключительно на кооперацию: горутина могла быть переключена только когда она вызывала функцию, входила в syscall, делала канальную операцию или явно вызывала runtime.Gosched(). Компилятор вставлял проверки на расширение стека в прологе каждой функции, что служило точками переключения. Такой подход прост и эффективен, но имеет фатальный недостаток: горутина, выполняющая плотный цикл без вызовов (например, for { i++ }), никогда не отдаст управление, монополизируя поток.

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

Таким образом, современный планировщик Go — гибридный: кооперативный в большинстве случаев и принудительный (async preemption) для предотвращения голодания.

Под капотом (internals)

Ключевой механизм кооперативного переключения — runtime.morestack. При вызове любой функции компилятор вставляет пролог, который проверяет, достаточно ли места в стеке. Если нет — вызывает morestack, который может расширить стек, а также проверяет флаг вытеснения. Если флага нет, просто выделяется стек. При кооперативной передаче горутина, например, блокируется на канале: chansend вызывает gopark, который сохраняет контекст и передаёт управление планировщику. Аналогично runtime.Gosched реализован как mcall(gosched_m), который вручную переключается на g0 и в планировщик.

Все эти точки — это кооперативная передача, и они составляют большинство переключений в типичном Go-приложении, работающем с сетью и каналами.

Типичные ошибки кандидатов

  1. Путать кооперативное и асинхронное вытеснение, утверждая, что Go только кооперативный.
    Стоит чётко разделять: кооперация – добровольно, асинхронное – принудительно через сигнал.
  2. Считать, что кооперативная модель устарела и полностью заменена.
    Она по-прежнему основной «движок» переключений; async preemption — страховочный механизм.
  3. Не понимать, почему кооперативное переключение быстрее.
    Потому что не нужно доставлять сигнал, сохранять контекст сигнала и восстанавливать. Это просто вызов функции.

Возможные дополнительные вопросы интервьюера

  • Как часто происходит кооперативное переключение?
    Зависит от кода, но в типичном сетевом сервере тысячи раз в секунду, потому что каждая операция с каналом или сетью вызывает планировщик.
  • Можно ли написать горутину, которая никогда не переключится кооперативно?
    Да, tight loop без вызовов функций — до 1.14 она висела бы навсегда. Сейчас выручает асинхронное вытеснение.

Что такое асинхронное вытеснение (async preemption)?

Краткий ответ

Асинхронное вытеснение — механизм в Go (с версии 1.14), позволяющий принудительно прервать горутину, даже если она находится в бесконечном цикле без вызовов функций. Реализовано через отправку сигнала SIGURG потоку ОС, который обрабатывается рантаймом, устанавливая флаг preempt, что заставляет горутину передать управление планировщику при следующей безопасной точке.

Развёрнутый ответ

Цель — улучшить отзывчивость и избежать зависаний из-за долгих циклов. До 1.14 существовало только кооперативное вытеснение, и tight loop мог «подвесить» программу. Асинхронное вытеснение решает это, используя сигналы. Механизм:

  1. sysmon отслеживает, сколько тактов планировщика (p.schedtick) прошло с момента последнего переключения на данном P. Если горутина выполняется дольше ~10 мс, sysmon вызывает preemptone(p).
  2. preemptone с помощью атомарной операции пытается взвести флаг preempt в M и отправляет сигнал SIGURG потоку, ассоциированному с M.
  3. Обработчик сигнала в Go (sighandler) проверяет, что сигнал от рантайма, и вызывает doSigPreempt, которая сохраняет текущий контекст и устанавливает g.preempt = true.
  4. После возврата из обработчика сигнала выполнение продолжается с того же места. Но при ближайшей проверке безопасной точки (чаще всего при следующем прологе функции или явной проверке в цикле runtime) горутина обнаруживает флаг и добровольно переходит в планировщик (goschedImpl).

Этот подход не нарушает работу программы, так как прерывание происходит только в точках, где горутина находится в согласованном состоянии, и не в середине записи в объекты.

Под капотом (internals)

Основные структуры и файлы:

  • runtime/signal_unix.go: sighandler, sigPreempt.
  • runtime/preempt.go: canPreemptM (проверка, можно ли прервать M), preemptM.
  • runtime/proc.go: preemptone, sysmon.

Сигнал — _SIGURG на Linux, не используемый в обычных приложениях. При получении обработчик вызывается на стеке сигнала ОС (altstack), а не на стеке горутины, что важно для безопасности. После установки флага, при возврате из сигнала, если горутина была в безопасной точке (например, не в атомарной операции), планировщик переключается на следующую горутину.

Безопасные точки — это инструкции, где известны все указатели и стек можно точно просканировать (для GC). Компилятор пометит список безопасных точек в таблице pcdata, и рантайм может проверить, можно ли прерывать. В Go 1.19+ количество безопасных точек увеличили, в том числе внутри некоторых runtime-функций.

sysmon запускается как отдельный поток без P, в цикле с usleep. При проверке P, если прошло > forcePreemptNS (10 мс) и P в состоянии _Prunning, вызывается preemptone.

Типичные ошибки кандидатов

  1. Считать, что асинхронное вытеснение прерывает горутину мгновенно в любой инструкции.
    Нет, оно ждёт безопасной точки — следующей проверки. В плотном цикле без вызовов функций на ассемблере без safe-points прерывание может задержаться.
  2. Не знать, какой сигнал используется.
    SIGURG — это интересный момент, его можно упомянуть.
  3. Путать async preemption с вытеснением ОС.
    Это userspace-прерывание, реализованное через сигнал, а не планировщик ядра.

Возможные дополнительные вопросы интервьюера

  • Можно ли писать код, не допускающий асинхронного вытеснения?
    Да, критически важные участки на runtime.LockOSThread и с блокировкой сигналов, но это опасно. Обычно рантайм сам определяет безопасные точки.
  • Что такое safe-points и где они размещаются?
    Места, где компилятор гарантирует, что все указатели доступны для GC. Обычно прологи функций, циклы с определёнными условиями. Go добавляет их даже в некоторые циклы runtime.

Как работает runtime.GOMAXPROCS?

Краткий ответ

runtime.GOMAXPROCS(n) устанавливает количество виртуальных процессоров P, которые одновременно могут выполнять горутины. По умолчанию равно числу логических ядер ЦП. Изменение в рантайме перераспределяет P (добавляет или приостанавливает лишние) и влияет на степень параллелизма.

Развёрнутый ответ

Функция GOMAXPROCS определяется в runtime/debug.go. При вызове она атомарно меняет глобальную переменную sched.maxmcount? На самом деле устанавливает sched.nprocs (количество желаемых P) и вызывает procresize(n), который согласовывает фактический массив P с новым значением. Если новое значение больше старого, создаются и инициализируются новые P, которые добавляются в пул холостых. Если меньше — лишние P переводятся в статус _Pdead и их локальные очереди горутин перемещаются в глобальную очередь, чтобы не потерять работу.

В контейнерной среде часто нужно ограничивать GOMAXPROCS числом доступных ядер, иначе могут возникнуть излишние переключения контекста. Go 1.5+ автоматически учитывает лимиты cgroups (через readUint из /sys/fs/cgroup), но не всегда корректно при --cpuset-cpus. Лучше явно задавать через переменную окружения GOMAXPROCS или runtime.GOMAXPROCS.

Под капотом (internals)

Основные структуры: sched.nprocs, глобальный массив P allp. procresize — ключевая функция (runtime/proc.go). Примерный алгоритм:

  1. Блокирует все P.
  2. Если new > old: инициализирует новые P (аллоцирует runq, mcache и т.д.), присваивает им статус _Pidle.
  3. Если new < old: для лишних P вызывает releasep() и переводит в _Pdead, их локальные очереди перемещаются в глобальную (globrunqputbatch).
  4. Перенастраивает массив allp.
  5. Обновляет sched.nprocs.
  6. Разблокирует.

Изменения вступают в силу мгновенно для будущих планировок. Текущие запущенные горутины не прерываются, но P сверх лимита завершат выполнение и освободятся.

За автоматическое определение в cgroup отвечает код в runtime/os_linux.go: getproccount смотрит cpu.cfs_quota_us и cpu.cfs_period_us. Однако этот метод не учитывает cpuset.cpus, поэтому GOMAXPROCS может превысить реальное число доступных ядер, что ухудшит производительность из-за оверкоммита.

Типичные ошибки кандидатов

  1. Устанавливать GOMAXPROCS > числа ядер без бенчмарков.
    Увеличение может помочь при большом количестве блокирующих операций, но обычно вызывает contention за системные ресурсы. Нужны метрики.
  2. Забывать про автоматическое определение в контейнере.
    Полагаться на автоопределение не всегда корректно. Лучше явно задавать через automaxprocs (пакет go.uber.org/automaxprocs).
  3. Игнорировать влияние на сборщик мусора.
    Количество P также влияет на число горутин, помогающих в параллельной фазе GC. При малом P GC может замедлиться.

Возможные дополнительные вопросы интервьюера

  • Как динамическое изменение GOMAXPROCS влияет на выполняющиеся горутины?
    Работающие горутины не прерываются. Новые P могут сразу начать принимать работу, лишние завершат обслуживание и умрут.
  • Что выдаст runtime.GOMAXPROCS(0)?
    Вернёт текущее значение, ничего не меняя. Это идиоматический способ получить количество P.

Может ли горутина бесконечно занимать поток?

Краткий ответ

Теоретически, без асинхронного вытеснения да (tight loop). Но начиная с Go 1.14 асинхронное вытеснение гарантирует, что горутина будет прервана примерно каждые 10 мс. Однако существуют граничные случаи: если горутина отключит вытеснение (например, в cgo без проверок), она может занять поток навсегда.

Развёрнутый ответ

До async preemption горутина с бесконечным циклом for {} без вызовов функций навечно захватывала M и P, что приводило к зависанию планировщика. Сейчас sysmon отправляет сигнал SIGURG, который принудительно снимает горутину. Но есть ситуации, где это не срабатывает:

  • Код на C через cgo, который не проверяет точки вытеснения и блокирует M.
  • Некоторые участки runtime (например, сборка мусора в определённых фазах) временно запрещают вытеснение.
  • Если горутина находится в бесконечном цикле на ассемблере без safe-points, но компилятор Go вставляет проверки даже в ассемблерные функции определённого типа.

Таким образом, ответ — «может, но только в особых обстоятельствах и не в обычном Go-коде».

Под капотом (internals)

sysmon проверяет P, выполняющий горутину дольше 10 мс, и отправляет сигнал M. Если M в cgo или в безопасном регионе, где вытеснение отложено, доставка сигнала может быть отложена. Однако даже в cgo можно использовать runtime.pthread_create и механизмы сигналов — но не всегда.

Если горутина выполняет бесконечный цикл, содержащий хотя бы одну функцию (например, fmt.Println), она будет кооперативно переключаться при вызове, и проблема снимается.

В коде на Go без unsafe и cgo бесконечный захват невозможен.

Типичные ошибки кандидатов

  1. Ответ «да, всегда», имея в виду старые версии.
    Важно упомянуть асинхронное вытеснение.
  2. Не знать об исключениях (cgo, отключение preemption).
    Интервьюер проверит глубину понимания.
  3. Смешивать с блокировкой в системном вызове.
    Там P освобождается, так что другие горутины работают.

Возможные дополнительные вопросы интервьюера

  • Что будет, если горутина войдёт в бесконечный цикл на C без вызова Go-функций?
    Она заблокирует M, и если это единственный M для P, P не сможет обрабатывать другие горутины, пока поток не вернётся.
  • Как отследить долго выполняющиеся горутины?
    Scheduler trace или pprof с таймлайн покажут непрерывно работающую горутину.

Что такое "локальные" и "глобальные" очереди выполнения?

Краткий ответ

Локальная очередь — это массив runq размера 256, привязанный к конкретному P, безлоковая кольцевая очередь, в которую P помещает новые горутины. Глобальная очередь — это общий список (sched.runq), куда горутины попадают при переполнении локальной очереди или при вытеснении. Планировщик проверяет глобальную очередь периодически для предотвращения голодания.

Развёрнутый ответ

В GMP-модели локальная очередь обеспечивает локальность и снижает синхронизацию. P управляет ею монопольно, поэтому добавление и извлечение быстрые и не требуют атомарных операций в большинстве случаев. Максимальный размер — 256. Если при создании горутины локальная очередь заполнена, она помещается в глобальную очередь (с атомарными операциями), а часть локальной очереди может быть передана в глобальную, чтобы освободить место.

Глобальная очередь — это lock-free односвязный список или очередь с мьютексом? В текущей реализации sched.runq — это головная горутина и хвост, используется мьютекс sched.lock для доступа, но он удерживается очень недолго из-за пакетной вставки и извлечения. Планировщик обращается к глобальной очереди каждые ~61 планирование (счётчик schedtick%61==0), а также когда локальная пуста и work stealing не дал результата.

Такая архитектура позволяет избежать централизованного узкого места и даёт хорошее распределение работы.

Под капотом (internals)

Структура P: runqhead, runqtail, runq [256]guintptr. Добавление в локальную очередь: runqput — запись в runq[tail%256], атомарное увеличение tail. Извлечение: runqget — из головы, но обычно P забирает сам у себя без атомиков, потому что он единственный потребитель. Исключение — кража другим P, которая требует атомарных операций.

Глобальная очередь: sched.runqhead, sched.runqtail, связный список через g.schedlink. Доступ через globrunqput и globrunqget, которые захватывают sched.lock только на время модификации списка, часто с пакетной вставкой (globrunqputbatch).

При помещении G в глобальную очередь, если в локальной нет места, планировщик может сбросить половину локальной очереди в глобальную, чтобы поддержать баланс.

Типичные ошибки кандидатов

  1. Неверно говорить, что глобальная очередь используется всегда для всех горутин.
    Она вовлекается только при переполнении или вытеснении, большинство операций локальны.
  2. Считать локальную очередь полностью безлоковой при краже.
    При краже используются атомарные CAS, но без блокировок спина, благодаря разделению потребителей.
  3. Путать runqput и runqputslow.
    runqputslow вызывается, когда локальная очередь переполнена, и выполняет сброс в глобальную.

Возможные дополнительные вопросы интервьюера

  • Почему локальная очередь размером именно 256?
    Компромисс между кеш-локальностью и overhead кражи. Слишком маленькая — частые глобальные операции, слишком большая — неэффективная кража.
  • Какова временная сложность вставки и извлечения?
    O(1) амортизированное практически всегда.

Как происходит рождение и завершение горутины?

Краткий ответ

Рождение: go func() компилируется в вызов runtime.newproc, который создаёт структуру G, выделяет стек, копирует аргументы, устанавливает PC на функцию и помещает G в очередь выполнения (локальную или глобальную). Завершение: по выходе из функции вызывается runtime.goexit, который переводит G в состояние _Gdead, освобождает стек и помещает G в пул свободных для переиспользования.

Развёрнутый ответ

При компиляции вызов go f(args) превращается в:

TEXT
newproc(fn, args)

где fn — указатель на функцию. newproc получает размер аргументов, создаёт G через gfget (из пула) или malg, если пул пуст. Стек выделяется stackalloc, начальный размер — обычно 2 КБ (зависит от платформы). Аргументы копируются на стек горутины (потому безопасны даже после выхода из области видимости). В поле sched.pc записывается адрес runtime.goexit, а в стек помещается адрес runtime.main для точки старта.

Затем runqput помещает G в очередь P (локальную, в runnext если возможно). Позже планировщик находит G и запускает через gogo. Когда функция горутины завершается, выполняется runtime.goexit (установленный как адрес возврата), который вызывает runtime.Goexit? На самом деле после возврата из функции управление передаётся на runtime.goexit, который выполняет mcall(goexit0). goexit0 переводит G в _Gdead, снимает её с M, освобождает стек (или помещает в стек-пул), сбрасывает состояние и кладёт G в gFree текущего P. Если в gFree накопилось много, часть возвращается в глобальный пул.

Под капотом (internals)

Код в runtime/proc.go:

  • newproc -> newproc1.
  • newproc1 аллоцирует G, вызывает gfget -> если нет, malg(stacksize).
  • Копирование аргументов: memmove на стек новой G.
  • Установка g.startpc и sched.pc = abi.FuncPCABI0(goexit).
  • Вызов runqput.

Завершение: goexit (ассемблерная метка) -> goexit1 -> mcall(goexit0).
goexit0 освобождает стек через stackfree, обнуляет поля, вызывает gfput для возврата G в список свободных. Может вызвать schedule для переключения.

Типичные ошибки кандидатов

  1. Путать goexit и Goexit.
    runtime.Goexit — функция для принудительного завершения горутины (но не паники), goexit — автоматический переход при выходе из верхней функции.
  2. Неверно говорить, что горутина никогда не завершится, если не вызвать return.
    При Goexit можно завершить, но обычно return из функции.
  3. Считать, что G всегда аллоцируется заново.
    Существует пул gFree для переиспользования, сильно снижая накладные расходы.

Возможные дополнительные вопросы интервьюера

  • Можно ли переиспользовать стек завершённой горутины?
    Да, стеки помещаются в кеш stackpool и могут быть использованы другими G, что снижает аллокации.
  • Что будет с defer-ами при завершении горутины?
    При нормальном выходе они выполняются, как обычно. При Goexit тоже выполняются все отложенные функции.

Что такое "sysmon" и как он связан с планировщиком?

Краткий ответ

Sysmon — это специальный поток, создаваемый рантаймом, который работает без P и не подчиняется планировщику горутин. Он выполняет мониторинговые функции: вытеснение долго работающих горутин, handoff P при зависших системных вызовах, проверку сетевого поллера, управление таймерами и мониторинг потоков.

Развёрнутый ответ

sysmon запускается в функции runtime.main и работает в бесконечном цикле. Его задачи:

  • Вытеснение: проверяет все P и если горутина на P выполняется дольше 10 мс без переключения, отправляет сигнал для async preemption.
  • Handoff P при блокирующем syscall: если P в _Psyscall дольше ~10 мс, sysmon выполняет handoff — передаёт P другому M, чтобы тот не простаивал.
  • Netpoller: вызывает netpoll(false) для проверки готовых сокетов и добавляет соответствующие G в очередь выполнения.
  • Таймеры: обрабатывает таймеры (checkTimers), если нет других активных P, работающих с ними.
  • Мониторинг потоков: подсчитывает холостые потоки и может уведомлять о необходимости освобождения.

Без P sysmon не может выполнять обычный Go-код, только функции рантайма. Это позволяет ему работать, даже когда все P заняты или заблокированы.

Под капотом (internals)

Создание: runtime.newm(sysmon, nil, -1). Поток не привязывается к P и имеет только g0 (системный стек). В цикле:

Go
for {
    if idle == 0 {
        delay = 20
    } else if idle > 50 {
        delay = 0
    }
    usleep(delay)
    ...
    checkdead()
    ...
}

Длительность сна варьируется от 20 мкс до 10 мс в зависимости от загрузки системы. checkdead проверяет, не зависло ли приложение (все горутины заблокированы).

Вытеснение: if netpollinited() { list := netpoll(false) } — забрать готовые G. retake — проверка всех P на предмет preemption и handoff.

Типичные ошибки кандидатов

  1. Считать sysmon частью планировщика, имеющей P.
    У него нет P, он отдельный поток вне GMP.
  2. Не знать про handoff в syscall через sysmon.
    Sysmon принудительно отбирает P у M, зависшего в syscall, если тот не сделал это сам.
  3. Путать sysmon и netpoller.
    Sysmon вызывает netpoller, но сам не является им.

Возможные дополнительные вопросы интервьюера

  • Что будет, если sysmon не работает?
    Вытеснение и handoff сломаются, потенциальны зависания. Но он всегда запущен.
  • Как sysmon взаимодействует с таймерами?
    Вызывает checkTimers и может запускать готовые таймеры в глобальной очереди.

Как планировщик обрабатывает сетевые вызовы (netpoller)?

Краткий ответ

Сетевые операции в Go (чтение/запись на сокет) используют неблокирующий ввод-вывод в сочетании с механизмом мультиплексирования ОС (epoll на Linux, kqueue на BSD). Когда операция вернула бы блокировку, горутина передаёт файловый дескриптор netpoller'у и приостанавливается (gopark). Когда сокет становится готовым, netpoller пробуждает горутину и помещает её в очередь выполнения.

Развёрнутый ответ

Netpoller — это компонент рантайма, реализованный в runtime/netpoll.go. Он абстрагирует системный вызов ожидания событий. Внутренний цикл:

  • Пользовательская горутина вызывает, например, conn.Read(), которая внутри делает системный вызов read в неблокирующем режиме (сокет установлен в O_NONBLOCK).
  • Если вызов возвращает EAGAIN, горутина вызывает netpollblock, который регистрирует дескриптор в poller (через epoll_ctl), переводит G в состояние ожидания (gopark) и планировщик переключается на другую горутину.
  • Когда данные приходят, ядро уведомляет epoll. sysmon или планировщик в findrunnable вызывает netpoll(false/true), который опрашивает epoll (epoll_wait с таймаутом 0), получает список готовых дескрипторов, находит ассоциированные G и делает их Grunnable, помещая в очередь выполнения.

Таким образом, ни одна горутина не блокируется, ожидая сеть, и не тратится поток ОС.

Под капотом (internals)

Структура pollDesc содержит ссылку на горутину, ожидающую чтение/запись. Реализация в internal/poll и рантайме.

Когда горутина блокируется:

  • fd.pd.wait(mode) вызывает runtime.pollWait -> netpollblock -> сохраняет G в pd.rg или pd.wg и делает gopark.

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

  • Функция netpoll(block bool) вызывается в нескольких точках: sysmon (неблокирующий), findrunnable (блокирующий с таймаутом), при выходе из syscall, при завершении schedule.
  • netpoll делает epollwait(0) (если block=false), получает события, для каждого вызывает netpollready, который делает G готовой и помещает в очередь.

Интеграция с findrunnable: перед парковкой M, findrunnable вызывает netpoll(true) с таймаутом, что позволяет не плодить потоки и эффективно ожидать сеть прямо в планировщике.

Типичные ошибки кандидатов

  1. Думать, что сетевая горутина блокирует M.
    На самом деле M освобождается и может использоваться другими горутинами, потому что блокировка происходит только на G, а не на M.
  2. Путать netpoller с syscall.
    Сетевой syscall неблокирующий, и блокировка обрабатывается на уровне пользователя.
  3. Не знать про интеграцию netpoller в findrunnable.
    Планировщик сам опрашивает poller, не полагаясь только на sysmon.

Возможные дополнительные вопросы интервьюера

  • Что такое netpoll и когда он вызывается?
    Функция опроса epoll. Вызывается sysmon, findrunnable и некоторыми другими частями рантайма для пробуждения ожидающих горутин.
  • Как добиться максимальной производительности сетевого сервера?
    Настроить GOMAXPROCS по ядрам, избегать блокирующих операций, следить за количеством горутин.

Что такое «привязка» горутины к машине (M)?

Краткий ответ

Привязка (lock) горутины к M означает, что горутина будет выполняться только на одном конкретном потоке ОС, и планировщик не будет переносить её на другой M. Это достигается через runtime.LockOSThread(), и освобождается UnlockOSThread. Привязка необходима для взаимодействия с библиотеками, использующими thread-local storage, или для работы с некоторыми системными ресурсами.

Развёрнутый ответ

Обычно горутина может мигрировать между M, что прозрачно. Однако некоторые сценарии требуют постоянной ассоциации с потоком: например, графические библиотеки (OpenGL), cgo-код, использующий TLS, или манипуляции с сигналами. LockOSThread() гарантирует, что текущая горутина будет закреплена за текущим M до тех пор, пока не будет вызван UnlockOSThread() или горутина не завершится. При этом привязка не исключает вытеснение: горутина по-прежнему может быть приостановлена асинхронным вытеснением, но когда она возобновит выполнение, это произойдёт на том же M.

Внутренне в G устанавливается флаг lockedm, указывающий на M. Планировщик, видя, что G привязана к M, при её планировании старается передать её именно на этот M, даже если M занят или спит.

Под капотом (internals)

Поля: g.lockedm, m.lockedg. При вызове LockOSThread:

  • g.lockedm устанавливается в текущий M.
  • m.lockedg — в текущую G.
    При UnlockOSThread оба поля сбрасываются.

Планировщик в execute проверяет, закреплена ли G за другим M. Если да, и текущий M не тот, G помещается в очередь, а M пробуждает целевой M (wakeup). В findrunnable есть специальная логика: M, имеющий закреплённую G (m.lockedg), предпочитает выполнять её, даже если есть другие готовые G.

Освобождение привязки может произойти при завершении горутины автоматически.

Типичные ошибки кандидатов

  1. Забывать вызвать UnlockOSThread.
    Если горутина завершится, привязка снимется, но M остаётся «загрязнённым» с ненулевым lockedg, что может привести к проблемам при последующем использовании M. Лучше всегда разблокировать.
  2. Считать, что LockOSThread запрещает параллельное выполнение нескольких горутин на одном M.
    Нет, если на M нет закреплённой G, он может выполнять любую. LockOSThread просто гарантирует, что конкретная G всегда на конкретном M.
  3. Использовать без необходимости, полагая, что это улучшит производительность.
    Наоборот, это снижает гибкость планировщика и может привести к простою M.

Возможные дополнительные вопросы интервьюера

  • Что будет, если вызвать LockOSThread из главной горутины?
    Она закрепится за M, выполняющим main. Это может повлиять на работу рантайма, но обычно допустимо.
  • Как LockOSThread взаимодействует с netpoller?
    Если горутина закреплена и блокируется на сети, M всё равно может быть занят ожиданием? На самом деле при блокировке на сети G приостанавливается, M может выполнять другую работу, но привязанная G позже будет пробуждена на том же M? Да, планировщик гарантирует, что она возобновится на том же M.

Как runtime.LockOSThread() влияет на планировщик?

Краткий ответ

Вызов LockOSThread() фиксирует горутину на текущем потоке ОС (M). Планировщик должен гарантировать, что эта горутина всегда будет выполняться на этом M, даже если это требует пробуждения спящего M или ожидания его освобождения. Это ограничивает работу планировщика, потенциально снижая параллелизм и вызывая задержки.

Развёрнутый ответ

Когда горутина вызывает LockOSThread, она связывает себя с текущим M. После этого:

  • Если горутина блокируется (например, на канале) и M освобождается, M не может взять другую обычную горутину, пока привязанная G не завершится или не освободит привязку. Вместо этого M может перейти в спящий режим, ожидая пробуждения привязанной G.
  • Если горутина становится готовой к выполнению, но её M занят другой работой, планировщик может попытаться передать сигнал пробуждения M или даже прервать его текущую горутину, чтобы выполнить закреплённую (через механизм preemptone).
  • Создание новых горутин, закреплённых за конкретным M, создаёт дополнительную сложность управления ресурсами. Например, несколько закреплённых горутин на одном M будут выполняться последовательно, даже если есть свободные M.

Планировщик сохраняет множество "закреплённых" M в специальных структурах и в findrunnable отдаёт приоритет выполнению m.lockedg.

Под капотом (internals)

Поля m.lockedg и g.lockedm. В schedule() есть проверка: если у M есть закреплённая G, и она готова, она будет выполнена. Иначе M паркуется (ставится m.lockedInt?). При пробуждении закреплённой G, функция wakeup или startlockedm активирует соответствующий M.

Код: runtime/proc.go, startlockedm, stoplockedm. stoplockedm паркует M с закреплённой G, освобождая P, но сохраняя M. startlockedm пробуждает такой M, когда его G становится Grunnable.

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

Типичные ошибки кандидатов

  1. Предполагать, что LockOSThread просто предотвращает миграцию, не изменяя планирование.
    На самом деле это накладывает сильные ограничения и может привести к неэффективному использованию ресурсов.
  2. Создавать много горутин с LockOSThread без явной необходимости.
    Каждая требует свой M, что ведёт к созданию лишних потоков и перегрузке.
  3. Забывать снять привязку перед блокировкой.
    Если горутина с привязкой блокируется в syscall, M всё равно будет ждать её, а не освободится для других.

Возможные дополнительные вопросы интервьюера

  • Как планировщик пробуждает M, когда его закреплённая G стала готова?
    Через notewakeup или прямой вызов startlockedm, который устанавливает состояние M в рабочий и помещает его в очередь на запуск.
  • Может ли другая горутина выполняться на M, если есть закреплённая, но она не готова?
    Нет, M не может выполнять другие пользовательские горутины, пока закреплённая G не завершит LockOSThread. M будет спать или ждать.

Что происходит с параллелизмом, если GOMAXPROCS=1?

Краткий ответ

При GOMAXPROCS=1 в системе одновременно выполняется только одна горутина (на одном P), что исключает параллельное выполнение. Однако горутины по-прежнему могут выполняться конкурентно, чередуясь на одном потоке. Это удобно для отладки или имитации однопоточной среды, но снижает производительность на многопроцессорных системах.

Развёрнутый ответ

С одним P планировщик Go не может использовать преимущества многоядерности, так как только один M может быть привязан к P в каждый момент времени. Горутины будут переключаться кооперативно и асинхронно, создавая иллюзию параллелизма, но реального параллельного исполнения не будет. Это полезно для:

  • Детерминированного тестирования конкурентных алгоритмов.
  • Реализации встроенных систем с ограниченными ресурсами.
  • Выявления гонок данных (хотя race detector работает независимо).

Производительность CPU-bound задач будет снижена примерно в N раз (где N — число ядер). Однако для I/O-bound задач с большим количеством блокировок на сети один P может быть достаточен, так как сетевое ожидание не потребляет CPU. Планировщик продолжит эффективно мультиплексировать горутины на один поток, и M может быть только один (если syscall потребует больше, может создаться дополнительный M, но без P они не будут выполнять пользовательский код).

Под капотом (internals)

sched.nprocs = 1. procresize(1) оставляет один P. M, обслуживающий этот P, выполняет schedule. Другие M, если создаются из-за блокирующих syscall, будут спать или ждать. Work stealing нечего красть у других P, так как их нет. Global queue остаётся, и горутины могут попадать в неё при переполнении локальной очереди (размер 256), но потребитель один.

Типичные ошибки кандидатов

  1. Считать, что GOMAXPROCS=1 гарантирует отсутствие гонок.
    Гонки возникают из-за несинхронизированного доступа к памяти, а не из-за параллелизма. Даже с одним P переключение горутин в неподходящий момент может привести к гонке.
  2. Полагать, что горутины вообще не будут выполняться одновременно.
    Они и так не выполняются одновременно — только одна, но переключения создают иллюзию параллелизма.
  3. Игнорировать возможность создания дополнительных M.
    Если горутина делает блокирующий syscall, P передаётся новому M, и программа продолжает выполняться. Но P всё равно один.

Возможные дополнительные вопросы интервьюера

  • Будет ли работать go программа с GOMAXPROCS=1 на многоядерном CPU?
    Да, но только на одном ядре (закрепление за конкретным ядром не гарантируется, но фактически M мигрирует по ядрам). Производительность будет ограничена.
  • Как эмулировать однопоточное выполнение в тестах?
    Установить GOMAXPROCS=1 и, возможно, runtime.LockOSThread для детерминированного порядка.

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

Краткий ответ

Планировщик вызывает функцию findrunnable, которая проверяет в строго определённом порядке: сначала закреплённую за M горутину (lockedg), затем runnext текущего P, потом локальную очередь runq, затем периодически глобальную очередь, потом пробует work stealing у других P, и в конце опрашивает netpoller и, возможно, глобальную очередь повторно.

Развёрнутый ответ

Алгоритм findrunnable (упрощённо):

  1. Проверить, есть ли у M закреплённая G (m.lockedg) и готовность её запуска. Если да — она идёт вне очереди.
  2. Проверить runnext текущего P (если не нулевой) — это даёт быстрый доступ к последней разбуженной горутине, улучшая локальность.
  3. Извлечь из локальной очереди runqget — off собственного P.
  4. С периодичностью примерно 1/61 (по schedtick) проверить глобальную очередь, чтобы предотвратить голодание.
  5. Если работы нет, вызвать findrunnable в цикле с попытками work stealing: выбрать случайный P, попытаться украсть половину его очереди (runqsteal).
  6. Если все попытки провалились, проверить глобальную очередь и netpoller (блокирующий опрос, если необходимо).
  7. Если совсем нет работы, M переходит в спящий режим.

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

Под капотом (internals)

Код runtime/proc.go -> findrunnable. Ключевой цикл:

Go
for {
    // проверка lockedg
    // проверка runnext
    // локальная очередь
    if gp := runqget(_p_); gp != nil { return gp }
    // глобальная очередь с периодичностью
    if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
        gp := globrunqget(_p_, 1)
        if gp != nil { return gp }
    }
    // work stealing
    for i := 0; i < 4; i++ {
        p2 := allp[randomOrder(i)]
        if gp := runqsteal(_p_, p2); gp != nil {
            return gp
        }
    }
    // netpoll и т.д.
}

Счётчик schedtick инкрементируется при каждом успешном переходе к выполнению новой G. Константа 61 выбрана как простое число для снижения синхронизации.

Типичные ошибки кандидатов

  1. Забывать про runnext.
    Это важная оптимизация для последовательной блокировки/разблокировки (например, паттерна «ping-pong» через канал).
  2. Преувеличивать роль глобальной очереди.
    В большинстве случаев горутины не попадают туда, и работа идёт локально.
  3. Считать, что work stealing гарантированно находит работу.
    Может и не найти, тогда M паркуется, и это нормально.

Возможные дополнительные вопросы интервьюера

  • Как часто планировщик обращается к глобальной очереди?
    Каждый 61-й tick, а также когда локальная очередь пуста и кража не удалась.
  • Что происходит, если runnext уже занят, а горутину нужно разбудить?
    Старая горутина из runnext перемещается в локальную очередь, а новая ставится на её место.

Что такое "spinning" M?

Краткий ответ

Spinning (прядущий) M — это поток ОС, который временно остался без горутин для выполнения, но не переходит в сон, а активно ищет работу (через work stealing) в течение короткого периода. Это позволяет быстро занять новые горутины без дорогого пробуждения спящего M. Однако количество spinning M ограничено, чтобы избежать лишней траты CPU.

Развёрнутый ответ

Когда у M заканчивается работа, но он решает не сразу засыпать, он переходит в состояние spinning. В этом состоянии он циклически пытается украсть работу у других P или найти готовые горутины в глобальной очереди или netpoller. Spinning M не удерживает P (P освобождается, если M переходит в спин), но активен. Это позволяет оперативно реагировать на внезапное появление новых горутин, например, после завершения сетевой операции.

Планировщик ограничивает количество spinning M: обычно не более GOMAXPROCS (точнее maxspinning = GOMAXPROCS). Если есть уже spinning M, другие M при отсутствии работы сразу засыпают. Это предотвращает перегрузку CPU пустым поиском.

Алгоритм: в findrunnable после неудачного поиска M может вызвать spinning и продолжать попытки кражи в течение некоторого времени. При нахождении работы он перестаёт крутиться. Если работа не найдена, он паркуется (sleep).

Под капотом (internals)

Глобальные переменные: sched.nmspinning — текущее количество spinning M. m.spinning — флаг. Логика в findrunnable:

Go
if !m.spinning {
    m.spinning = true
    atomic.Xadd(&sched.nmspinning, 1)
}
...
// цикл поиска
...
if m.spinning {
    // не нашли работу, остановить спин
    m.spinning = false
    if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
        // переход в парковку
    }
}

Планировщик также рассматривает spinning M при создании новых потоков: если есть spinning M, новый поток может не создаваться, так как spinning M скоро найдет работу.

Типичные ошибки кандидатов

  1. Думать, что spinning M удерживает P.
    Обычно он отдаёт P (через releasep), чтобы другие M могли его использовать.
  2. Считать, что spinning бесконечен.
    У spinning есть таймаут и лимит на число, он быстро прекращается при отсутствии работы.
  3. Путать spinning с busy loop в приложении.
    Это внутренний механизм планировщика.

Возможные дополнительные вопросы интервьюера

  • Зачем нужен spinning, если можно сразу усыпить M?
    Пробуждение потока требует системного вызова (futex), что дорого. Spinning помогает быстро подхватить работу без задержки.
  • Как ограничение количества spinning M влияет на масштабируемость?
    Предотвращает излишнюю конкуренцию и перегрузку CPU при пиковых нагрузках.

Как планировщик балансирует нагрузку между P?

Краткий ответ

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

Развёрнутый ответ

Балансировка нагрузки в Go децентрализована:

  • Локальные очереди: каждый P наполняет свой runq. Пока очередь не пуста, он работает локально, что минимизирует синхронизацию.
  • Кража: когда P нечего делать, он идёт к случайному P и забирает половину его очереди. Это эффективно перераспределяет накопившуюся работу с занятых P на свободные.
  • Глобальная очередь: когда локальная очередь P переполнена (256 элементов), он помещает избыток в глобальную очередь. Периодически (каждые 61 тик) другие P забирают горутины оттуда, предотвращая концентрацию работы на одном P.
  • Sysmon также может вмешиваться, выполняя handoff P при долгих системных вызовах, тем самым освобождая P и позволяя ему обслуживать другие горутины.

Благодаря рандомному выбору жертвы для кражи, нагрузка распределяется статистически равномерно, и не требуется глобальная координация.

Под капотом (internals)

Реализация кражи: runqgrab/runqsteal. Выбор жертвы: stealOrder.start и случайный индекс. Код:

Go
for i := 0; i < stealTries; i++ {
    p2 := allp[order.add(i)]
    // попытка украсть у p2
}

Глобальная очередь: globrunqput и globrunqget. globrunqputbatch используется для передачи половины локальной очереди в глобальную при переполнении.

Дополнительно, runnext помогает ускорить обработку последовательных коммуникаций, уменьшая необходимость в балансировке.

Типичные ошибки кандидатов

  1. Считать, что планировщик использует какой-то алгоритм на основе загрузки CPU для балансировки.
    Нет, всё на уровне очередей.
  2. Забывать про периодический доступ к глобальной очереди.
    Без него горутины, застрявшие в глобальной очереди, могли бы голодать.
  3. Переоценивать роль кражи для короткоживущих горутин.
    Если горутины быстро завершаются, очереди часто пустые, и балансировка не нужна. Механизм self-balancing за счёт создания G всегда в локальную очередь текущего P.

Возможные дополнительные вопросы интервьюера

  • Что произойдёт, если все P загружены, а один простаивает?
    Простаивающий P немедленно украдёт работу у случайного, выровняв загрузку.
  • Как часто P проверяет глобальную очередь?
    При schedtick % 61 == 0, т.е. каждые 61 планирование.

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

Краткий ответ

В современных версиях Go (1.14+) горутина не может выполняться бесконечно долго благодаря асинхронному вытеснению: sysmon прерывает её примерно каждые 10 мс. Кооперативное переключение может произойти гораздо раньше, при любом вызове функции или работе с каналом. Таким образом, максимальная квант времени без переключения — порядка 10 миллисекунд.

Развёрнутый ответ

Планировщик Go не использует фиксированные кванты времени, как планировщик ОС. Вместо этого:

  • Кооперативно горутина может быть переключена на каждом вызове функции, если у другого P скопилась работа (через Gosched при вытеснении). Но на практике горутина может бежать, пока не вызовет функцию, не заблокируется или не завершится.
  • Если горутина выполняет CPU-bound цикл без вызовов, sysmon через ~10 мс пошлёт сигнал SIGURG, заставив горутину передать управление планировщику в ближайшей безопасной точке. В Go 1.19 добавили больше safe-points, в том числе внутри runtime-функций, что улучшило отзывчивость.

Таким образом, максимальная длительность непрерывного выполнения — примерно 10 мс (значение forcePreemptNS = 10 мс в рантайме). Это гарантирует, что даже злонамеренная горутина не заблокирует планировщик.

Под капотом (internals)

Константа forcePreemptNS = 10 * 1000 * 1000 (10 мс). sysmon вызывает retake, где проверяет pd := &allp[i], если статус _Prunning и прошло > 10 мс, то preemptone(pd). preemptone отправляет сигнал M, если можно прервать.

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

Типичные ошибки кандидатов

  1. Говорить, что горутина может выполняться бесконечно (пре-1.14).
    Устаревшая информация.
  2. Называть точный квант времени (10 мс) гарантированным.
    Это не жёсткая гарантия реального времени, а best-effort.
  3. Не учитывать, что асинхронное вытеснение требует безопасных точек.
    Если горутина находится в ассемблерной функции без safe-point, она может задержаться.

Возможные дополнительные вопросы интервьюера

  • Можно ли настроить интервал вытеснения?
    Нет, forcePreemptNS — константа, не настраивается без модификации рантайма.
  • Что будет, если горутина выполняется внутри нативного кода cgo?
    Асинхронное вытеснение может не сработать, пока cgo не вернёт управление.

Как профилировать работу планировщика (scheduler trace)?

Краткий ответ

Для профилирования планировщика Go предоставляет инструмент go tool trace, который визуализирует события планировщика (запуск/остановка горутин, системные вызовы, work stealing и т.д.). Сбор трассировки происходит через runtime/trace или HTTP-обработчик net/http/pprof. Результат можно проанализировать в интерактивном веб-интерфейсе, что позволяет детально диагностировать проблемы производительности, связанные с планированием.

Развёрнутый ответ

Scheduler trace — мощный инструмент для анализа. Он записывает такие события, как:

  • Переключение горутины (GoStart, GoStop),
  • Блокировка/разблокировка (GoBlock, GoUnblock),
  • Системные вызовы (GoSysCall),
  • Создание горутины (GoCreate),
  • Work stealing и переходы P.
    Трассировка даёт временную шкалу, на которой видно, сколько времени горутины проводили в ожидании выполнения, какие P были заняты, как часто происходила кража и т.д.

Сбор данных:

Go
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// ... код

Или через pprof:

TEXT
curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out

Анализ: go tool trace trace.out открывает браузер с вкладками View Trace, Goroutine Analysis, Network, etc.

Это позволяет найти проблемы вроде «горутина долго не запускается из-за занятости P», «чрезмерное количество work stealing», «большое время блокировки на канале».

Под капотом (internals)

События трассировки генерируются в ключевых точках планировщика. Например, execute вызывает traceGoStart, goexit0 — traceGoStop. Данные пишутся в циклический буфер, а затем сбрасываются в файл. Формат бинарный, документирован в исходниках internal/trace.

При визуализации go tool trace показывает ленту P, на которой отображаются полосы выполнения G. Можно кликать и анализировать задержки.

Типичные ошибки кандидатов

  1. Использовать только CPU-профиль для анализа проблем планировщика.
    CPU-профиль показывает, где тратится процессорное время, но не показывает ожидания и переключения. Трассировка дополняет его.
  2. Собирать трассировку слишком долго.
    Файл быстро растёт, может быть гигабайт и зависнуть при анализе. Обычно 5-30 секунд.
  3. Игнорировать вкладку «Scheduler latency» в trace viewer.
    Там можно увидеть задержки между разблокировкой и фактическим запуском горутины.

Возможные дополнительные вопросы интервьюера

  • Как найти причину высокой задержки планировщика?
    Посмотреть на длительность пребывания G в состоянии Runnable, найти, какой P был занят или не хватало M.
  • В чем отличие трассировки планировщика от бенчмарков?
    Трассировка показывает реальное поведение во времени, бенчмарки — агрегированные метрики. Оба нужны.

Как go func() превращается в горутину внутри?

Краткий ответ

Компилятор преобразует go func() в вызов runtime.newproc, передавая указатель на функцию и её аргументы. newproc аллоцирует G (из пула или новый), выделяет стек, копирует аргументы, настраивает контекст так, что после запуска горутина выполнит функцию, а при возврате — вызовет runtime.goexit. Затем G помещается в очередь выполнения текущего P.

Развёрнутый ответ

Процесс:

  1. В исходном коде go foo(x, y). Компилятор создаёт замыкание или прямую ссылку на функцию.
  2. Генерируется вызов runtime.newproc(fn, args) где fn — funcval (структура с адресом функции).
  3. В newproc/newproc1:
    • Получить G из пула gfget или создать через malg со стеком ~2-4 КБ.
    • Стек инициализируется: на нём размещаются аргументы, адрес возврата на goexit.
    • В g.sched.pc записывается адрес runtime.goexit (или специальный трамплин), а g.startpc — адрес целевой функции.
    • Устанавливается статус _Grunnable.
  4. runqput помещает G в runnext или локальную очередь P.

При запуске G, планировщик вызывает gogo(&g.sched), который загружает контекст и передаёт управление. Выполняется пролог функции, затем пользовательский код. По завершении, благодаря записанному return-адресу, управление переходит на goexit, который освобождает ресурсы.

Под капотом (internals)

newproc определён в runtime/proc.go. Аргументы:

Go
func newproc(fn *funcval) {
    // get caller PC
    // newproc1(fn, argp, narg, nret, callerpc)
}

newproc1 — основная логика. Аллокация стека через stackalloc. Интересный момент: если стек нового G выделяется из кеша, он уже содержит мусор, но инициализация перезаписывает нужные части.

Типичные ошибки кандидатов

  1. Думать, что go func() создаёт поток ОС.
    Создаётся G, поток может быть создан только при необходимости.
  2. Забывать про копирование аргументов.
    Аргументы копируются на стек горутины, поэтому передача указателей безопасна, если исходная переменная не изменяется до копирования? Нужно помнить, что копия — значение на момент go, а не ссылка.
  3. Считать, что G создаётся всегда заново.
    Используется пул gFree, чтобы избежать аллокаций.

Возможные дополнительные вопросы интервьюера

  • Как компилятор определяет, что функция вызывается через go?
    Статически по ключевому слову go. Генерируется специальный код.
  • Что такое funcval?
    Внутренняя структура, содержащая указатель на код функции. Используется для косвенных вызовов.

Как происходит "handoff" P при блокирующем вызове?

Краткий ответ

Handoff — это процесс передачи P от заблокированного M (в syscall) другому M, чтобы избежать простоя логического процессора. Когда M входит в блокирующий системный вызов, он открепляет P и вызывает handoffp, который ищет спящий M или создаёт новый, передаёт ему P и запускает его с планировщиком. После выхода из syscall M пытается вернуть P обратно или ищет другой свободный P.

Развёрнутый ответ

Детальный процесс:

  1. Перед syscall вызывается runtime.entersyscall() — M переводится в статус _Msyscall, старый P сохраняется в m.oldp.
  2. Рантайм немедленно (или через entersyscallblock) делает releasep() для открепления P, что переводит P в статус _Psyscall.
  3. Затем handoffp(p) находит нового M: startm(p, false). Если в sched.midle есть спящие M, один пробуждается. Иначе создаётся новый поток newm. Новый M забирает P (acquirep) и начинает выполнять schedule(), подхватывая другие горутины.
  4. Оригинальный M выполняет syscall в ядре, блокируясь. После возврата вызывается exitsyscall. Сначала быстрый путь: если старый P ещё в _Psyscall (т.е. не был передан), M забирает его обратно и спокойно продолжает. Если P уже у другого M, то текущий M пытается найти любой другой P в sched.pidle. Если находит — забирает и продолжает. Иначе M ставит G в очередь выполнения и засыпает (stopm).

Handoff гарантирует, что блокирующие вызовы не парализуют параллелизм.

Под капотом (internals)

Функции:

  • entersyscall: сохраняет контекст, вызывает releasem и handoffp (не всегда, иногда ждёт sysmon).
  • entersyscallblock: сразу handoff.
  • handoffp -> startm.
  • exitsyscall: быстрый путь exitsyscallfast, медленный exitsyscall0.

Важно: sysmon тоже может принудительно сделать handoff, если видит P в _Psyscall > 10 мс.

Типичные ошибки кандидатов

  1. Считать, что блокирующий syscall полностью освобождает M.
    M заблокирован в ядре и не может быть использован для других целей, пока не выйдет.
  2. Забывать, что новый M не всегда создаётся — могут быть спящие.
    Пулы спящих M ускоряют процесс.
  3. Не понимать роли exitsyscallfast.
    Этот путь оптимизирует случай, когда syscall был коротким и P ещё не перехвачен.

Возможные дополнительные вопросы интервьюера

  • Что будет, если нет свободных M и достигнут лимит потоков?
    P останется без M, и его работа встанет до освобождения какого-либо потока.
  • Как sysmon помогает при handoff?
    Если горутина задерживается в syscall, sysmon принудительно открепляет P и делает handoff, не дожидаясь возврата из syscall.

Как работает GOMAXPROCS в контейнерной среде?

Краткий ответ

По умолчанию Go определяет количество P равным числу логических ядер, видимых в cgroup (учитывая CPU quota), но не всегда корректно обрабатывает cpuset.cpus. Это может привести к завышенному GOMAXPROCS внутри контейнера и просадке производительности из-за избыточных переключений. Рекомендуется явно задавать GOMAXPROCS или использовать утилиты вроде uber-go/automaxprocs.

Развёрнутый ответ

В Linux контейнере (Docker, Kubernetes) ресурсы процессора ограничиваются через cgroup v1 или v2. Go runtime считывает cpu.cfs_quota_us и cpu.cfs_period_us для вычисления эквивалентного числа ядер. Например, если лимит 400m CPU, GOMAXPROCS будет установлен в 1 (400/1000 с округлением вверх). Однако в случае выделения конкретных ядер через cpuset.cpus (без quota) или при комбинации quota+cpuset, рантайм может неправильно определить число P, часто устанавливая runtime.GOMAXPROCS(0) = количество всех ядер хоста, что приводит к оверкоммиту и потере производительности.

Проблема: слишком много P вызывает частые переключения M, contention и кеш-промахи, а также неоптимальное использование реальных ядерных ресурсов. Поэтому в контейнеризированных средах рекомендуется:

  • Устанавливать GOMAXPROCS равным лимиту CPU контейнера (например, через automaxprocs, который читает cgroup корректно).
  • Или использовать фиксированное значение, соответствующее выделенным ядрам.

Go 1.23+ улучшает автоопределение, но всё ещё не идеально.

Под капотом (internals)

Инициализация в runtime/os_linux.go: getproccount -> schedinit -> procresize. Функция schedinit вызывает getproccount, которая открывает /sys/fs/cgroup/cpu/cpu.cfs_quota_us и период. Если quota >0, вычисляет ncpu = quota/period. Иначе ncpu = runtime.NumCPU(). Проблема: NumCPU() возвращает число ядер хоста из /proc/cpuinfo, не учитывая cpuset.cpus.

Пакет automaxprocs решает это, парся cpuset и корректируя результат.

Типичные ошибки кандидатов

  1. Полагаться на автоматическое определение при запуске в Kubernetes.
    Может установить GOMAXPROCS равным числу ядер ноды, что катастрофично.
  2. Устанавливать GOMAXPROCS по количеству реплик контейнера.
    Нужно смотреть на лимит CPU, а не на количество контейнеров.
  3. Игнорировать проблему при тестировании локально.
    На локальной машине всё может работать быстро, а в продакшене упадёт.

Возможные дополнительные вопросы интервьюера

  • Как automaxprocs определяет количество ядер?
    Анализирует cgroup: учитывает quota, period и cpuset.cpus, берёт минимум из них.
  • Что будет, если задать GOMAXPROCS больше лимита?
    Лишние P будут создаваться, но реального выигрыша не дадут, только overhead на переключения.

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

Краткий ответ

Когда горутина блокируется на канале (отправка в заполненный или получение из пустого), рантайм вызывает gopark, который переводит G в состояние _Gwaiting, связывает её с каналом через структуру sudog и передаёт управление планировщику. При разблокировке (другой конец канала выполнил операцию) планировщик делает G готовой и, если возможно, помещает её в runnext P-владельца для немедленного выполнения.

Развёрнутый ответ

Работа с каналами в Go — это кооперативная точка переключения. Рассмотрим отправку ch <- val при заполненном буфере:

  1. runtime.chansend определяет, что буфер полон, и нужно заблокироваться.
  2. Выделяется sudog (структура ожидания), представляющая данную горутину.
  3. G переводится в _Gwaiting с указанием причины (канал), sudog помещается в очередь ожидания канала recvq или sendq.
  4. Вызывается runtime.gopark, который сохраняет контекст и запускает schedule().
  5. Планировщик выбирает другую горутину.

Когда другая горутина читает из канала, она извлекает ожидающий sudog, копирует данные, и вызывает runtime.goready(gp). goready переводит G в _Grunnable и вызывает runqput, стараясь поместить G в runnext текущего P, чтобы минимизировать задержку. Если runnext занят, старая G перемещается в локальную очередь.

Таким образом, разблокированная горутина часто получает немедленный запуск, что очень эффективно для паттернов типа «ping-pong».

Под капотом (internals)

Структура sudog (runtime/runtime2.go) содержит ссылки на G, канал, элемент данных, следующий sudog в очереди.

gopark делает mcall(park_m), сохраняет состояние и переключается на g0. goready вызывает systemstack и ready, которая ставит runqput. Использование runnext: при помещении G в runnext, если там уже есть G, та ставится в конец локальной очереди.

Типичные ошибки кандидатов

  1. Считать, что блокировка на канале блокирует M.
    M освобождается и может выполнять другие горутины.
  2. Не знать про runnext как оптимизацию для каналов.
    Это снижает задержки межгорутинного взаимодействия.
  3. Полагать, что горутины, ожидающие канал, находятся в глобальной очереди.
    Они в специальной очереди канала и невидимы планировщику, пока не будут разбужены.

Возможные дополнительные вопросы интервьюера

  • Как планировщик обрабатывает закрытие канала?
    Все ожидающие горутины разблокируются с соответствующим нулевым значением и флагом.
  • Что такое «park» и «unpark» в общем смысле?
    gopark — усыпить горутину, goready — разбудить.

Как планировщик взаимодействует со сборщиком мусора?

Краткий ответ

Планировщик тесно взаимодействует с GC для обеспечения безопасного и эффективного выполнения фаз сборки мусора. Он предоставляет механизм остановки всех горутин (stop-the-world) для некоторых фаз, управляет вспомогательными горутинами маркирования, встраивает проверки расширения стека, где GC может запросить вытеснение, и обеспечивает, чтобы потоки M могли сканировать свои стеки.

Развёрнутый ответ

GC в Go — параллельный, с короткими STW-паузами. Ключевые точки взаимодействия:

  • Stop-the-world (STW): планировщик должен приостановить все пользовательские горутины. Это делается через механизм stopTheWorld/startTheWorld, который проходит по всем P и принудительно вытесняет горутины, устанавливая флаг p.runSafePoint. G останавливаются в безопасных точках.
  • Вспомогательные горутины (mark workers): во время фазы маркирования GC создаёт специальные горутины, которые помогают сканировать объекты. Планировщик управляет ими, как и обычными G, но они привязаны к P и имеют особый статус.
  • Прерывание для GC: при необходимости GC может попросить планировщик прервать текущую G через preemptone (асинхронное вытеснение), чтобы та вошла в безопасную точку и помогла с маркированием (assisting).
  • Выделение памяти и ассистирование (mutator assist): когда горутина аллоцирует память и видит, что GC не успевает, она сама выполняет часть работы по маркированию. Планировщик это учитывает, т.к. такая горутина может быть занята дольше обычного.
  • Сканирование стека: при STW или во время маркирования, рантайм сканирует стеки всех G. Планировщик гарантирует, что G находятся в состояниях, где их стеки можно безопасно сканировать (safe-point).

Под капотом (internals)

Функции: runtime.gcStart, gcStopTheWorld, gcStartTheWorld, forEachP. Остановка мира: stopTheWorldWithSema обходит все P, отправляет сигнал о необходимости остановки (gcstopm), затем ждёт, когда все M освободят P и уснут. Используется sched.stopwait.

Встраивание safe-points: компилятор добавляет проверки в прологи функций и в некоторые циклы. Там проверяется флаг gcphase или флаг вытеснения.

Типичные ошибки кандидатов

  1. Считать, что планировщик и GC независимы.
    Они сильно завязаны: GC требует остановку горутин и помощь от планировщика.
  2. Забывать про mutator assist.
    Горутина может неожиданно потратить время на помощь GC, что видно в планировщике.
  3. Не знать про влияние на вытеснение.
    Во время GC асинхронное вытеснение может быть временно подавлено?

Возможные дополнительные вопросы интервьюера

  • Как планировщик гарантирует, что все горутины остановлены для STW?
    Использует механизм safe-point и ожидание, когда все M достигнут точки останова.
  • Что такое mark worker горутины?
    Специальные G, которые планировщик запускает на P для помощи маркировке; они невидимы для пользователя.

Что такое "findrunnable" и как он ищет работу?

Краткий ответ

findrunnable — это функция в рантайме (в runtime/proc.go), которая ищет следующую готовую горутину для выполнения текущим M и P. Она реализует многоуровневый поиск: закреплённые G, runnext, локальная очередь, глобальная очередь, work stealing, netpoller и в конце концов парковка M, если работа не найдена.

Развёрнутый ответ

Алгоритм findrunnable можно представить как последовательность шагов с возрастающей стоимостью:

  1. Проверить m.lockedg: если M закреплён за конкретной G, и она готова — запустить её.
  2. Проверить runnext: поле P, в которое помещается последняя разблокированная горутина.
  3. Локальная очередь runqget: быстро извлечь из своего массива (обычно без атомиков, если не было кражи).
  4. Глобальная очередь с периодичностью: каждые 61 тик (schedtick%61 == 0) и если в глобальной очереди есть элементы, забрать несколько (до 17) горутин, первая из которых идёт в runnext, остальные в локальную очередь.
  5. Work stealing: цикл до 4 попыток украсть половину очереди у случайного P. Если удача — вернуть полученную G.
  6. Netpoller: неблокирующий опрос, если ранее не проверяли.
  7. Повторная проверка глобальной очереди и кража, если в прошлый раз не получилось.
  8. Блокирующий опрос netpoller (с таймаутом) и затем повтор кражи.
  9. Если совсем ничего, M становится spinning и через некоторое время паркуется.

Этот алгоритм разработан для минимизации задержек и обеспечения баланса, избегая голодания.

Под капотом (internals)

Функция findrunnable (в proc.go) имеет контекст p, m. Константа stealTries = 4. Периодичность 61 хранится в p.schedtick — счётчик успешных планирований. При обнулении счётчика, планировщик гарантирует, что глобальная очередь проверяется каждые 61 выполнение.

Типичные ошибки кандидатов

  1. Утверждать, что глобальная очередь проверяется только когда локальная пуста.
    Периодическая проверка спасает от голодания даже при непустой локальной очереди.
  2. Забывать про runnext.
    Это важный первый шаг, который даёт большую часть выигрыша в локальности.
  3. Путать порядок проверки.
    На собеседовании важно правильно перечислить шаги.

Возможные дополнительные вопросы интервьюера

  • Почему глобальная очередь проверяется каждые 61 тик?
    Простое число, чтобы избежать синхронизации с другими P и снизить contention.
  • Что делает планировщик, если все проверки не дали результата?
    M переходит в spinning, затем паркуется.

Как runtime.Gosched реализован на уровне планировщика?

Краткий ответ

runtime.Gosched — это явная кооперативная передача управления: текущая горутина помещается в конец локальной очереди выполнения, а планировщик выбирает новую горутину для исполнения. Реализуется через mcall(gosched_m), который сохраняет состояние G, ставит её в очередь и вызывает schedule().

Развёрнутый ответ

Вызов runtime.Gosched() приводит к тому, что:

  1. G переводится в _Grunnable.
  2. Вызывается goschedImpl(bg) (внутренняя), которая делает runqput для текущей G — она попадает в локальную очередь текущего P (символически).
  3. Затем управление передается планировщику: schedule().
    Таким образом, горутина сама решает не ждать вытеснения и дать шанс другим.

Это используется в ситуациях, когда горутина собирается ждать чего-то в busy loop (вместо чистого цикла лучше использовать Gosched для предотвращения голодания других) или при кооперативных вычислениях.

Под капотом (internals)

Функция Gosched ассемблерная? нет, на Go с переключением стека:

Go
func Gosched() {
    checkTimeouts()
    mcall(gosched_m)
}

mcall переключается на g0 и вызывает gosched_m.
gosched_m вызывает goschedImpl(gp).
goschedImpl:

Go
func goschedImpl(gp *g) {
    status := readgstatus(gp)
    if status&^_Gscan != _Grunning {
        throw("bad g status")
    }
    casgstatus(gp, _Grunning, _Grunnable)
    dropg()
    lock(&sched.lock)
    globrunqput(gp)
    unlock(&sched.lock)
    schedule()
}

В более новых версиях goschedImpl ставит G в локальную очередь через runqput, а не глобальную, чтобы сохранить локальность. (в Go 1.22+ goschedImpl помещает G в runq текущего P).

Типичные ошибки кандидатов

  1. Считать, что Gosched немедленно передаёт управление конкретной горутине.
    Она просто даёт шанс другим, но планировщик выбирает следующую.
  2. Использовать Gosched для синхронизации или ожидания.
    Это не примитив синхронизации, нет гарантий порядка.
  3. Полагать, что Gosched всегда ставит G в глобальную очередь.
    Уже не так, для улучшения локальности.

Возможные дополнительные вопросы интервьюера

  • Чем Gosched отличается от time.Sleep(0)?
    Sleep(0) возвращает управление планировщику, но также может задействовать таймеры и netpoller, Gosched — просто планировщик.
  • Можно ли использовать Gosched для улучшения fairness?
    Да, в вычислительных циклах с долгим выполнением.

Что такое "idle P" и как он находит работу?

Краткий ответ

Idle P — это виртуальный процессор, который не привязан ни к какому M и находится в состоянии _Pidle. Он хранится в глобальном списке sched.pidle. Когда M просыпается или создаётся, он пытается получить idle P. Если P становится idle (M освободил его), он добавляется в этот список. Процесс поиска работы для idle P не происходит сам по себе — только когда M захватит его.

Развёрнутый ответ

P переходит в idle, когда M, владевший им, решил приостановиться (stopm) или при handoff, если новый M не был найден. В этом случае P возвращается в пул pidle (функция releasep). M без P не может выполнять пользовательский код.

Когда появляется работа (например, sysmon разбудил M, или новая горутина создана), производится попытка взять idle P: функция acquirep ищет P в sched.pidle. Если находит, M забирает его, инициализирует и начинает цикл планирования. Если нет — M может перейти в spinning или ожидание.

Таким образом, idle P пассивно ждут, пока их активирует M. Сам P не ищет работу активно.

Под капотом (internals)

Структура sched.pidle — указатель на head P, связный список. pidleput, pidleget. acquirep вызывает pidleget(). Если список пуст, M должен найти другой способ получить P (например, work stealing не требуется, т.к. без P нечего красть).

Типичные ошибки кандидатов

  1. Путать idle P с spinning M.
    Spinning M активен, хоть и без работы; idle P — пассивен.
  2. Считать, что idle P может сам находить работу.
    Пробуждать P некому без M.
  3. Забывать, что P может стать idle при handoff.
    Это происходит часто при блокирующих syscall.

Возможные дополнительные вопросы интервьюера

  • Что произойдёт, если все P idle?
    Система простаивает, ждёт работы. M спят.
  • Как число idle P влияет на производительность?
    Большое число idle P говорит о низкой загрузке, их можно убавить.

Как планировщик обрабатывает таймеры и time.AfterFunc?

Краткий ответ

Планировщик интегрирован с системой таймеров через «таймерную кучу» (timer heap) и специальные горутины. Когда таймер срабатывает, функция-колбэк выполняется в отдельной горутине. Управление таймерами распределено по P (каждый P имеет свою кучу таймеров), что снижает contention. sysmon или планировщик проверяют таймеры и ставят готовые горутины.

Развёрнутый ответ

В Go таймеры (time.Timer, time.AfterFunc) не создают отдельные потоки ОС. Вместо этого используется встроенная в планировщик инфраструктура:

  • Каждый P имеет кучу таймеров (p.timers), управляемую lock-free.
  • Когда таймер создаётся, он привязывается к текущему P.
  • При срабатывании таймера вызывается функция goroutineReady, которая делает связанную G готовой.
  • time.AfterFunc оборачивает функцию в горутину при создании таймера (точнее, создаёт G, которая выполнит функцию при срабатывании).

Проверка таймеров происходит в нескольких точках:

  • В findrunnable при парковке M – проверяет свои таймеры P.
  • В sysmon — периодически для всех P.
  • При планировании (schedule) — быстрый взгляд на локальные таймеры.

Это позволяет эффективно использовать таймеры без дополнительных потоков.

Под капотом (internals)

Файлы: runtime/time.go. Структура timer содержит ссылку на G или колбэк. Добавление таймера: addtimer -> помещается в кучу текущего P. Проверка: checkTimers пробегает по куче, изымает просроченные таймеры и вызывает goroutineReady (для обычных) или напрямую запускает колбэк в новой G.

В Go 1.14+ таймеры были реорганизованы для улучшения масштабируемости: теперь каждый P обслуживает свои таймеры без глобальной блокировки (раньше был глобальный timer heap с мьютексом). Это повысило производительность.

Типичные ошибки кандидатов

  1. Думать, что time.AfterFunc выполняется в текущей горутине.
    Она создаёт новую горутину.
  2. Считать, что таймеры проверяются только sysmon.
    Планировщик при парковке или в findrunnable тоже проверяет.
  3. Игнорировать потенциальные утечки таймеров.
    Если не остановить таймер, он останется в куче, и связанная горутина может никогда не быть собрана.

Возможные дополнительные вопросы интервьюера

  • Как избежать утечки таймеров?
    Всегда вызывать timer.Stop() по окончании использования.
  • Где хранятся таймеры и как они упорядочены?
    В куче по времени срабатывания, привязанной к P.

Как работает "глобальная очередь" и когда горутины туда попадают?

Краткий ответ

Глобальная очередь (sched.runq) — это связный список горутин, доступный всем P. Горутины попадают туда при переполнении локальной очереди P (более 256), при вытеснении с помощью globrunqputbatch, или когда планировщик решает переместить часть горутин для балансировки. Из глобальной очереди горутины забираются планировщиком периодически или когда локальная очередь пуста.

Развёрнутый ответ

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

  1. Переполнение локальной очереди: при вызове runqputslow, когда локальная очередь заполнена, часть (половина) локальной очереди извлекается и помещается в глобальную (globrunqputbatch). Это эффективно, так как амортизирует затраты на блокировку.
  2. Вытеснение: асинхронное вытеснение может поместить горутину в глобальную очередь (раньше было так, сейчас через runqput).
  3. Запуск go func() при заполненной локальной очереди: newproc -> runqput может вызвать runqputslow, который часть локальной очереди сбрасывает в глобальную.
  4. Инжекция через runtime.injectg (редко).

Получение из глобальной очереди: globrunqget(p, max) забирает до max горутин, первую помещая в runnext P, остальные в локальную очередь. Это делается либо при опустошении локальной очереди, либо при периодической проверке (каждые 61 тик), чтобы предотвратить голодание.

Под капотом (internals)

globrunqput и globrunqget управляются под sched.lock. Структура: g.schedlink для связного списка. Пакетная вставка/извлечение минимизирует захват мьютекса.

Типичные ошибки кандидатов

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

Возможные дополнительные вопросы интервьюера

  • Верно ли, что глобальная очередь медленнее локальной?
    Да, из-за необходимости блокировки, поэтому её избегают.
  • Как измеряется длина глобальной очереди?
    sched.runqsize.

Что такое "P's runnext" и как это ускоряет выполнение?

Краткий ответ

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

Развёрнутый ответ

Идея: если горутина A отправила данные в канал и стала ждать ответа от B, а затем B, получив данные, пробуждает A, то A будет немедленно запущена после B. Без runnext A попала бы в конец локальной очереди и ждала бы, пока выполнятся другие горутины.

runnext содержит не более одной G. При вставке через runqput, если runnext пуст, G помещается туда. Если уже занят, старая G из runnext перемещается в хвост локальной очереди, а новая G становится в runnext. Планировщик в findrunnable сначала проверяет runnext, затем локальную очередь.

Это особенно эффективно для паттернов типа «запрос-ответ» через каналы, увеличивая пропускную способность.

Под капотом (internals)

Поле runnext типа guintptr. runqput реализует логику: если runnext == 0, записать новую G; иначе oldnext = runnext, runnext = new, затем runqput для oldnext в локальную очередь (хомяк в хвост). Это реализовано атомарно? Не вполне, т.к. P владеет им, но кража может затронуть runnext через специальный CAS.

При краже: если локальная очередь пуста, runqsteal может украсть runnext через runqgrab.

Типичные ошибки кандидатов

  1. Забывать про runnext и утверждать, что всегда используется очередь.
    Это упущение серьёзное.
  2. Считать, что runnext — это просто ещё один слот в очереди.
    Нет, он приоритезирован перед очередью, уменьшая задержки.
  3. Не понимать, как кража взаимодействует с runnext.
    Если очередь пуста, runnext могут украсть.

Возможные дополнительные вопросы интервьюера

  • Какие сценарии выигрывают от runnext?
    Цепочки горутин, общающихся через каналы, «гонки» горутин.
  • Может ли runnext привести к несправедливости?
    Теоретически да, если две горутины бесконечно будили друг друга, другие могли бы голодать, но асинхронное вытеснение решает эту проблему.

Как планировщик решает, красть ли ему работу у другого P?

Краткий ответ

Планировщик пытается украсть работу, когда текущий P исчерпал свою локальную очередь, runnext пуст, и в глобальной очереди тоже ничего нет (или проверка не дала результата). Попытки кражи делаются до 4 раз к случайным P. Если кража успешна, планировщик получает половину очереди жертвы. Решение не взвешенное, а статистическое: через рандомизацию достигается баланс.

Развёрнутый ответ

Алгоритм в findrunnable после проверки локальных и глобальной очереди:

Go
for i := 0; i < stealTries; i++ {
    p2 := allp[stealOrder.start + i]
    if gp := runqsteal(_p_, p2, stealRunNextG); gp != nil {
        return gp
    }
}

stealOrder — случайная перестановка, чтобы не все воровали у одного. stealTries = 4. Если кража удаётся, возвращается украденная G, а остальные добавляются в локальную очередь вора. Если нет — продолжает.

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

Под капотом (internals)

runqsteal (или runqgrab в новых версиях) атомарно считывает head/tail, вычисляет половину очереди, копирует и обновляет head. Если очередь жертвы содержит менее 2 элементов, кража может обратиться к runnext.

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

Типичные ошибки кандидатов

  1. Полагать, что планировщик использует показатели загрузки CPU для выбора жертвы.
    Нет, только случайность.
  2. Считать, что кража происходит всегда, когда есть свободный P.
    Свободный P без M не крадёт, т.к. кража выполняется только в контексте M.
  3. Забывать, что кража может затрагивать runnext.
    В новых версиях это учтено.

Возможные дополнительные вопросы интервьюера

  • Насколько эффективна рандомизированная кража?
    Очень эффективна, доказано на практике для планировщиков задач.
  • Что будет, если все P перегружены и нет свободных?
    Каждый P занят, кража не происходит, кроме как при завершении горутин и появлении новых.

Как wakeup спящей горутины работает на уровне планировщика?

Краткий ответ

Пробуждение спящей горутины (например, после разблокировки канала или сигнала) осуществляется через вызов runtime.goready(g). Она переводит G в состояние _Grunnable, затем runqput помещает G в очередь выполнения (в runnext текущего P). Если G была привязана к спящему M через LockOSThread, планировщик пробуждает этот M.

Развёрнутый ответ

Когда горутина, ожидающая на канале, должна быть разбужена, происходит следующее:

  1. Отправитель/получатель вызывает runtime.goready(gp).
  2. goready проверяет статус G (_Gwaiting), меняет на _Grunnable.
  3. Вызывает runqput для текущего P, который помещает G в runnext (или локальную очередь, если runnext занят). Это оптимизирует повторный запуск той же горутины.
  4. Если пробуждённая G закреплена за потоком (lockedg), выполняется wakeup для соответствующего M: notewakeup(m.park), что снимает поток с ожидания.
  5. Если в результате runqput обнаруживается, что в очереди есть горутины, а данный P был в состоянии _Pidle? но goready вызывается из работающего P, поэтому P не idle. В некоторых случаях wakep может пробудить спящий M, если есть свободные P.

Таким образом, пробуждение — это быстрая операция без системных вызовов, сводящаяся к смене статуса и постановке в очередь.

Под капотом (internals)

goready в runtime/proc.go. runqput внутренне: если runnext пуст, G туда; иначе старый next в локальную очередь, новая в next. Если G закреплён, wakeup -> startlockedm -> notewakeup.

Структура m.park — это note (futex), используемый для парковки и пробуждения потоков.

Типичные ошибки кандидатов

  1. Считать, что wakeup всегда будит M.
    Обычно будить не надо, потому что G помещается в очередь работающего P, и M не требуется.
  2. Путать goready с прямым запуском G.
    G становится готовой, но планировщик решает, когда ей дать процессор.
  3. Не учитывать runnext при пробуждении.
    Это ключ к низкой задержке.

Возможные дополнительные вопросы интервьюера

  • Что делает goready, если вызывается из горутины, не владеющей P?
    Такого не бывает, пользовательская горутина всегда выполняется с P.
  • Может ли goready привести к переполнению локальной очереди?
    Да, тогда вызывается runqputslow, и горутина уходит в глобальную очередь.

Что такое "schedtick" и "syscalltick"?

Краткий ответ

schedtick и syscalltick — это счётчики в структуре P, используемые для отслеживания активности планировщика и продолжительности системных вызовов. schedtick увеличивается каждый раз, когда P переключается на новую горутину. syscalltick увеличивается при входе в системный вызов. Они нужны для обнаружения длительных вызовов и горутин, а также для периодической проверки глобальной очереди.

Развёрнутый ответ

  • schedtick: инкрементируется в execute, когда горутина начинает выполнение. Используется для:
    • Определения, нужно ли проверить глобальную очередь (if p.schedtick%61 == 0).
    • Обнаружения горутин, выполняющихся слишком долго (sysmon сравнивает текущие ticks с предыдущими).
    • Статистики планирования.
  • syscalltick: инкрементируется при входе в syscall (entersyscall). Используется sysmon для определения, не застрял ли M в syscall. Если tick не менялся в течение 10 мс, sysmon предпринимает handoff.

Эти счётчики защищены от переполнения, обнуляются при переполнении без последствий.

Под капотом (internals)

Поля: p.schedtick, p.syscalltick. sysmon хранит копии lasttick для каждого P и сравнивает при каждом цикле. Если p.syscalltick != lasttick, обновляет, иначе понимает, что syscall затянулся.

Типичные ошибки кандидатов

  1. Путать schedtick с числом выполненных горутин.
    Он считает переключения на новые горутины, а не завершённые.
  2. Не знать про модуль 61 для глобальной очереди.
    Это часто спрашивают.
  3. Считать, что syscalltick считает количество системных вызовов.
    Нет, он счётчик для отслеживания изменений.

Возможные дополнительные вопросы интервьюера

  • Как часто сбрасываются эти счётчики?
    Не сбрасываются, только увеличиваются, переполняются через очень долгое время (32-битные).
  • Почему 61, а не 64 или 100?
    Простое число уменьшает вероятность одновременной проверки несколькими P и contention.

Как принудительно ограничить количество используемых потоков ОС?

Краткий ответ

Максимальное количество потоков ОС (M) ограничивается через runtime.debug.SetMaxThreads(limit). По умолчанию граница — 10 000. При превышении рантайм вызывает панику. Также можно ограничить через GOMAXPROCS количество P, что косвенно влияет на число активных потоков, но не даёт жёсткой гарантии.

Развёрнутый ответ

Функция SetMaxThreads из пакета runtime/debug устанавливает лимит на количество M. Когда newm пытается создать поток и счётчик mcount >= maxmcount, создание блокируется и вызывает панику с сообщением "thread exhaustion". Лимит включает потоки, созданные для sysmon, GC worker, но не включает главный поток.

Это полезно в окружениях с ограниченными ресурсами или для предотвращения исчерпания PID по неосторожности (cgo, блокирующие syscall). Однако установка слишком низкого лимита может привести к зависанию всей программы, если все потоки будут заняты блокирующими вызовами, а новых создать нельзя. Поэтому нужно тщательно тестировать.

Дополнительно, можно управлять через GOMAXPROCS число P, что ограничивает количество одновременно выполняющихся горутин и потенциально снижает число активных M. Но при блокирующих syscall M могут создаваться и сверх GOMAXPROCS.

Под капотом (internals)

Переменная sched.maxmcount. В newm1 проверка:

Go
if mcount() >= maxmcount {
    throw("too many threads")
}

mcount — атомарный счётчик.

Типичные ошибки кандидатов

  1. Устанавливать лимит слишком низко.
    Если все M заблокированы, нет свободных для P, приложение зависнет.
  2. Полагать, что GOMAXPROCS полностью контролирует потоки.
    Нет, M могут создаваться и при малом числе P.
  3. Забывать, что sysmon тоже считается.
    Он не входит в лимит? Входит, он создаётся newm.

Возможные дополнительные вопросы интервьюера

  • Как мониторить количество потоков?
    runtime.ThreadCreateProfile или /debug/pprof/threadcreate. Также go tool trace.
  • Что будет при превышении лимита?
    Паника в момент создания нового M.

Как работает runtime.NumGoroutine() и что он показывает?

Краткий ответ

NumGoroutine возвращает количество существующих горутин (объектов G) на текущий момент. В счёт входят все G, кроме находящихся в состоянии _Gdead и ещё не переиспользованных. По сути, это сумма G в состояниях _Gidle, _Grunnable, _Grunning, _Gsyscall, _Gwaiting. Функция итеративно обходит список всех G.

Развёрнутый ответ

runtime.NumGoroutine реализован в runtime/proc.go. Он захватывает allglock (мьютекс списка всех G), проходит по связному списку allgs и суммирует все G, чей статус не _Gdead. Это точное количество на момент вызова, но оно может тут же измениться в конкурентной среде. Используется для мониторинга, например, обнаружения утечек горутин.

Показывает общее число, а не только активные (бегущие+готовые). Включает спящие на каналах, таймерах, системных вызовах. Это важно для диагностики: если число неуклонно растёт — утечка.

Под капотом (internals)

Код:

Go
func NumGoroutine() int {
    var n int
    lock(&allglock)
    for _, gp := range allgs {
        if readgstatus(gp) != _Gdead {
            n++
        }
    }
    unlock(&allglock)
    return n
}

Список allgs поддерживается в актуальном состоянии: при создании G добавляется, при завершении и возврате в пул — удаляется или остаётся? Завершённые G могут оставаться в gFree, но они помечены _Gdead и не считаются.

Типичные ошибки кандидатов

  1. Ожидать, что число совпадает с количеством созданных go вызовов.
    Завершённые горутины не считаются.
  2. Использовать NumGoroutine для точных метрик производительности.
    Это лишь снимок, для метрик лучше использовать runtime метрики или trace.
  3. Не учитывать служебные горутины (например, sysmon, GC workers).
    Они тоже попадают в счётчик.

Возможные дополнительные вопросы интервьюера

  • Как быстро меняется число горутин?
    Очень быстро, до миллионов в секунду.
  • Что покажет NumGoroutine при заблокированной на канале горутине?
    Она будет учтена, так как в состоянии _Gwaiting.

В чем разница между "Scheduler Trace" и "CPU Profile"?

Краткий ответ

Scheduler Trace (трассировка) записывает события планировщика (создание, блокировка, запуск горутин) во времени и показывает задержки, переключения и работу P. CPU Profile показывает агрегированное распределение времени CPU по функциям. Trace отвечает на «когда и почему горутина ждала», а CPU-профиль — «где тратится процессорное время».

Развёрнутый ответ

  • Scheduler Trace: фокусируется на событиях рантайма, отображает активность горутин и P на временной шкале. Позволяет анализировать latency (сколько горутина ждала запуска), загруженность P, частоту work stealing, блокировки. Идеален для проблем concurrency.
  • CPU Profile: собирается через runtime/pprof, показывает функции, в которых горутины чаще всего проводили процессорное время. Подходит для поиска горячих точек, CPU-bound узких мест.
  • Trace может быть более дорогим в сборе (большой объём данных), но даёт динамику. CPU-профиль лёгкий и показывает статическую картину.

Обычно используют оба инструмента: профиль — чтобы найти горячие функции, трассировку — чтобы понять, почему горутины простаивают.

Под капотом (internals)

Сбор трассировки: runtime/trace использует буфер событий, записывает при каждом значимом действии планировщика. CPU-профилирование: периодически (обычно 100 Гц) срабатывает сигнал (SIGPROF), по которому профилировщик захватывает стек вызовов текущего M. Анализируется через pprof.

Типичные ошибки кандидатов

  1. Считать, что CPU-профиль показывает ожидания.
    Нет, только время, когда горутина реально исполнялась.
  2. Включать trace на продакшене без необходимости.
    Может замедлить работу.
  3. Пытаться найти утечку горутин только CPU-профилем.
    Нужен либо trace, либо дамп горутин.

Возможные дополнительные вопросы интервьюера

  • Как профилировать задержки планировщика?
    Использовать go tool trace и вкладку «Scheduler latency».
  • Какой профиль лучше для обнаружения contention на мьютексах?
    Mutex profile (-mutexprofile) или блок-профиль, но trace тоже может показать.

Как gomaxprocs влияет на производительность сетевого сервера?

Краткий ответ

GOMAXPROCS определяет число P, которые могут параллельно обрабатывать соединения. Для сетевого сервера, где много горутин блокируются на вводе-выводе, увеличение P сверх числа ядер может дать прирост до определённого предела, так как блокировки на сети не потребляют CPU, а P переключаются на готовые горутины. Однако слишком большое число P может привести к избыточному планированию, contention и потреблению памяти.

Развёрнутый ответ

Сетевой сервер (например, HTTP) характеризуется тем, что горутины часто находятся в ожидании сети (Gwaiting). Благодаря netpoller, когда данные приходят, горутина становится готовой и может быть быстро запущена. Если P столько же, сколько ядер, в пиковые моменты готовые горутины могут ждать, пока освободится P, хотя CPU мог бы обрабатывать больше. Увеличение P > ядер позволяет большему числу горутин находиться в состоянии «готов к выполнению» и быстрее получать процессорное время после пробуждения. Это особенно заметно при высокой конкуренции и коротких запросах.

Однако слишком много P:

  • Увеличивает количество M, которые могут быть созданы, потребляя память стеков ОС.
  • Увеличивает contention на локальных очередях и глобальной очереди.
  • Может ухудшить локальность кеша процессора.

Практика: рекомендуется GOMAXPROCS = NumCPU для большинства случаев, но в некоторых сетевых сценариях выгодно удвоить. Обязательно бенчмаркать.

Под капотом (internals)

Взаимодействие с netpoller: когда горутина становится готовой после сети, она помещается в runnext текущего P. Если все P заняты, готовые горутины могут накапливаться в очередях. Дополнительные P помогают быстрее их разгребать. Однако планировщик оптимизирован под число ядер, и избыток P может привести к тому, что M будут бороться за CPU, вызывая больше планировочных переключений ядра.

Типичные ошибки кандидатов

  1. Назначать GOMAXPROCS в 2–4 раза больше ядер без измерений.
    Может привести к деградации из-за кеш-промахов.
  2. Связывать число горутин и GOMAXPROCS.
    Нет прямой связи, миллион горутин может работать с 1 P.
  3. Считать, что netpoller полностью устраняет необходимость в P.
    P нужны для выполнения кода обработки запросов.

Возможные дополнительные вопросы интервьюера

  • Будет ли польза от GOMAXPROCS=100 на 4-ядерной машине для веб-сервера?
    Скорее нет, из-за конкуренции за CPU и накладных расходов. Но для I/O-bound с очень быстрыми ответами может быть незначительный плюс.
  • Как измерить влияние?
    Провести нагрузочное тестирование и сравнить latency, throughput.

Как планировщик обрабатывает долгие вызовы cgo?

Краткий ответ

Вызовы cgo блокируют M до завершения, но Go не может вытеснить поток, выполняющий C-код. Поэтому планировщик не может прервать долгий cgo-вызов. Чтобы избежать захвата P, рантайм может открепить P до вызова (через entersyscallblock для потенциально блокирующих cgo или через механизм sysmon). Это позволяет P обслуживать другие горутины, но M занят и не может быть использован.

Развёрнутый ответ

При вызове cgo, Go переходит в C-функции через ассемблерный переход. Рантайм не может контролировать этот код или вставить safe-points. Поэтому долгий вызов cgo может занять поток, потенциально навсегда. Чтобы смягчить это:

  • Перед вызовом cgo может быть вставлена логика, аналогичная блокирующему syscall: entersyscallblock() освобождает P и делает handoff. Это гарантирует, что P не простаивает.
  • Если программист не использует специальные аннотации, рантайм всё равно может открепить P через sysmon, если вызов затянулся >10 мс (как с обычным syscall).
  • Однако M, исполняющий cgo, не может быть разбужен сигналом вытеснения, так как блокировка в C неконтролируема.

Поэтому важно минимизировать время нахождения в cgo или использовать отдельные потоки с LockOSThread.

Под капотом (internals)

Механизм: runtime.entersyscall или entersyscallblock может быть вызван обвязкой cgo. Для cgo-функций, сгенерированных cgo, рантайм автоматически вставляет entersyscall перед передачей управления C. Если C-функция быстрая, она может и не потребовать handoff; если долгая — sysmon принудительно отберёт P.

Поток, бегущий C-код, не обрабатывает сигналы Go, поэтому асинхронное вытеснение не работает.

Типичные ошибки кандидатов

  1. Полагать, что асинхронное вытеснение работает и для cgo.
    Это не так, C-код не имеет safe-point.
  2. Забывать о возможности освобождения P.
    P уходит, но M блокирован.
  3. Создавать много cgo-горутин без контроля.
    Быстро кончатся потоки, упрётся в лимит.

Возможные дополнительные вопросы интервьюера

  • Как гарантировать, что cgo-вызов не блокирует P?
    Установить GOMAXPROCS достаточно большим или использовать пул потоков в C.
  • Что произойдёт, если все M заняты cgo, а пришла новая задача?
    Будет создаваться новый M до лимита, после — зависание.

Как работает "thread parking" и "unparking"?

Краткий ответ

Thread parking — это перевод потока ОС (M) в спящий режим, когда нет работы. Реализуется через futex (futexsleep) на note структуре. Thread unparking — пробуждение спящего M через futexwakeup (notewakeup). Эти операции позволяют эффективно управлять потоками без системного планировщика, экономя CPU.

Развёрнутый ответ

В Go, когда M не может найти работу (после проверки всех очередей), он вызывает stopm, которая помещает M в список бездействующих (sched.midle) и вызывает notesleep(&m.park). notesleep в Linux выполняет futex(..., FUTEX_WAIT, ...), который приостанавливает поток до тех пор, пока не придёт пробуждение. При этом M не потребляет CPU.

Unparking происходит при появлении работы: например, при создании новой горутины, или при handoff P. Тогда планировщик вызывает notewakeup(&m.park), который делает futex(FUTEX_WAKE), пробуждая поток. Пробуждённый M продолжает цикл планировщика и ищет работу.

Это быстрее, чем создание/уничтожение потоков, и используется для поддержания пула потоков.

Под капотом (internals)

Структура note в runtime/lock_futex.go:

Go
type note struct {
    key uintptr
}

notesleep и notewakeup работают с этим key как futex word. futexsleep — системный вызов futex (или через clone с FUTEX_WAIT), futexwakeup — FUTEX_WAKE.

Также есть noteclear и notewakeup с атомарными операциями.

Типичные ошибки кандидатов

  1. Думать, что потоки уничтожаются при отсутствии работы.
    Они паркуются, переиспользуются.
  2. Путать парковку M с park горутины.
    M park — спящий поток, горутина park — ожидание в планировщике.
  3. Не знать, как реализован futex в Go.
    Это интересная деталь.

Возможные дополнительные вопросы интервьюера

  • Почему не использовать pthread_cond?
    Go использует futex напрямую для производительности и контроля.
  • Что произойдёт, если не разбудить M?
    Поток останется в спячке навсегда, будет утечка потоков.

Что такое "goexit" и как он освобождает ресурсы горутины?

Краткий ответ

goexit — это специальная функция рантайма, которая вызывается при завершении горутины (после выхода из функции верхнего уровня). Она выполняет mcall(goexit0), которая переводит G в _Gdead, освобождает стек, снимает привязки, очищает состояние и помещает G в пул свободных (gFree) для переиспользования.

Развёрнутый ответ

Каждая горутина создаётся с адресом возврата, установленным на runtime.goexit. Когда функция горутины заканчивается, процессор переходит на goexit. Эта последовательность:

  1. goexit вызывает goexit1, которая делает mcall(goexit0).
  2. goexit0 на системном стеке:
    • Переводит статус G в _Gdead.
    • Убирает связь с M.
    • Вызывает gfput(p, gp), которая помещает G в список свободных P (или глобальный).
    • Вызывает stackfree для освобождения стека и возврата в стек-пул.
    • Планирует следующую горутину через schedule().

Ресурсы G переиспользуются: при следующем newproc G берётся из пула, экономя аллокации.

Под капотом (internals)

Ассемблерный код в runtime/asm_amd64.s: TEXT runtime·goexit(SB). Переход на goexit0. gfput возвращает G в gFree. Стек освобождается в stackfree, который кладёт его в stackpool.

Типичные ошибки кандидатов

  1. Путать goexit с runtime.Goexit().
    Goexit() — функция для принудительного завершения, вызывается из пользовательского кода.
  2. Считать, что горутина удаляется сразу.
    Структура G переиспользуется.
  3. Забывать, что defer-ы выполняются до goexit.
    Они выполняются нормально при возврате из функции.

Возможные дополнительные вопросы интервьюера

  • Может ли goexit паниковать?
    Нет, это внутренняя функция.
  • Можно ли перехватить завершение горутины?
    Только через defer в самой горутине.

Как планировщик Go эволюционировал от версии к версии?

Краткий ответ

Эволюция планировщика Go прошла от простой модели GM (глобальная очередь с мьютексом) к GMP с work stealing (1.2), добавлению кооперативного вытеснения, асинхронного вытеснения на сигналах (1.14), улучшению safe-points (1.19), и постоянным оптимизациям масштабируемости и производительности.

Развёрнутый ответ

Основные вехи:

  • Go 1.0: модель GM с глобальной очередью и единственным мьютексом. Плохая масштабируемость на многоядерных системах.
  • Go 1.1: Введение P (GMP) и work stealing, что радикально повысило параллелизм.
  • Go 1.2: непрерывный рост стека (копирование вместо сегментации).
  • Go 1.3: улучшения в работе с сетью (netpoller переведён в рантайм).
  • Go 1.4: внедрение кооперативного вытеснения на основе проверок стека.
  • Go 1.5: реорганизация GC, планировщик больше интегрирован с GC (mutator assists).
  • Go 1.8: уменьшение STW пауз.
  • Go 1.12: улучшения timer management.
  • Go 1.14: асинхронное вытеснение (async preemption) на сигналах — ключевое изменение для fairness.
  • Go 1.19: добавление дополнительных safe-points в рантайме.
  • Go 1.22+: улучшения работы с cgroup, оптимизации work stealing и netpoller.

Планировщик стал надёжным, предсказуемым и высокопроизводительным.

Под капотом (internals)

Изменения в исходниках отражаются в proc.go, preempt.go, signal_unix.go. Ключевые коммиты можно найти в истории Go.

Типичные ошибки кандидатов

  1. Описывать только текущее состояние без упоминания эволюции.
    Это показывает непонимание контекста.
  2. Говорить, что асинхронное вытеснение было всегда.
    Нет, это с 1.14.
  3. Смешивать вытеснение с P.
    Вытеснение появилось позже.

Возможные дополнительные вопросы интервьюера

  • Какие проблемы решила модель GMP?
    Централизованный contention на мьютексе глобальной очереди.
  • Почему асинхронное вытеснение стало возможным только в 1.14?
    Требовалось решить множество проблем с небезопасными точками и сигналами.

Может ли одна горутина быть запланирована на нескольких P одновременно?

Краткий ответ

Нет, одна горутина не может одновременно выполняться на нескольких P. В каждый момент времени горутина существует в единственном экземпляре, обслуживается одним M и, следовательно, одним P. Планировщик гарантирует, что G находится не более чем в одной очереди или на выполнении. Это предотвращает состояние гонки на уровне планировщика.

Развёрнутый ответ

G — это единица исполнения, обладающая собственным стеком и контекстом. Если бы G могла быть запущена на двух P одновременно, возник бы конфликт при изменении стека и регистров. Рантайм строго следит, чтобы G была либо в одном из состояний (running, runnable, waiting), но не дублировалась. При помещении G в очередь, она удаляется из предыдущего места. Планировщик предотвращает множественное планирование одной G с помощью атомарных статусов.

Таким образом, параллелизм достигается запуском разных G, а не одной G на нескольких ядрах.

Под капотом (internals)

Статус G хранится в atomicstatus. Переходы между состояниями используют атомарные CAS и сопровождаются проверками, что G не находится в очереди и не выполняется. Например, перед runqput G должна быть в _Grunnable и не находиться в другой очереди.

Типичные ошибки кандидатов

  1. Предполагать, что для ускорения можно размножить горутину по ядрам.
    Нет, это нарушило бы семантику последовательного выполнения.
  2. Путать с форком потока.
    Go не поддерживает fork внутри горутины.
  3. Не понимать разницы между горутиной и задачей.
    Одна горутина — одна задача.

Возможные дополнительные вопросы интервьюера

  • Можно ли вручную запустить одну и ту же функцию в нескольких горутинах?
    Да, но это будут разные G.
  • Как планировщик избегает двойного планирования?
    Атомарный статус и проверки перехода.

Как работает канал semacquire и semrelease для синхронизации с семафорами в планировщике?

Краткий ответ

semacquire и semrelease — это внутренние функции рантайма, реализующие легковесные семафоры для синхронизации горутин на уровне планировщика. semacquire пытается получить семафор; если он занят, горутина паркуется (goparkunlock) и помещается в очередь ожидания. semrelease освобождает семафор и пробуждает одну ожидающую горутину (goready). Используются для мьютексов, каналов и других примитивов.

Развёрнутый ответ

Рантайм включает общий механизм семафоров (runtime/sema.go), который используется в реализации sync.Mutex, sync.RWMutex, блокировках каналов и т.д. В отличие от каналов, которые более высокоуровневые, semacquire/semrelease работают с 32-битным счётчиком и списком ожидающих горутин (sudog).

  • semacquire(addr): атомарно декрементирует семафор; если значение стало отрицательным, горутина добавляется в очередь ожидания (связный список через sudog.schedlink), вызывает goparkunlock для парковки.
  • semrelease(addr): атомарно инкрементирует семафор; если есть ожидающие (значение было отрицательным), извлекает одну горутину и вызывает goready, ставя её в очередь выполнения.

Эта реализация эффективна и без блокировок на основном пути, используя атомарные операции и структуру sudog.

Под капотом (internals)

Структура semaRoot (глобальная хеш-таблица для адресов семафоров). Парковка происходит через goparkunlock с передачей unlockf, которая отпускает блокировку таблицы. Пробуждение: semrelease находит ожидающего в списке, удаляет его и вызывает goready.

Примерно:

Go
func semacquire(addr *uint32) {
    for {
        if atomic.Load(addr) > 0 && atomic.Cas(addr, v, v-1) { return }
        // enqueue sudog, goparkunlock
    }
}

Типичные ошибки кандидатов

  1. Считать, что semacquire блокирует поток.
    Блокируется только горутина, M продолжает.
  2. Путать с системными семафорами.
    Это чисто userspace механизм.
  3. Думать, что семафоры используются только для мьютексов.
    Каналы и другие примитивы тоже их используют.

Возможные дополнительные вопросы интервьюера

  • В чём отличие semacquire от блокировки канала?
    Канал — более высокий уровень, но внутри использует тот же подход с sudog и семафорами.
  • Почему семафоры Go не подходят для прямого использования пользователем?
    Они не экспортируются как публичный API.

Что такое Container-Aware GOMAXPROCS в Go 1.25?

Краткий ответ (30–60 секунд)

В Go 1.25 планировщик стал автоматически распознавать ограничения процессора, заданные контейнеризацией (Container-Aware GOMAXPROCS). Рантайм читает квоты пропускной способности CPU из cgroups v2 (/sys/fs/cgroup/cpu.max) и выставляет значение GOMAXPROCS пропорционально выделенной квоте, округляя до целого числа (минимум 1). Это устраняет проблему троттлинга CFS в Kubernetes и делает ненужной популярную библиотеку uber-go/automaxprocs.

Развёрнутый ответ

Историческая проблема в Kubernetes:
Если под развёрнут на хосте со 128 ядрами CPU с лимитом resources.limits.cpu: "2", функция runtime.NumCPU() возвращала 128. Рантайм Go инициализировал GOMAXPROCS=128, создавая 128 потоков ОС ($M$), каждый из которых пытался выполнять горутины. Ядро Linux через планировщик CFS отслеживало квоту в 200 мс процессорного времени на 100-миллисекундный период: 128 потоков расходовали этот лимит за 1.5 миллисекунды, после чего весь процесс безжалостно замораживался ядром на оставшиеся 98.5 мс (CFS CPU Throttling). Для внешних пользователей это выглядело как резкие спайки латентности (p99 > 1000 мс).

С Go 1.25:
Рантайм при запуске читает /sys/fs/cgroup/cpu.max:

  • Если там заданы значения 200000 100000 (квота 200мс на период 100мс = 2 CPU), рантайм автоматически выставляет GOMAXPROCS=2.
  • Если сервис запускается с дробным CPU (например, 50000 100000 = 0.5 CPU), рантайм устанавливает безопасный минимум GOMAXPROCS=1.

Под капотом (internals)

Логика инициализации находится в src/runtime/os_linux.go в функции cgroupCPUQuota(). Рантайм проверяет монтирование cgroups v2, парсит cpu.max, делит квоту на период и округляет значение. Ручная установка через переменную окружения GOMAXPROCS или вызов runtime.GOMAXPROCS(n) по-прежнему имеет наивысший приоритет и переопределяет автоопределение cgroups.

Типичные ошибки кандидатов

  1. Поддержка cgroups v1: Автоопределение из коробки работает на современной версии cgroups v2. В старых дистрибутивах Linux с cgroups v1 рантайм откатывается к стандартному runtime.NumCPU().
  2. Использование сторонних костылей: Кандидаты продолжают советовать тащить uber-go/automaxprocs в проекты на Go 1.25+, не зная, что функционал стал нативной частью стандартного рантайма.