8. Планировщик ОС. Как CPU выбирает, что исполнять
Фундамент: Кто решает, кто исполняется?
«Цель вычислений — понимание, а не числа. А цель планировщика — справедливость, а не судорожная спешка».
— Вольная перефразировка Ричарда Хэмминга
Когда вы пишете код на Go, ваш разум оперирует абстракциями высокого полета: легкими горутинами, каналами, контекстами и неблокирующим вводом-выводом. Кажется, будто сотни тысяч задач парят в воздухе сами по себе.
Но процессор из кремния и меди ничего не знает о ваших горутинах. Арифметико-логические устройства (ALU) ядра CPU выполняют исключительно машинные инструкции системных потоков (threads) операционной системы.
Планировщик ОС (OS Scheduler) — это верховный арбитр и дирижер ядра, который ежесекундно решает, какой именно системный поток получит доступ к физическому ядру CPU прямо сейчас, на сколько наносекунд ему будет предоставлен процессор и когда его бесцеремонно вытеснят ради других задач.
Для Go-инженера понимание планировщика ОС жизненно необходимо по трем причинам:
- Рантайм Go не управляет физическим кремнием напрямую. Он лишь запрашивает у ядра операционной системы пул системных потоков (
GOMAXPROCS), а затем раскладывает свои горутины по этим потокам. Если планировщик ОС решит снять поток вашего Go-приложения с ядра, все тысячи горутин на этом потоке моментально замирают, даже если сервер кажется ненагруженным. - Производительность бэкенда привязана к локальности кэшей. Когда планировщик ядра хаотично мигрирует поток с ядра 0 на ядро 7, процессор теряет горячие кэш-линии L1/L2 и сбрасывает кэш трансляции адресов TLB. Это стоит тысячи тактов впустую.
- Поведение
time.Sleep, блокировок и IO напрямую зависит от того, как планировщик ядра рассчитывает приоритеты и управляет очередями исполнения (runqueues).
Исторически стандартом де-факто в Linux долгие годы был CFS (Completely Fair Scheduler). Однако в современных ядрах Linux (начиная с Linux 6.6+) на смену ему пришел еще более совершенный алгоритм EEVDF (Earliest Eligible Virtual Deadline First). Давайте разберем эту эволюцию от фундаментальных принципов до современного железа.
Алгоритм CFS: Справедливость через vruntime
Исторические планировщики делили процессорное время на жесткие кванты (Time Slices, например, по 10 или 50 миллисекунд). Это приводило к рывкам: интерактивные задачи тормозили, а фоновые расчеты забирали слишком много непрерывного времени.
CFS (Completely Fair Scheduler), созданный Инго Молнаром для Linux 2.6.23, ввел революционную идею: идеального многозадачного процессора (Ideal Multi-tasking CPU). В идеальном мире, если у вас есть $N$ задач, процессор делил бы свою мощность ровно на $N$ частей, исполняя их одновременно со скоростью $1/N$. В реальности ядро должно квантовать время, поэтому CFS отслеживает, сколько виртуального процессорного времени получила каждая задача. Эта метрика называется virtual runtime (vruntime).
Золотое правило CFS предельно лаконично: процессор всегда отдается той задаче, у которой значение vruntime наименьшее.
КРАСНО-ЧЕРНОЕ ДЕРЕВО ЗАДАЧ CFS (RB-TREE)
[Задача C]
vruntime=14ms
/ \
[Задача B] [Задача D]
vruntime=8ms vruntime=22ms
/
┌────────────────┐
│ ★ [Задача A] │ <-- rb_first (Крайний левый узел)
│ vruntime=3ms │ ВСЕГДА ВЫБИРАЕТСЯ СЛЕДУЮЩЕЙ!
└────────────────┘
Как работает vruntime?
Каждое ядро CPU имеет свой собственный дескриптор очереди исполнения runqueue. Когда задача выполняется на ядре, ее vruntime монотонно увеличивается. Однако скорость этого роста обратно пропорциональна приоритету задачи (значению nice):
vruntime_{next} = vruntime_{current} + (delta_exec * NICE_0_LOAD) / load.weight
Разберем эту формулу по косточкам:
delta_exec— реальное физическое время, которое задача только что провела на CPU (в наносекундах).NICE_0_LOAD— константа базового веса задачи с нейтральным приоритетомnice = 0(в ядре Linux равна 1024).load.weight— вес текущей задачи, вычисленный на основе ее nice-значения.- Чем выше приоритет задачи (отрицательный
nice, например-5), тем выше ееweight. В результате дробь уменьшается, и ееvruntimeрастет медленнее. Задача дольше остается в левой части дерева и чаще получает процессор! - Для задач с низким приоритетом (положительный
nice, например+10)weightмал, дробь велика, иvruntimeвзлетает как ракета, быстро отбрасывая задачу в правую часть очереди.
- Чем выше приоритет задачи (отрицательный
CFS также использует системные пороги сглаживания sched_latency_ns (период, за который все готовые задачи должны получить хотя бы один квант, обычно ~6 мс) и sched_min_granularity_ns (минимальный неделимый квант времени задачи на CPU, обычно ~0.75 мс). Это защищает процессор от вырожденного случая, когда сотни задач переключались бы каждую микросекунду, сжигая все ресурсы на Context Switch.
Context Switch: Что происходит за кулисами
Когда таймер процессорных прерываний сигнализирует о завершении кванта или текущий поток добровольно засыпает в ожидании I/O, ядро активирует центральную функцию планирования — schedule().
Это тяжелейшая системная операция, включающая пять обязательных шагов:
- Сохранение контекста текущего потока: Все регистры общего назначения CPU (
RAX,RBX,RCX,RSI,RDI,RBP,RSP,RIP, регистр флаговRFLAGS) и регистры векторных расширений AVX/FPU сохраняются в структуруthread_infoи стек ядра текущего потока. - Смена стека ядра: Указатель стека ядра
RSPперезаписывается на значение стека ядра выбранного нового потока. - Смена адресного пространства (MMU): Если новый поток принадлежит другому процессу, ядро обязано загрузить физический адрес новой таблицы страниц PML4 в системный регистр процессора
CR3. Это действие инициирует сброс TLB (Translation Lookaside Buffer), лишая процессор кэшированных переводов виртуальных адресов в физические. - Обновление очередей планировщика: Текущий поток с обновленным
vruntimeзаново балансируется в дереве CFS/EEVDF, а если задача мигрирует на другое ядро — переносится вrunqueueцелевого процессора. - Загрузка контекста нового потока: Регистры нового потока вычитываются из его памяти, и процессор совершает аппаратный прыжок (
JMP/IRET) по адресу сохраненногоRIP.
flowchart TD
A["Таймер прерывание (Tick) или вызов sched_yield/I/O"] --> B["Переход в ядро: Сохранение регистров в thread_info"]
B --> C{"Разные процессы?"}
C -- "Да" --> D["Смена CR3 и сброс TLB (дорого)"]
C -- "Нет (потоки одного процесса)" --> E["Адресное пространство остается прежним"]
D --> F["Обновление runqueues CFS / пересчет vruntime"]
E --> F
F --> G["Загрузка контекста нового потока"]
G --> H["Возврат в пользовательский код (User Space)"]
classDef step fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
class A,B,C,D,E,F,G,H step;
Цена вопроса: Один Context Switch на современных архитектурах x86-64 и ARM64 стоит от 1000 до 5000 тактов CPU (от 1 до 5 микросекунд). Если сервер выполняет 100 000 недобровольных переключений контекста в секунду, до 30–50% всей вычислительной мощности машины уходит на бесполезное перекладывание регистров и разогрев холодного кэша!
Go Runtime и OS Scheduler: Границы взаимодействия
Создатели Go понимали эту проблему лучше многих. Поэтому рантайм Go выстроен так, чтобы свести прямое обращение к планировщику ОС к абсолютному минимуму.
Модель G-M-P
В рантайме Go действует трехуровневая модель абстракции:
- G (Goroutine): Легковесная единица исполнения с динамическим стеком от 2 КБ.
- M (Machine): Реальный системный поток ОС (
task_struct), созданный вызовом ядраclone(). - P (Processor): Логический контекст выполнения, пул ресурсов для запуска Go-кода. Количество
Pстрого ограничено переменнойGOMAXPROCS.
Планирование горутин внутри Go происходит полностью в пользовательском пространстве (User Space) через функции рантайма runtime.schedule и findrunnable:
- CPU-bound задачи: Все логические процессоры
Pнепрерывно заняты. Горутины переключаются за десятки наносекунд без участия ядра ОС. Планировщик Linux видит просто стабильный набор изNпотоков, равномерно загружающих доступные ядра. - IO-bound задачи: Когда горутина блокируется (чтение из сетевого сокета, ожидание канала или системный вызов), рантайм вызывает функцию
gopark(). Горутина отцепляется от потока, а освободившийся потокmтут же подхватывает следующую готовую задачу. Если же потокmнамертво заблокирован в синхронном системном вызове дискового ввода-вывода (блокирующийsyscall), рантайм Go временно отсоединяет от него процессорPи создает (или берет из пула) новый потокmдля продолжения работы остальных горутин.
NUMA и миграция потоков
На современных двухсокетных или чиплетных серверах память разделена на NUMA-узлы (Non-Uniform Memory Access). Обращение к локальной оперативной памяти, распаянной возле сокета текущего CPU, занимает порядка 70–100 нс (это обращение к DRAM; ~20 нс — масштаб кэша L3), а чтение из памяти соседнего сокета через шину межпроцессорного соединения (UPI / Infinity Fabric) заметно дороже (порядка сотни наносекунд и выше, в зависимости от платформы)!
Если планировщик ОС решит перебросить поток m на процессор другого NUMA-узла, локальность кэша разрушается. Для сверхкритичных Go-приложений с ультранизкой задержкой сервисы запускают с жесткой привязкой к NUMA-домену:
numactl --cpunodebind=0 --membind=0 ./my_service
Практика и отладка
Как бэкенд-разработчику собственными глазами увидеть влияние планировщика Linux на Go-сервис?
-
Проверка привязки к конкретному ядру CPU:
Bash # Вывести PID, имя процесса и номер текущего ядра CPU (PSR) ps -eo pid,comm,psr | grep myapp -
Анализ типов переключения контекста:
Bash # Метрики планировщика в виртуальной файловой системе /proc cat /proc/$(pgrep myapp)/sched | grep -E "voluntary|involuntary"Либо воспользуйтесь классической утилитой
pidstat:Bash pidstat -w -p $(pgrep myapp) 1Здесь критически важно различать два значения:
voluntary(добровольные переключения): Поток сам уступил ядро, заблокировавшись на системном вызове, ожидании сокета или таймере. Это нормальное рабочее состояние бэкенда.involuntary(недобровольные вытеснения): Поток хотел продолжать вычисления, но планировщик ядра насильно согнал его с CPU, так как истек выделенный квант времени или пришла более приоритетная задача.
-
Диагностика проблем в Go: Если счетчик
involuntaryпереключений зашкаливает (тысячи в секунду на поток), ваше приложение страдает от борьбы за процессор (CPU Contention). Типичные причины:- Несоответствие
GOMAXPROCSреальным лимитам контейнера в Kubernetes. - Запуск CPU-bound микросервиса на виртуальной машине с Oversubscription (переподпиской vCPU хоста).
- Активная работа шумных соседей по серверу (демоны сборки логов, мониторинг, сторонние фоновые агенты).
- Несоответствие
Итог
- Планировщик ОС — верховный судья времени CPU. Классический Linux опирался на CFS с расчетом виртуального времени
vruntime, а современные ядра (6.6+) переходят на алгоритм EEVDF для лучшего баланса отклика и производительности. - Context Switch — дорогая аппаратная операция. Перезапись регистра
CR3, сброс TLB и вымывание кэшей CPU сжигают тысячи тактов на каждое переключение между процессами. - Рантайм Go минимизирует вмешательство ядра: через модель G-M-P и локальные очереди горутины сменяют друг друга за наносекунды в User Space.
- Следите за контейнерами: в Go 1.25+ (при
go 1.25и выше вgo.mod) рантайм сам учитывает квоты CFS; для более старых языковых версий используйтеautomaxprocsв сервисах под Kubernetes, чтобы рантайм Go не конфликтовал с квотами CFS ядра. - Привязка потоков через
runtime.LockOSThread()должна быть хирургически точной и редкой мерой, иначе вы парализуете масштабируемость планировщика Go.
Мы выяснили, как ядро выбирает поток для исполнения. Но почему само аппаратное переключение между потоками столь болезненно бьет по кэшам микропроцессора? В следующей статье мы разберем анатомию этого явления на уровне кремния: 9. Context Switch. Почему переключение между потоками дорого.