Go under the hood
Go: Under the Hood

9.8 Системный монитор

Штатный путь планировщика описан в 9.4 Цикл планирования: один M привязывается к одному P, извлекает горутину из очереди, выполняет её, затем берёт следующую. Этот путь опирается на одно условие — что M вообще получает возможность работать. Но стоит всем P застрять в длительных системных вызовах, или какой-либо горутине уйти в бесконечный цикл и намертво удерживать P, — обычное планирование встаёт. Никто не забирает P обратно, никто не опрашивает сеть. Иными словами, кооперативная логика, выполняемая на P, не способна справиться с ситуацией, когда «сам P не может двигаться».

Ответ Go — завести вторую страховочную нить вне цикла планирования. При старте рантайма main вызывает newm, чтобы запустить специальный M, назначением которого является выполнение sysmon:

1
2
3
4
5
6
7
8
9
func main() {
	// ...
	if haveSysmon {
		systemstack(func() {
			newm(sysmon, nil, -1) // специальный M, который не привязывается к P
		})
	}
	// ...
}

Особенность этого M в том, что он не владеет P и никогда не входит в обычный цикл планирования. Он засыпает и пробуждается через собственный механизм уведомлений рантайма (futex на Linux), независимый от планировщика, поэтому даже если все P заблокированы, он всё равно просыпается и совершает обход. Именно это и есть паттерн «сторожевого пса» (watchdog): супервизор должен находиться вне наблюдаемой системы — только тогда он способен обнаружить её остановку.

9.8.1 Пульс: адаптивный ритм сна

sysmon — это цикл, который никогда не завершается. В каждом витке он просыпается, проверяет свои обязанности и засыпает снова. Главная сложность — определить, сколько спать. Долгий сон делает вытеснение и опрос сети вялыми; слишком частые пробуждения впустую жгут CPU, пока система простаивает. sysmon снимает это противоречие с помощью адаптивного отступа (backoff): энергичен при нагрузке, ленив при простое.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
//go:nowritebarrierrec
func sysmon() {
	lock(&sched.lock)
	sched.nmsys++ // зарегистрировать как системный M, "не участвующий в обнаружении дедлоков"
	checkdead()   // однократная проверка дедлоков при старте, см. 9.8.5
	unlock(&sched.lock)

	idle := 0       // количество последовательных витков, в которых "никого не разбудили" (ни вытеснения, ни инъекции G)
	delay := uint32(0)
	for {
		if idle == 0 {        // начинаем со сна 20 мкс
			delay = 20
		} else if idle > 50 { // после 50 пустых витков (>1мс простоя) удваиваем задержку
			delay *= 2
		}
		if delay > 10*1000 {  // предел — 10 мс
			delay = 10 * 1000
		}
		usleep(delay)
		// ... проверка различных обязанностей (см. ниже) ...
	}
}

Сон начинается с 20 μs20\,\mu s. Пока в каждом витке есть работа (успешное вытеснение или инъекция G в очередь), idle сбрасывается в ноль и sysmon остаётся на минимальном интервале, сохраняя чувствительность. Если более 50 последовательных витков не приносят ничего, sysmon считает систему перешедшей в простой и позволяет задержке расти удвоением — вплоть до предела в 10 ms10\,ms. Оба конца диапазона [20 μs,10 ms][20\,\mu s, 10\,ms] обоснованы: нижняя граница достаточно мала, чтобы своевременно вытеснять (порог вытеснения равен ровно 10 ms10\,ms, см. 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 (10 ms10\,ms), это означает, что выполняемая на нём горутина ни разу не уступила управление, и sysmon направляет запрос на её вытеснение:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const forcePreemptNS = 10 * 1000 * 1000 // 10 мс — квант времени, после которого G подлежит вытеснению

func retake(now int64) uint32 {
	// ... перебор allp ...
	if int64(pd.schedtick) != schedt {
		pd.schedtick = uint32(schedt) // schedtick изменился: планирование произошло, сбрасываем отсчёт
		pd.schedwhen = now
	} else if pd.schedwhen+forcePreemptNS <= now {
		preemptone(pp) // schedtick не менялся более 10 мс: запрос на вытеснение
		sysretake = true
	}
	// ...
}

preemptone (см. 9.7) лишь «запрашивает»; фактическая уступка зависит от готовности самой горутины к кооперации, а начиная с Go 1.14 может быть инициирована сигналом для асинхронного вытеснения. Однако если P застряла в системном вызове, preemptone бессильна: поток исполнения находится вне кода Go и никто не реагирует на запрос вытеснения. Именно здесь вступает в силу второй вид.

Второй вид — P, захваченная системным вызовом. Как только M входит в системный вызов, удерживаемый им P находится в состоянии выполнения, но никто не продвигает его вперёд; если это затягивается, горутины из локальной очереди этого P и доступные для кражи задачи замораживаются без дела. sysmon отзывает такой P и передаёт его другому M:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
	// обработка P, застрявшего в системном вызове, внутри retake (сокращено)
	thread, ok := setBlockOnExitSyscall(pp) // удержать поток, чтобы он не мог выйти из системного вызова прямо сейчас
	if !ok {
		goto done // уже вышел из системного вызова или состояние изменилось
	}
	// если локальная очередь пуста и в системе есть вращающиеся или простаивающие M — не торопиться с отзывом
	if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 &&
		pd.syscallwhen+10*1000*1000 > now {
		thread.resume()
		goto done
	}
	thread.takeP() // отобрать P
	thread.resume()
	handoffp(pp)   // передать P другому M (см. 9.5)

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 является страховкой на этом пути:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	// если сеть не опрашивалась более 10 мс, sysmon выполняет резервный опрос
	lastpoll := sched.lastpoll.Load()
	if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
		sched.lastpoll.CompareAndSwap(lastpoll, now)
		list, delta := netpoll(0) // неблокирующий вызов, возвращает список готовых G
		if !list.empty() {
			incidlelocked(-1)
			injectglist(&list) // добавить готовые G в глобальную очередь
			incidlelocked(1)
			netpollAdjustWaiters(delta)
		}
	}

Порог снова составляет 10 ms10\,ms: если с последнего опроса прошло больше этого интервала, sysmon выполняет один неблокирующий опрос и добавляет готовые горутины в очередь. Это гарантирует, что даже если вся программа занята вычислительно-интенсивным циклом, задержка ответа на сетевой ввод-вывод имеет верхнюю границу и не откладывается бесконечно.

9.8.4 Принудительный GC и возврат памяти

Ещё две обязанности, связанные с памятью, выполняются в рамках обхода sysmon. Первая — принудительный GC. Даже если программа аллоцирует медленно и не торопится достигать порога роста кучи, запускающего GC, рантайм не хочет, чтобы мусор, оставшийся после предыдущей сборки, залёживался бесконечно. В каждом витке sysmon проверяет, не превысило ли время с последнего GC значение forcegcperiod (по умолчанию 22 минуты), и если да — пробуждает постоянно присутствующую горутину forcegc, чтобы запустить очередной цикл сборки (логика триггера описана в 13.3):

1
2
3
4
5
6
7
8
	if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {
		lock(&forcegc.lock)
		forcegc.idle.Store(false)
		var list gList
		list.push(forcegc.g)
		injectglist(&list) // разбудить горутину forcegc, которая запустит GC
		unlock(&forcegc.lock)
	}

Важно: sysmon сам не выполняет GC — он лишь добавляет forcegc.g в очередь и позволяет обычному планировщику запустить её. Причина — жёсткое ограничение, сформулированное в начале раздела: sysmon не владеет P и может не иметь барьеров записи (//go:nowritebarrierrec), поэтому не может выполнять код GC, требующий кооперации барьеров записи. Его роль здесь — лишь будильник с таймером.

Вторая обязанность — возврат неиспользуемой памяти операционной системе (scavenge, см. 12.7 Аллокатор страниц). Здесь прослеживается эволюция, заслуживающая внимания. В ранних версиях sysmon напрямую очищал внутри цикла страницы кучи, не использовавшиеся некоторое время. Впоследствии рантайм вынес эту работу в отдельную фоновую горутину bgscavenge, которая регулирует темп в соответствии с давлением памяти, а sysmon отступил к роли простого пробудителя по запросу:

1
2
3
	if scavenger.sysmonWake.Load() != 0 {
		scavenger.wake() // только пробудить выделенную горутину bgscavenge
	}

Этот переход «от встроенного выполнения к простому пробуждению» — повторяющийся паттерн в рантайме 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 держит для него фундамент.

Дополнительная литература

  1. The Go Authors. runtime/proc.go (sysmon, retake, handoffp, preemptone, checkdead, forcePreemptNS, forcegcperiod). https://github.com/golang/go/blob/master/src/runtime/proc.go
  2. 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
  3. 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
  4. 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
  5. The Linux Kernel. Detecting Hung Tasks (khungtaskd, hung_task_panic). https://www.kernel.org/doc/html/latest/admin-guide/sysctl/kernel.html
  6. 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
  7. Эта книга: 9.7 Кооперация и вытеснение, 9.5 Управление потоками, 9.9 Опросчик сети, 16.1 Проверка дедлоков рантайма.