9.8 Системный монитор
Штатный путь планировщика описан в 9.4 Цикл планирования: один M привязывается к одному P, извлекает горутину из очереди, выполняет её, затем берёт следующую. Этот путь опирается на одно условие — что M вообще получает возможность работать. Но стоит всем P застрять в длительных системных вызовах, или какой-либо горутине уйти в бесконечный цикл и намертво удерживать P, — обычное планирование встаёт. Никто не забирает P обратно, никто не опрашивает сеть. Иными словами, кооперативная логика, выполняемая на P, не способна справиться с ситуацией, когда «сам P не может двигаться».
Ответ Go — завести вторую страховочную нить вне цикла планирования. При старте рантайма main вызывает newm,
чтобы запустить специальный M, назначением которого является выполнение sysmon:
|
|
Особенность этого M в том, что он не владеет P и никогда не входит в обычный цикл планирования. Он засыпает и пробуждается через собственный механизм уведомлений рантайма (futex на Linux), независимый от планировщика, поэтому даже если все P заблокированы, он всё равно просыпается и совершает обход. Именно это и есть паттерн «сторожевого пса» (watchdog): супервизор должен находиться вне наблюдаемой системы — только тогда он способен обнаружить её остановку.
9.8.1 Пульс: адаптивный ритм сна
sysmon — это цикл, который никогда не завершается. В каждом витке он просыпается, проверяет свои обязанности и засыпает снова. Главная сложность — определить, сколько спать. Долгий сон делает вытеснение и опрос сети вялыми; слишком частые пробуждения впустую жгут CPU, пока система простаивает. sysmon снимает это противоречие с помощью адаптивного отступа (backoff): энергичен при нагрузке, ленив при простое.
|
|
Сон начинается с . Пока в каждом витке есть работа (успешное вытеснение или инъекция G в очередь),
idle сбрасывается в ноль и sysmon остаётся на минимальном интервале, сохраняя чувствительность. Если более 50
последовательных витков не приносят ничего, sysmon считает систему перешедшей в простой и позволяет задержке расти
удвоением — вплоть до предела в . Оба конца диапазона обоснованы:
нижняя граница достаточно мала, чтобы своевременно вытеснять (порог вытеснения равен ровно ,
см. 9.8.2), а верхняя достаточно велика, чтобы почти не расходовать CPU
при простое.
Поверх backoff есть ещё один уровень — глубокий сон. При активном STW или когда все P простаивают
(sched.npidle == gomaxprocs), наблюдать не за чем, поэтому sysmon вызывает notetsleep и ждёт
срабатывания ближайшего таймера или явного пробуждения, полностью освобождая CPU. Если пробуждение
вызвано возвратом из системного вызова, значит приложение снова начало работать: sysmon сбрасывает
idle и delay в наиболее чувствительное состояние, исходя из предположения: «мы только что отозвали P
из системного вызова — велика вероятность, что скоро придётся делать это снова».
9.8.2 retake: вытеснение и отзыв
Первое, чем занимается sysmon после пробуждения, — retake. Функция обходит все P и реагирует на два вида
ситуаций «не желает уходить». Это и есть ядро роли sysmon как страховочного механизма вытесняющего планирования,
покрывающего слепые зоны, недостижимые для кооперативного вытеснения из 9.7.
Первый вид — горутина, монополизирующая P продолжительное время. Если некий P остаётся на одном и том же
schedtick дольше forcePreemptNS (), это означает, что выполняемая на нём горутина ни разу
не уступила управление, и sysmon направляет запрос на её вытеснение:
|
|
preemptone (см. 9.7) лишь «запрашивает»; фактическая уступка зависит от готовности самой
горутины к кооперации, а начиная с Go 1.14 может быть инициирована сигналом для асинхронного вытеснения. Однако
если P застряла в системном вызове, preemptone бессильна: поток исполнения находится вне кода Go и никто не
реагирует на запрос вытеснения. Именно здесь вступает в силу второй вид.
Второй вид — P, захваченная системным вызовом. Как только M входит в системный вызов, удерживаемый им P находится в состоянии выполнения, но никто не продвигает его вперёд; если это затягивается, горутины из локальной очереди этого P и доступные для кражи задачи замораживаются без дела. sysmon отзывает такой P и передаёт его другому M:
|
|
handoffp (см. 9.5 Управление потоками) находит для этого P новый M или, если работы
действительно нет, переводит P в простой. Здесь есть тонкий момент с конкурентностью: перед началом действий
необходимо вызвать incidlelocked(-1), имитируя наличие одного дополнительного работающего M, — иначе только что
лишённый P M, вернувшись из системного вызова и не обнаружив ни работы, ни других работающих M, ложно
сообщит о дедлоке. Отзыв не является безусловным: если локальная очередь P пуста и в системе есть вращающиеся
или простаивающие M, sysmon предпочтёт воздержаться, избегая бессмысленного переключения потока.
Когда retake выполняет полезную работу в витке, он сбрасывает idle в ноль, удерживая sysmon в чувствительном
режиме; когда работы нет — выполняет idle++, приближаясь к глубокому сну. Это значение является прямым
входом для backoff из 9.8.1.
9.8.3 Резервный опрос сети
В обычных условиях события о готовности сети забирает цикл планирования вызовом netpoll во время поиска работы
(см. 9.9 Опросчик сети). Но если все P заняты вычислениями и никто не опрашивает сеть достаточно
долго, горутины, уже готовые к выполнению из-за сетевых событий, будут голодать. sysmon является страховкой
на этом пути:
|
|
Порог снова составляет : если с последнего опроса прошло больше этого интервала, sysmon выполняет один неблокирующий опрос и добавляет готовые горутины в очередь. Это гарантирует, что даже если вся программа занята вычислительно-интенсивным циклом, задержка ответа на сетевой ввод-вывод имеет верхнюю границу и не откладывается бесконечно.
9.8.4 Принудительный GC и возврат памяти
Ещё две обязанности, связанные с памятью, выполняются в рамках обхода sysmon. Первая — принудительный GC.
Даже если программа аллоцирует медленно и не торопится достигать порога роста кучи, запускающего GC, рантайм
не хочет, чтобы мусор, оставшийся после предыдущей сборки, залёживался бесконечно. В каждом витке sysmon
проверяет, не превысило ли время с последнего GC значение forcegcperiod (по умолчанию минуты), и если
да — пробуждает постоянно присутствующую горутину forcegc, чтобы запустить очередной цикл сборки (логика
триггера описана в 13.3):
|
|
Важно: sysmon сам не выполняет GC — он лишь добавляет forcegc.g в очередь и позволяет обычному
планировщику запустить её. Причина — жёсткое ограничение, сформулированное в начале раздела: sysmon не владеет P
и может не иметь барьеров записи (//go:nowritebarrierrec), поэтому не может выполнять код GC, требующий
кооперации барьеров записи. Его роль здесь — лишь будильник с таймером.
Вторая обязанность — возврат неиспользуемой памяти операционной системе (scavenge, см.
12.7 Аллокатор страниц). Здесь прослеживается эволюция, заслуживающая
внимания. В ранних версиях sysmon напрямую очищал внутри цикла страницы кучи, не использовавшиеся некоторое
время. Впоследствии рантайм вынес эту работу в отдельную фоновую горутину bgscavenge, которая регулирует темп
в соответствии с давлением памяти, а sysmon отступил к роли простого пробудителя по запросу:
|
|
Этот переход «от встроенного выполнения к простому пробуждению» — повторяющийся паттерн в рантайме Go: трудоёмкая
работа, требующая тщательного темпорального управления, выносится из sysmon — этой общей страховочной нити — в
независимую горутину, тогда как sysmon сохраняет лишь лёгкую обязанность «дать толчок». Это позволяет sysmon
всегда работать быстро, не замедляясь ни одной отдельной задачей. Примерно в то же время появилась ещё одна
обязанность — sysmonUpdateGOMAXPROCS, выполняемая не чаще одного раза в секунду: динамическая корректировка
GOMAXPROCS в соответствии с квотой CPU контейнера — тоже лёгкая работа «проверить и подправить».
9.8.5 Обнаружение дедлоков
checkdead отвечает на конкретный вопрос: если все горутины заблокированы и никто не может продвинуться вперёд,
программа должна сообщить fatal error: all goroutines are asleep - deadlock! вместо того, чтобы
молча зависнуть. Функция подсчитывает работающие и заблокированные M в системе, а также ожидающие таймеры и
сетевых ожидателей; если никого нельзя пробудить — объявляет дедлок.
Стоит развеять распространённое заблуждение: обнаружение дедлоков — не обязанность, которую sysmon проверяет в
каждом витке. checkdead вызывается преимущественно в точках смены состояния, например когда M переходит
в простой: «последний, кто ложится спать», пересчитывает присутствующих на выходе. sysmon вызывает checkdead
лишь однажды — при старте — и участвует во всём механизме как один из его элементов (см.
16.1 Проверка дедлоков рантайма). Причина его упоминания в этом
разделе в том, что sysmon — этот «системный M» — должен зарегистрировать себя в nmsys, объявив: «я не
считаюсь живым потоком для целей обнаружения дедлоков», — иначе само его существование не даст checkdead
когда-либо зафиксировать дедлок.
9.8.6 Карта обязанностей и итоги проектирования
Сведём все обязанности на одну диаграмму — полная картина одного обхода sysmon выглядит следующим образом:
flowchart TD
START["sysmon пробуждается<br/>адаптивный сон 20 мкс ~ 10 мс"] --> STW{STW или все P простаивают?}
STW -->|да| DEEP["глубокий сон notetsleep<br/>ожидание таймера / явного пробуждения"]
DEEP --> START
STW -->|нет| RETAKE["retake: обход всех P"]
RETAKE --> LONG["G монополизирует P дольше 10 мс<br/>preemptone запрашивает вытеснение (9.7)"]
RETAKE --> SYS["P застряла в системном вызове надолго<br/>takeP + handoffp отзывает его (9.5)"]
LONG --> NET["с последнего опроса прошло более 10 мс<br/>резервный netpoll, инъекция G (9.9)"]
SYS --> NET
NET --> GC["с последнего GC прошло более 2 минут<br/>пробуждение forcegc (13.3)"]
GC --> SCAV["пробудить bgscavenge при наличии запроса (12.7)"]
SCAV --> LOOP["скорректировать idle по итогам витка, возврат ко сну"]
LOOP --> START
В контексте промышленной традиции «завести отдельный надзорный поток вне системы» — не изобретение Go. В JVM HotSpot
есть WatcherThread, периодически выполняющий различные задачи по расписанию (семплирование, статистика,
таймаутные коллбэки) — он наиболее близок к пульсирующей роли sysmon; тогда как VMThread посвящён
точкам безопасности (safepoints) и STW-координации, что ближе к той части Go, которая инициирует STW,
а не к sysmon. В ядре Linux также есть два сторожевых механизма: soft-lockup (watchdog/N, использующий
высокоточные таймеры для обнаружения того, что CPU перестал реагировать на планирование) и hung-task
(khungtaskd, обнаруживающий задачи, надолго застрявшие в состоянии D).
sysmon отличается от этих сторожей ядра по одному ключевому пункту: сторожи ядра (lockup и hung-task) только обнаруживают и только сигнализируют (выводят стеки, вызывают панику согласно конфигурации) — они диагностируют проблемы, но не устраняют их. sysmon же обнаруживает и действует: он вытесняет найденную долго-работающую G, отзывает и переназначает найденный замороженный P, опрашивает голодающую сеть. Это делает его не пассивным зондом состояния, а активным страховочным планировщиком — более полным вариантом типичного надзорного потока.
Таково место sysmon в общей архитектуре планирования. Обычное планирование идёт по кооперативному, малозатратному быстрому пути: горутина отдаёт P в естественных точках уступки — при вызовах функций и операциях с каналами — практически без накладных расходов. Но чистая кооперация имеет слепые зоны: бесконечные циклы, заблокированные системные вызовы, игнорируемая сеть, куча, которая больше не растёт. Имея единственную, независимую страховочную нить, которую ни один P не способен захватить, sysmon закрывает эти слепые зоны одну за другой, добавляя слой вытесняющего резервирования поверх дешевизны кооперации. Выгода в производительности никогда не достаётся бесплатно: быстрый путь может быть настолько лёгким именно потому, что на медленном пути sysmon держит для него фундамент.
Дополнительная литература
- The Go Authors. runtime/proc.go (
sysmon,retake,handoffp,preemptone,checkdead,forcePreemptNS,forcegcperiod). https://github.com/golang/go/blob/master/src/runtime/proc.go - The Go Authors. runtime/mgcscavenge.go (
bgscavenge,scavenger.wake,sysmonWake, the dedicated goroutine that returns idle memory). https://github.com/golang/go/blob/master/src/runtime/mgcscavenge.go - Dmitry Vyukov, Austin Clements et al. Non-cooperative goroutine preemption (proposal #24543, the cooperation between sysmon preemption and signal preemption). https://go.googlesource.com/proposal/+/master/design/24543-non-cooperative-preemption
- The Linux Kernel. Software and Hardware Lockup Detector (soft-lockup / hard-lockup watchdogs). https://www.kernel.org/doc/html/latest/admin-guide/lockup-watchdogs.html
- The Linux Kernel. Detecting Hung Tasks (
khungtaskd,hung_task_panic). https://www.kernel.org/doc/html/latest/admin-guide/sysctl/kernel.html - OpenJDK / HotSpot. WatcherThread and VMThread source (
src/hotspot/share/runtime/,watcherThread.cpp,vmThread.cpp). https://github.com/openjdk/jdk/tree/master/src/hotspot/share/runtime - Эта книга: 9.7 Кооперация и вытеснение, 9.5 Управление потоками, 9.9 Опросчик сети, 16.1 Проверка дедлоков рантайма.