42. Планировщик и блокирующие syscalls
В статье 40. Как runtime обрабатывает системные вызовы мы поверхностно коснулись того, как системный монитор sysmon отрывает процессор P от заблокированного потока M (механизм Handoff). А в 41. cgo. Как Go взаимодействует с C выяснили, что вызовы C-кода идут именно по этому тяжелому пути синхронных блокировок.
Но для системного инженера и архитектора фраза «отрывает процессор» звучит слишком абстрактно и расплывчато. Планировщик Go — это строгая, математически выверенная конечная машина состояний (State Machine).
Чтобы научиться диагностировать и устранять скрытые всплески задержек (Latency Spikes P99), нам необходимо опуститься на уровень исходного кода рантайма (src/runtime/proc.go) и проследить каждый шаг горутин и потоков при их погружении в ядро операционной системы и возвращении обратно в пространство пользователя.
1. Погружение во тьму: entersyscall
Когда горутина (G) инициирует синхронный системный вызов (например, syscall.Read для локального файла на диске) или пересекает границу CGO, компилятор вызывает функцию reentersyscall (или стандартную entersyscall).
В этот момент рантайм производит подготовку к потенциально долгой блокировке:
- Статус G: Горутина меняет свой статус с
_Grunningна_Gsyscall. - Отвязка кэша аллокатора: Горутина сбрасывает прямую связь с локальным кэшем аллокатора
mcache(так как во время нахождения в пространстве ядра операционной системы нельзя аллоцировать память в куче Go). - Статус P: Логический процессор
Pменяет статус с_Prunningна_Psyscall. - Фиксация времени в sysmon: Процессор
Pсохраняет текущий такт планировщика (schedtick) и временную метку, чтобы фоновый монитор мог точно определить продолжительность блокировки.
stateDiagram-v2
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
direction LR
[*] --> _Prunning: Нормальное выполнение
_Prunning --> _Psyscall: entersyscall()
_Psyscall --> _Pidle: handoffp() (sysmon отрывает P)
_Psyscall --> _Prunning: exitsyscallfast() (быстрый возврат)
_Pidle --> _Prunning: Назначен новому M2
Ключевой нюанс: На этапе вызова
entersyscallфизический поток операционной системы (M) всё ещё владеет процессоромP! Рантайм проявляет инженерный оптимизм: большинство дисковых сисколлов (например, чтение данных, уже прогретых в Page Cache операционной системы) отрабатывают за сотни наносекунд. Немедленно отрыватьPи будить новый поток ОС было бы расточительством — переключение контекста ядра ОС заняло бы в разы больше времени, чем сам сисколл!
Пометив P как _Psyscall, поток M выполняет ассемблерную инструкцию SYSCALL и переходит в ядро Linux.
2. Кража процессора: Syscall Handoff
Если данные не оказались в оперативной памяти (Page Cache), дисковый контроллер усыпляет поток M1 в пространстве ядра. Поток спит, а его логический процессор P простаивает со статусом _Psyscall.
А ведь в локальной очереди выполнения этого P (массив runq) могут находиться еще 256 готовых к работе горутин! Каждая наносекунда их простоя — это прямая деградация производительности сервиса.
Раз в $20$ микросекунд (с адаптивным увеличением до $10$ миллисекунд при простое) просыпается фоновый страж — поток sysmon.
Он итерируется по глобальному массиву всех процессоров (allp) и инспектирует их состояние.
Если sysmon видит, что процессор находится со статусом _Psyscall дольше порогового интервала (или в его локальной очереди есть ждущие горутины), он инициирует процедуру Handoff (Передача процессора):
sysmonатомарно меняет статус процессораPс_Psyscallна_Pidle(свободен).- Вызывает функцию
handoffp(p). - Рантайм пытается найти свободный поток
Mв глобальном пуле спящих потоков (sched.midle). Если свободных потоков нет, рантайм порождает новый физический поток ОС через системный вызовclone. - Новый поток
M2забирает освободившийся процессорP, переводит его в рабочий статус_Prunningи немедленно начинает выполнять горутины из его локальной очереди.
Горутины спасены от голодания. Но что произойдет с первым потоком M1, когда ядро ОС разбудит его?
3. Возвращение из ядра: exitsyscall
Ядро Linux завершило операцию ввода-вывода и вернуло поток M1 в пространство пользователя.
Поток M1 готов возобновить выполнение своей горутины (G), но он лишен прав: у него больше нет привязанного процессора P. Без P поток не имеет права исполнять Go-код, так как ему недоступны локальный кэш памяти mcache, стек горутины и планировщик.
Поток вызывает функцию exitsyscall. Перед ним открываются три возможных пути:
flowchart TD
WAKEUP["Поток M1 просыпается<br>после Syscall"] --> FAST_PATH
subgraph ПУТИ ВОЗВРАТА
FAST_PATH{"Fast Path<br>Старый P свободен?"}
SLOW_PATH{"Slow Path<br>Есть ли любой свободный P?"}
FALLBACK["Fallback<br>Сдаться (exitsyscall0)"]
FAST_PATH -->|"Да (sysmon не успел его украсть)"| SUCCESS["M1 берет P и продолжает работу"]
FAST_PATH -->|"Нет"| SLOW_PATH
SLOW_PATH -->|"Да"| SUCCESS
SLOW_PATH -->|"Нет"| FALLBACK
end
FALLBACK -->|"1. G отправляется в Глобальную очередь"| G_GLOB["sched.runqput(G)"]
FALLBACK -->|"2. M1 засыпает в пуле Idle M"| M_SLEEP["mput(M1) -> спать"]
style FAST_PATH fill:transparent,stroke:#00aa00,stroke-width:2px
style SLOW_PATH fill:transparent,stroke:#aa8800,stroke-width:2px
style FALLBACK fill:transparent,stroke:#ff0000,stroke-width:2px
1. Fast Path (Быстрый путь)
Поток M1 проверяет свой прежний процессор P. Если системный вызов завершился молниеносно (например, данные были в кэше), поток sysmon просто не успел среагировать и запустить handoffp.
В этом случае прежний P всё еще привязан к M1. Поток атомарно возвращает статус процессора в _Prunning, статус горутины — в _Grunning, и немедленно продолжает выполнение с нулевыми накладными расходами!
2. Slow Path (Медленный путь)
Если прежний P уже был украден и отдан потоку M2, поток M1 опрашивает глобальный список свободных процессоров (sched.pidle).
Если в системе есть хотя бы один незанятый P (например, соседний поток только что завершил свои горутины и заснул), M1 привязывает этот свободный P к себе, прикрепляет его mcache и продолжает выполнение.
3. Fallback (Изгнание в Глобальную очередь)
Худший сценарий: сервер находится под 100% утилизацией процессора, и абсолютно все P заняты полезной работой.
Потоку M1 негде исполнять код. Он вызывает функцию exitsyscall0:
- Отвязывает от себя горутину
G. - Переводит горутину
Gв статус_Grunnable(готова к выполнению). - Помещает горутину в Глобальную очередь выполнения (Global Run Queue) под защитой глобального мьютекса планировщика
sched.lock. - Сам поток
M1паркуется и отправляется спать в глобальный пул свободных потоков (sched.midle).
4. Mechanical Sympathy: Глобальная очередь и правило 61
Когда наша горутина оказывается изгнана в Глобальную очередь выполнения, возникает прямая угроза Голодания (Starvation).
По умолчанию каждый процессор P берет задачи из своей локальной очереди (это не требует блокировок мьютексом и удерживает процессорный кэш L1/L2 горячим). Если локальные очереди постоянно пополняются новыми задачами, планировщик рискует никогда не заглянуть в Глобальную очередь.
Чтобы гарантировать справедливость планирования, разработчики рантайма Go внедрили элегантный механизм — Правило 61 тика.
Внутри структуры каждого логического процессора P ведется целочисленный счетчик запланированных горутин — поле schedtick. Алгоритм поиска работы функцией findrunnable начинается со строгой проверки:
// src/runtime/proc.go
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 1) // Забрать горутину из Глобальной очереди!
unlock(&sched.lock)
return gp, false, true
}
Каждый 61-й такт планировщика процессор обязан проигнорировать свою локальную очередь, захватить мьютекс sched.lock, заглянуть в Глобальную очередь и забрать оттуда задачу!
Почему именно 61?
61 — это простое число (Prime Number). Выбор простого числа предотвращает гармоническую синхронизацию между ядрами CPU: если в системе работает 8 или 16 процессоров P, вероятность того, что несколько процессоров одновременно разделят свой schedtick на 61 и одновременно ринутся захватывать глобальный мьютекс sched.lock, стремится к нулю. Это исключает аппаратный затор (Lock Contention) на шине памяти.
5. Thread Exhaustion: Предел потоков
Механизм Handoff обеспечивает феноменальную отзывчивость системы, но несет в себе скрытую угрозу при неграмотном архитектурном проектировании.
Представьте конфигурацию: GOMAXPROCS = 8 (8 процессоров P).
Разработчик запускает 1 000 горутин, и каждая из них одновременно выполняет синхронный вызов к сетевому диску или обращается к базе данных через CGO-библиотеку:
- Первые 8 потоков
Mвызывают сисколл и засыпают. sysmonвидит простой, отрывает 8 процессоровPи создает 8 новых потоков ОС, чтобы их обслужить.- Новые 8 потоков берут следующие 8 горутин и тоже блокируются в ядре...
sysmonснова отрываетPи спавнит еще 8 потоков ядра ОС...
В результате количество потоков ОС начинает лавинообразно расти. Каждый системный поток Linux резервирует от 2 до 8 МБ виртуального адресного пространства под свой системный стек. Тысяча потоков сожрет гигабайты памяти только на служебные структуры ядра ОС!
Чтобы уберечь операционную систему от исчерпания ресурсов (Thread Starvation) и ядра от Kernel Panic, рантайм Go устанавливает жесткий защитный барьер: 10 000 потоков ОС на один процесс.
Если sysmon попытается создать 10 001-й поток, рантайм мгновенно аварийно завершит программу:
fatal error: thread exhaustion
Итог
entersyscall: Горутина переходит в статус_Gsyscall, процессор — в_Psyscall. ПотокMвременно сохраняет владениеP, оптимистично надеясь на быстрый возврат из сисколла.- Handoff (
handoffp): Если сисколл затягивается, фоновыйsysmonотрываетPи передает его новому или спящему потокуM2, сохраняя параллелизм на уровнеGOMAXPROCS. exitsyscall: По возвращении из ядра поток пытается вернуть свойP(Fast Path) или захватить свободныйsched.pidle(Slow Path). Если свободныхPнет, горутина отправляется в Глобальную очередь (_Grunnable), а потокMзасыпает.- Правило 61 тика: Предотвращает голодание горутин в Глобальной очереди: каждый 61-й шаг процессор забирает работу из общего пула.
- Thread Exhaustion: Лавинообразные блокирующие сисколлы могут исчерпать потоки ОС и привести к крашу с ошибкой
fatal error: thread exhaustion(лимит 10 000).
Мы подробно рассмотрели ситуации, когда горутины добровольно уступают процессор: при системных вызовах (entersyscall), парковке на каналах (gopark), сетевом вводе-выводе (Netpoller) или явном вызове runtime.Gosched(). Это фундаментальный принцип кооперативной многозадачности.
Но что произойдет, если программист напишет жадный плотный цикл вычислений for { } без единого системного вызова, без вызовов функций и без аллокаций? Зависнет ли всё приложение? Как рантайм Go научился принудительно отбирать процессор у горутины с помощью сигналов ядра ОС?
В следующей статье мы разберем эволюцию механизма вытеснения в Go:
43. Preemption. Как Go останавливает горутины