43. Preemption. Как Go останавливает горутины
В прошлых статьях мы рассматривали ситуации, когда горутины отдают процессор P добровольно (или полудобровольно). Системные вызовы, чтение из сетевого сокета через Netpoller, блокировка на мьютексе или ожидание данных из канала — во всех этих сценариях рантайм заранее знает, что горутина не может продолжать работу, и планировщик выполняет переключение контекста. Это классическая Кооперативная многозадачность (Cooperative Multitasking).
Но что произойдет, если программист случайно или намеренно напишет следующий фрагмент кода:
func main() {
runtime.GOMAXPROCS(1)
go func() {
// Фоновая задача
fmt.Println("Я работаю!")
}()
// Бесконечный плотный цикл без вызовов функций и аллокаций памяти
for {
// Чистая математика в регистрах CPU
i := 0
i++
}
}
В ранних версиях Go этот код приводил к полной катастрофе.
Фоновая горутина никогда не напечатала бы текст! Главная горутина монопольно захватывала единственный логический процессор P и бесконечно крутилась на ядре CPU, потому что в теле цикла нет ни каналов, ни системных вызовов, ни переключений.
Хуже того: в такой программе не мог запуститься сборщик мусора (GC)! Для перехода в фазу разметки или завершения Stop-The-World (STW) рантайму требуется перевести абсолютно все горутины в состояние паузы на безопасной точке (Safe Point). Если хотя бы одна горутина бесконечно крутится в цикле, не отдавая процессор, GC будет ждать её вечно. Процесс намертво зависал, а очередь запросов переполнялась до падения по Out Of Memory.
Чтобы язык мог работать в реальных серверных условиях, планировщик обязан уметь отбирать процессор у «жадной» горутины принудительно. Этот фундаментальный механизм называется Вытеснением (Preemption).
Эра 1: Кооперативное вытеснение (до Go 1.14)
Создатели Go изначально стремились избежать классического вытеснения потоков ОС по таймеру (как в планировщике ядра Linux CFS), поскольку обработка аппаратного прерывания таймера на каждый квант времени — относительно тяжелая операция, снижающая общую пропускную способность (Throughput) микро-горутин.
Поэтому в версиях Go от 1.0 до 1.13 действовала хитроумная схема: вытеснение маскировалось под рутинную проверку размера стека.
Как мы помним из статьи про стек горутины, компилятор Go генерирует в прологе (самом начале) практически каждой функции короткую ассемблерную инструкцию проверки:
CMPQ SP, 16(R14) // Сравнение SP со значением stackguard0
JBE morestack // Если SP < stackguard0, прыгаем в выделение памяти
Она проверяет, хватает ли горутине свободного места на стеке (SP < stackguard0). Если места недостаточно, вызывается ассемблерный трамплин runtime.morestack для выделения нового блока памяти.
Разработчики рантайма применили изящный трюк («отравление стека»):
- Фоновый системный поток
sysmonсканирует все логические процессорыP. - Если он видит, что какая-то горутина выполняется непрерывно дольше 10 миллисекунд, он помечает её как нарушителя.
- Поток
sysmonзаписывает в полеstackguard0этой горутины специальное константное служебное значение — маркерstackPreempt(равный0xfffffffffffffadeна 64-битных системах). - Когда ничего не подозревающая горутина вызывает любую следующую функцию, процессор выполняет стандартную проверку пролога.
- Увидев огромное значение
stackPreempt, процессор решает, что стек горутины переполнен, и вызываетruntime.morestack. - Функция
runtime.morestackинспектирует адрес и понимает: переполнения нет, это сигнал принудительного вытеснения! Она вызываетruntime.gosched(), отвязывает горутину от процессораP, переводит её в статус_Grunnableи отправляет в Глобальную очередь выполнения. ПроцессорPберет другую задачу.
flowchart LR
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
SYSMON["sysmon видит G > 10 мс"]:::process -->|"Запись stackPreempt в стек"| POISON["g.stackguard0 = 0xfffffffffffffade"]
POISON --> NEXT_CALL["Горутина вызывает любую функцию foo()"]
NEXT_CALL --> CHECK{"SP < stackguard0?"}
CHECK -- "Да (переполнение?)" --> MORE["runtime.morestack()"]
MORE --> GOSCHED["Распознан сигнал вытеснения:<br>runtime.gosched() -> уход в очередь"]
В чем был фатальный изъян?
Кооперативное вытеснение срабатывало исключительно при вызове функций!
Если горутина выполняла тяжелый математический расчет, матричное умножение, криптографический хэш или алгоритм сжатия в цикле for, и все вспомогательные функции были встроены компилятором (Inlined), в сгенерированном машинном коде не было ни одного пролога функции.
Горутина становилась неуязвимой для планировщика. Паузы Stop-The-World для сборки мусора растягивались на секунды, превращая сервис с низкими задержками в застывшую глыбу.
Эра 2: Асинхронное вытеснение (Go 1.14+)
Проблему бесконечных циклов требовалось решить раз и навсегда. В Go 1.14 инженер Google Остин Клементс (Austin Clements) реализовал полноценное Некооперативное асинхронное вытеснение (Asynchronous Preemption) на основе сигналов операционной системы.
Теперь рантайм Go не ждет милости от горутины. Он прерывает исполнение любого пользовательского кода прямо посреди цикла на аппаратном уровне.
flowchart TD
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
subgraph M ["Поток ОС (M) с процессором (P)"]
G["Горутина крутит плотный цикл for { ... }<br>(нет вызовов функций)"]
end
SYSMON["Фоновый поток sysmon"] -->|"G выполняется > 10 мс.<br>Системный вызов tgkill(tid, SIGURG)"| M
M -.->|"Ядро Linux прерывает поток"| HANDLER["Обработчик сигнала Go<br>(runtime.doSigPreempt)"]
HANDLER --> CHECK{"Безопасна ли инструкция?<br>(Safe-Point: не в рантайме, не в lock)"}
CHECK -->|"Нет (внутри malloc или барьера)"| SKIP["Отложить: прервать на следующем шаге"]
CHECK -->|"Да"| HIJACK["Модификация сохраненного контекста CPU:<br>Подмена RIP/PC на runtime.asyncPreempt"]
HIJACK --> G_SCHED["Инструкция IRET возвращает CPU в asyncPreempt:<br>Сохранение регистров -> runtime.gosched()"]
Как работает прерывание (на примере Linux/macOS)
- Сигнал
SIGURG: Когда потокsysmonфиксирует, что горутина непрерывно занимает процессор больше 10 мс, он выполняет системный вызовtgkill(на Linux) илиpthread_kill(на macOS), отправляя сигналSIGURGконкретному потоку ядра ОС (M), исполняющему данную горутину.
(Почему разработчики выбрали именно сигналSIGURG? ИсторическиSIGURGпредназначен для уведомления о поступлении внеполосных данных по сокету (Out-Of-Band Data). В современных серверных приложениях он практически никогда не используется, что позволяет рантайму Go монопольно задействовать его, не конфликтуя с пользовательскими обработчиками сигналовSIGUSR1,SIGALRMилиSIGINT). - Прерывание ОС: Ядро Linux перехватывает поток
M, сохраняет все аппаратные регистры процессора в стек сигнального фрейма (ucontext_t) и передает управление встроенному обработчику сигналов рантайма —runtime.doSigPreempt. - Безопасные точки (Safe Points): Рантайм не имеет права вытеснить горутину абсолютно в любой момент. Если процессор в момент прерывания выполнял служебный код аллокатора памяти (
mallocgc), удерживал внутренний спинлок планировщика, выполнял барьер записи GC или производил манипуляции черезunsafe.Pointer, прерывание приведет к фатальной порче метаданных. Обработчик проверяет текущий счетчик командPC(Program Counter) по компиляторной карте безопасных точек: если точка небезопасна, сигнал мягко игнорируется до следующей попытки. - Подмена контекста (Context Hijacking): Если точка безопасна, обработчик сигнала совершает элегантную модификацию сохраненного контекста в стеке ядра: он перезаписывает сохраненный адрес возврата (регистр
RIPна x86-64 илиPCна ARM64), подставляя туда адрес внутренней функцииruntime.asyncPreempt. - Сдача процессора: Когда ядро завершает обработку сигнала и возвращает управление в пространство пользователя, процессор вместо продолжения пользовательского цикла переходит в
runtime.asyncPreempt! Эта функция аккуратно сохраняет в стек абсолютно все регистры процессора (включая регистры общего назначения, флаги и векторные регистры SSE/AVX), вызываетruntime.gosched()и отправляет горутину в очередь. ПроцессорPмгновенно освобождается для других задач.
Результат: Задержки сборщика мусора Stop-The-World стали стабильными и предсказуемыми (доли миллисекунды), а зависание программы из-за «горячих» циклов вычислений ушло в историю.
Mechanical Sympathy: Стоит ли бояться бесконечных циклов?
Появление асинхронного вытеснения освободило разработчиков от необходимости вручную расставлять вызовы runtime.Gosched() в теле тяжелых циклов обработки данных.
Однако системный инженер должен учитывать аппаратную цену этого механизма:
Асинхронное вытеснение — ресурсоемкая системная операция.
Генерация сигнала ядром ОС, сохранение десятков регистров процессора в ucontext_t, инвалидация процессорного конвейера и переключение колец защиты Ring 0 / Ring 3 сжигают тысячи тактов CPU.
Если ваш сервис выполняет сложные численные расчеты, старайтесь проектировать их так, чтобы горутины естественно и кооперативно освобождали ресурсы (например, читали или писали в буферизированные каналы каждые несколько миллисекунд), не доводя планировщик до необходимости стрелять сигналами SIGURG.
Итог
- Кооперативное вытеснение (до Go 1.14): Опиралось на проверку стека в прологах функций (
morestack). Фоновыйsysmonподменялstackguard0на маркерstackPreempt. Механизм был бессилен против плотных циклов без вызовов функций. - Асинхронное вытеснение (Go 1.14+): Базируется на системных сигналах операционной системы (
SIGURGна Linux/macOS) и перехвате контекста потока (SuspendThread/SetThreadContextна Windows). - При непрерывной работе горутины $> 10$ мс
sysmonотправляет целевому потоку сигнал прерывания черезtgkill. - Обработчик сигнала на лету подменяет сохраненный счетчик команд
PC/RIPна адрес ассемблерной функцииruntime.asyncPreempt. - Функция
asyncPreemptсохраняет состояние регистров и вызываетruntime.gosched(), гарантируя предсказуемость времени отклика (Soft Real-Time) и минимальные паузы STW для GC.
Мы завершили огромный фундаментальный блок, посвященный внутреннему устройству рантайма, памяти, планировщика и взаимодействию с операционной системой. Мы видели, как компилятор вставляет вызовы morestack, как он оптимизирует defer и как он собирает структуру itab для интерфейсов.
Но как весь этот колоссальный механизм — наш прикладной код, стандартная библиотека, планировщик и сборщик мусора — упаковывается в единый монолитный исполняемый файл операционной системы? Какую роль в этом играет компилятор и линкер (компоновщик)?
В следующей статье мы разберем анатомию сборки исполняемых бинарников:
44. Linker и Build Modes