ОС · Виртуализация CPU · 15 мин
Пропорциональное планирование и CFS
Другая постановка задачи
В главе про метрики мы гнались за оборотом (как быстро задача завершится) и откликом (как быстро она впервые получит CPU). А в MLFQ — пытались угадать, кто интерактивный, кто счётный, и раздавать приоритеты.
Теперь сменим вопрос. Допустим, нам не так важно, кто закончит первым. Важно справедливо поделить CPU в нужной пропорции. Например: «сервису A дать 50% процессора, сервису B — 30%, фоновым джобам C — 20%». Это и есть пропорциональное планирование (proportional-share, оно же fair-share).
Бытовая аналогия: вы делите интернет-канал в квартире. Не «кто первый качает, тому всё», а «геймеру 50% скорости, рабочему ноуту 30%, телефону 20%». Кто сколько заплатил долей — столько в среднем и получает.
Обычный планировщик Пропорциональный планировщик
(scheduling / mlfq) (lottery / stride / CFS)
«кто закончит быстрее?» «кто какую ДОЛЮ CPU получит?»
цель: turnaround / response цель: A=50%, B=30%, C=20%
угадываем длину/тип явно задаём вес/билетыЧто нарисовано: слева — привычная цель (быстрее завершить/ответить), справа — новая цель (поделить ресурс по заданным долям). Дальше разберём три способа это сделать: lottery, stride и CFS (то, что реально крутится в Linux).
Lottery: розыгрыш билетов
Самая наглядная идея. Каждому процессу выдаём билеты (tickets) — пропорционально желаемой доле CPU. На каждом тике планировщик тянет случайный билет из общей кучи. Чей билет вытянули — тот и бежит этот квант времени.
Чем больше у тебя билетов, тем выше шанс, что вытянут именно твой → тем больше CPU в среднем достанется. «В среднем» — ключевое слово: это вероятностный механизм.
Раздали билеты (всего 100):
A: ███████████████████████████████████████████████████ тикеты 0..49 (50)
B: ██████████████████████████████ тикеты 50..79 (30)
C: ████████████████████ тикеты 80..99 (20)
Розыгрыш тиков (random 0..99):
тик: 1 2 3 4 5 6 7 8 9 10
rnd: 63 12 85 41 07 77 90 33 58 21
→ B A C A A B C A B AЧто нарисовано: билеты — это диапазоны чисел. На каждом тике берём случайное число и смотрим, в чей диапазон оно попало. За 10 тиков здесь вышло A=5, B=3, C=2 — почти ровно 50/30/20. На коротком интервале возможен перекос, на длинном доли сходятся к заданным (закон больших чисел).
Плюсы lottery:
- Простота. Весь планировщик — это «сгенерируй random, найди владельца».
- Нет голодания. У каждого, у кого хоть 1 билет, есть ненулевой шанс.
- Легко менять доли: просто выдай/забери билеты.
Минус — дисперсия (разброс). На коротких отрезках реальная доля скачет: не повезло с рандомом — и за секунду ты получил не 50%, а 35%. Для строгого SLA это плохо. Хочется детерминизма — и тут приходит stride.
Stride: детерминированная версия
Stride scheduling убирает случайность. Каждому процессу считаем шаг (stride) — обратно пропорционально числу билетов:
stride = БОЛЬШОЕ_ЧИСЛО / ticketsЧем больше билетов — тем меньше шаг. У каждого процесса есть счётчик pass (накопленный «виртуальный пробег»), стартует с 0. Алгоритм на каждом тике:
- Выбрать процесс с минимальным pass (он «меньше всех пробежал» → его очередь).
- Запустить его на квант.
- Прибавить к его pass его же stride.
Возьмём билеты A=100, B=50, C=25 и большое число 10000. Тогда strideA=100, strideB=200, strideC=400. Тот, у кого больше билетов, имеет меньший шаг → его pass растёт медленно → его выбирают чаще.
stride: A=100 B=200 C=400
тик │ выбрали │ pass.A │ pass.B │ pass.C
────┼─────────┼────────┼────────┼────────
0 │ A │ 100 │ 0 │ 0 (все по 0, берём A)
1 │ B │ 100 │ 200 │ 0
2 │ C │ 100 │ 200 │ 400
3 │ A │ 200 │ 200 │ 400
4 │ A │ 300 │ 200 │ 400
5 │ B │ 300 │ 400 │ 400
6 │ A │ 400 │ 400 │ 400
7 │ A │ 500 │ 400 │ 400
────┴─────────┴────────┴────────┴────────
Итог за 8 тиков: A=4, B=2, C=2 → ~ 4:2:2 = 100:50:25 ✓Что нарисовано: на каждом шаге берём строку с минимальным pass, запускаем этот процесс и увеличиваем его pass на stride. У A шаг маленький, поэтому его pass догоняет остальных медленно — и A выбирают вдвое чаще, чем B, и вчетверо чаще, чем C. Ровно как требовали билеты. Никакого рандома — результат точный и повторяемый.
Сравним два подхода:
| Свойство | Lottery | Stride |
|---|---|---|
| Механизм | случайный билет | минимальный pass |
| Точность на коротком | скачет (дисперсия) | точная |
| Состояние на процесс | только билеты | билеты + pass |
| Новый процесс | просто дай билеты | надо выбрать стартовый pass |
| Сложность | минимум | чуть больше |
Тонкость stride: какой pass дать новому процессу? Если поставить 0 — он монополизирует CPU, пока не догонит остальных. Поэтому новичку обычно присваивают текущий минимальный pass в системе. Именно эту проблему изящно решает CFS.
CFS: как это сделано в Linux
CFS (Completely Fair Scheduler) — планировщик, много лет работавший по умолчанию в Linux. Идея — та же пропорциональность, но через понятие vruntime (virtual runtime, виртуальное время выполнения).
vruntime — это «сколько процессор уже накрутил для данной задачи», но взвешенное по приоритету. Правило одно:
Всегда запускай задачу с НАИМЕНЬШИМ vruntime.Это прямой родственник stride: vruntime ≈ pass, «выбрать минимальный» — то же самое. Когда задача бежит, её vruntime растёт. У низкоприоритетных он растёт быстрее (они быстрее «наедаются»), у высокоприоритетных — медленнее, так что их выбирают чаще.
Приоритет в Linux задаётся через nice — число от −20 (самый «жадный», высокий приоритет) до +19 (самый «вежливый», уступает другим). nice превращается в вес (weight). Прирост vruntime считается так:
Δvruntime = Δreal_time * (вес_базовый / вес_задачи)
nice = 0 → вес ~1024 → vruntime растёт «нормально»
nice < 0 → вес больше → vruntime растёт медленнее → CPU больше
nice > 0 → вес меньше → vruntime растёт быстрее → CPU меньшеТо есть «реальную» секунду CPU система пересчитывает в «виртуальные» секунды по весу. У жадной задачи секунда CPU стоит дёшево (мало виртуального времени), у вежливой — дорого.
Кто следующий? Берём минимальный vruntime:
задача │ vruntime
───────┼─────────
P1 │ 120 ← минимум, запускаем его
P2 │ 145
P3 │ 210
P4 │ 145
P1 отбегал квант, его vruntime подрос до 150:
задача │ vruntime
───────┼─────────
P2 │ 145 ← теперь минимум он
P4 │ 145
P1 │ 150
P3 │ 210Что нарисовано: CFS всегда хватает задачу с самым маленьким vruntime, даёт ей CPU, после чего её vruntime растёт и она «отодвигается» назад. Так все задачи держатся примерно на одном виртуальном времени — отсюда «completely fair».
Чтобы находить минимум быстро, CFS хранит задачи не в списке, а в красно-чёрном дереве (red-black tree) — самобалансирующемся бинарном дереве поиска, упорядоченном по vruntime. Самый левый узел — минимум.
Красно-чёрное дерево по vruntime:
(150)
/ \
(145) (210)
/
(120) ← самый левый = минимум = запустить следующим
Вставка/удаление/поиск минимума — O(log n).Что нарисовано: дерево упорядочено по vruntime, минимум всегда «крайний слева». Поэтому «выбрать следующего» и «вернуть отработавшего обратно» стоят O(log n) — дёшево даже при тысячах задач. Сравните с MLFQ, где были отдельные очереди на каждый приоритет.
Новый процесс в CFS получает vruntime около текущего минимума в дереве — тот самый трюк, что мы вручную делали в stride, тут встроен.
Связь с многоядерностью и проддействительностью
На многоядерной машине (см. multiprocessor scheduling) у каждого ядра — своя очередь (свой runqueue, своё дерево). Это даёт affinity (задача чаще остаётся на том же ядре → горячий кеш) и убирает борьбу за один общий замок. Периодически работает load balancing — перетаскивание задач между ядрами, чтобы они не простаивали.
А теперь то, ради чего бэкендеру это всё нужно: cgroups (control groups) — механизм Linux, которым контейнеры (Docker, Kubernetes) ограничивают CPU. Под капотом — тот же CFS. Два рычага:
- cpu.shares (вес) — относительная доля при конкуренции. Это буквально билеты/вес из CFS. Если процессор свободен, шарой ты не ограничен.
- cpu.cfs_quota_us / cpu.cfs_period_us — жёсткий потолок. «За каждый
период
periodтебе дают не большеquotaпроцессорного времени». Это и есть «дать контейнеру 1.5 ядра».
Контейнеру дали 1.5 CPU: quota=150ms, period=100ms
Реальное время →
период 1: |■■■■■■■■■■ ■■■■■|.....| потратил 150ms CPU (по 2 ядрам)
0ms 100ms (за период бюджет 150ms)
↑ бюджет кончился раньше конца периода?
→ THROTTLING: задачи замораживают до
нового периода
cpu.shares = 512 vs 1024 → при драке за ядро доли 1:2
свободно → бери сколько хочешь (shares не режет в простое)Что нарисовано: quota/period — это бюджет CPU на окно времени. Выбрал бюджет раньше — тебя throttle (приостанавливают) до следующего периода, даже если ядра простаивают. Отсюда классическая боль: latency-сервису поставили слишком маленькую quota — он упирается в потолок, появляются «пилообразные» задержки, хотя CPU на ноде свободен. shares же режут только при реальной конкуренции.
Финальное сравнение трёх механизмов:
| Критерий | Lottery | Stride | CFS (Linux) |
|---|---|---|---|
| Природа | случайная | детерминир. | детерминир. |
| «Ключ» выбора | random ticket | min pass | min vruntime |
| Структура данных | список | список | красно-чёрное дерево |
| Сложность выбора | O(n)* | O(n)* | O(log n) |
| Приоритет | билеты | билеты | nice → вес |
| Точность доли | в среднем | точная | точная |
| Где встречается | учебники | учебники | прод (cgroups/k8s) |
(*) наивные реализации; их тоже можно ускорить структурами данных.
Что спрашивают на собесе
- Чем пропорциональное планирование отличается от SJF/MLFQ? SJF/MLFQ оптимизируют оборот/отклик и угадывают тип задачи; proportional-share явно делит CPU в заданных долях (по билетам/весу), справедливость важнее скорости.
- Lottery vs stride. Оба дают одинаковые средние доли. Lottery — случайный, простой, но с дисперсией на коротких интервалах; stride — детерминированный (min pass), точный, но нужно состояние pass и аккуратный старт нового процесса.
- Что такое vruntime и как работает CFS. vruntime — взвешенное по приоритету накопленное время CPU; CFS всегда запускает задачу с минимальным vruntime, хранит их в красно-чёрном дереве (выбор минимума за O(log n)). Это «stride» с весами и быстрой структурой.
- Как nice влияет на CPU. nice задаёт вес: чем меньше nice (ближе к −20), тем больше вес, тем медленнее растёт vruntime, тем больше доля CPU.
- cpu.shares vs cpu.quota в cgroups. shares — относительный вес, режет только при конкуренции; quota/period — жёсткий потолок; при исчерпании бюджета наступает throttling даже на простаивающих ядрах. Частая причина «непонятных» задержек у контейнеров.
- Почему у CFS per-core runqueue. Чтобы не драться за общий замок и сохранять affinity (горячий кеш); баланс между ядрами делает периодический load balancing (см. multiprocessor scheduling).
В чём главная цель пропорционального планирования (proportional-share)?
Главный недостаток lottery scheduling по сравнению со stride?
Как stride вычисляет шаг и кого запускает следующим?
Что делает CFS на каждом решении о планировании?
Как значение nice влияет на долю CPU в CFS? (выберите верные)
Контейнеру задали cpu.cfs_quota_us=200000 при cpu.cfs_period_us=100000. Сколько ядер CPU это даёт как потолок?