---
title: "Планирование на многоядерных"
description: "До сих пор планировщик выбирал задачу для одного ядра CPU. Ноутбук, на котором вы это читаете, имеет 8 или больше ядер, сервер в проде — 32, 64, 128. Появляется второй вопрос: не…"
canonical: https://graphlms.ru/os/book/multiprocessor-scheduling
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Планирование на многоядерных

> Источник: https://graphlms.ru/os/book/multiprocessor-scheduling — курс «Операционные системы», GraphLMS.

## Что меняется, когда ядер много

До сих пор планировщик выбирал задачу для одного ядра CPU. Ноутбук, на котором вы
это читаете, имеет 8 или больше ядер, сервер в проде — 32, 64, 128. Появляется
второй вопрос: не только **кого** запускать, но и **где**.

Вместе с ним приходят три проблемы, которых на одном ядре не было:

1. **Кэш-аффинность.** Задаче выгодно возвращаться на то же ядро, где она работала.
2. **Где хранить очередь готовых задач.** Одна общая на все ядра или по одной на
   каждое.
3. **Балансировка.** Что делать, когда одни ядра перегружены, а другие простаивают.

В конце главы окажется, что планировщик горутин в Go решает ровно эти три задачи
теми же приёмами.

## Кэш и аффинность

У каждого ядра CPU есть свой **кэш** — маленькая быстрая память рядом с
вычислительным блоком. Порядок цен: обращение к кэшу первого уровня занимает
несколько тактов, к оперативной памяти — сотни. Пока задача работает на ядре, её
данные постепенно оседают в кэше этого ядра, и она ускоряется. Говорят, что кэш
«прогрелся».

```text
   Ядро 0                            RAM (сотни тактов)
 ┌───────────┐                   ┌──────────────────────┐
 │  CPU      │                   │  все данные задачи A  │
 │  ┌─────┐  │  несколько тактов │                      │
 │  │кэш  │◄─┼───── горячие ─────┼─ копии нужных строк   │
 │  │ A   │  │     данные A      │                      │
 │  └─────┘  │                   └──────────────────────┘
 └───────────┘
```

Если планировщик перенесёт A на ядро 1, кэш ядра 1 про A ничего не знает. A снова
пойдёт за данными в медленную RAM и будет работать медленнее, пока не прогреет
новый кэш. Свойство «задача быстрее работает на ядре, где её данные уже в кэше»
называется **кэш-аффинностью** (cache affinity). Вывод: по возможности оставлять
задачу на одном и том же ядре.

## Одна очередь на всех

Первый вариант: одна общая очередь готовых задач на всю систему (в литературе
SQMS, single-queue multiprocessor scheduling). Освободилось ядро — взяло следующую
задачу из очереди.

```text
                 ┌──────────────────────────────┐
                 │  ОБЩАЯ ОЧЕРЕДЬ (один замок)   │
                 │  [ A ][ B ][ C ][ D ][ E ]    │
                 └───┬───────┬───────┬───────┬───┘
                 ┌───▼──┐ ┌──▼───┐ ┌─▼────┐ ┌▼─────┐
                 │Ядро 0│ │Ядро 1│ │Ядро 2│ │Ядро 3│
                 └──────┘ └──────┘ └──────┘ └──────┘
```

Плюс — простота и автоматическая балансировка: свободное ядро само берёт работу.
Минусов два.

**Замок.** Очередь — общая структура данных, и чтобы два ядра не испортили её
одновременными изменениями, её защищают блокировкой (подробно в главе
[как устроен лок](/os/book/locks-hardware)). Чем больше ядер, тем дольше они ждут
друг друга у этого замка. Добавили ядер, а производительность почти не выросла.

**Потеря аффинности.** Задача A в этот раз досталась ядру 0, в следующий — ядру 2,
потом ядру 1. Кэш каждый раз холодный.

```text
  Шаг 1: A → Ядро 0     Шаг 2: A → Ядро 2      Шаг 3: A → Ядро 1
  кэш греется на 0      прогрев на 0 пропал    снова холодный старт
```

## Очередь на каждое ядро

Второй вариант: у каждого ядра своя очередь (runqueue) со своим замком (MQMS,
multi-queue multiprocessor scheduling). Новая задача попадает в одну из очередей и
дальше обычно живёт на этом ядре.

```text
 ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
 │ Ядро 0   │  │ Ядро 1   │  │ Ядро 2   │  │ Ядро 3   │
 │ [A][C]   │  │ [B][E]   │  │ [D]      │  │          │
 │ свой замок│ │ свой замок│ │ свой замок│ │ свой замок│
 └──────────┘  └──────────┘  └──────────┘  └──────────┘
```

Ядра не спорят за общий замок, и задачи остаются на своих ядрах с горячим кэшем.
Так устроены почти все современные планировщики, включая планировщик Linux.

| Свойство | Общая очередь | Очередь на ядро |
|---|---|---|
| Масштабируемость | плохая (борьба за замок) | хорошая |
| Кэш-аффинность | теряется | сохраняется |
| Балансировка | автоматическая | нужна отдельно |
| Сложность | низкая | выше |

## Перекос нагрузки: пример

У очередей на ядро своя беда. Два ядра, три одинаковые вычислительные задачи A, B,
C. Планировщик положил A на ядро 0, а B и C на ядро 1:

```text
  Ядро 0: [ A  A  A  A  A  A  A  A ]   A получает 100 % ядра
  Ядро 1: [ B  C  B  C  B  C  B  C ]   B и C получают по 50 %
```

Задачи одинаковые, а A работает вдвое быстрее. Если бы задач было две, а одна
закончилась, одно ядро вообще простаивало бы, пока у второго есть очередь. Это
прямая потеря пропускной способности.

Лекарство — **миграция**: время от времени переносить задачи между очередями. Для
трёх задач на двух ядрах выход — переносить задачу по кругу: какое-то время A
делит ядро с B, потом с C, и в среднем каждая получает по 2/3 ядра.

Распространённый способ миграции — **кража работы** (work stealing). Ядро, у
которого очередь опустела, само заглядывает в очередь к соседу и забирает часть его
задач.

```text
 ДО:
 Ядро 0: [A][B][C][D]  ← перегружено      Ядро 1: (пусто) ← простаивает

 ПОСЛЕ кражи половины:
 Ядро 0: [A][B]                           Ядро 1: [C][D]
```

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

**Как это сделано в Linux.** Балансировкой занимается планировщик ядра ОС по двум
сценариям. Периодически каждое ядро сравнивает свою загрузку с соседями и, если
перекос велик, перетягивает задачи к себе. Кроме того, ядро, которое вот-вот уйдёт в
простой, сначала пытается забрать работу у загруженного соседа. Соседей
перебирают по иерархии: сначала логические ядра одного физического, потом ядра
одного процессорного сокета, потом другие сокеты. Переносить задачу на другой
сокет дороже всего: у сокета своя память (это называется **NUMA**), и после
переезда задача будет ходить за своими данными в «чужую» память.

## Управление размещением вручную

Иногда ОС мешать не нужно, а нужно закрепить процесс за ядрами:

- `taskset -c 2,3 ./server` запускает процесс только на ядрах 2 и 3 (системный
  вызов `sched_setaffinity`);
- в Kubernetes статическая политика CPU manager выдаёт подам класса Guaranteed с
  целым числом CPU эксклюзивные ядра;
- базы данных и обработчики сетевых пакетов часто закрепляют потоки за ядрами,
  чтобы не терять кэш и не ходить в чужую NUMA-память.

## Мост в Go: планировщик GMP

Рантайм Go планирует горутины по той же схеме, но в пространстве пользователя.
Модель называется **GMP**:

- **G** (goroutine) — горутина, единица работы. Их могут быть сотни тысяч.
- **M** (machine) — поток ОС. Именно M исполняется на ядре CPU, и именно его
  планирует ядро ОС.
- **P** (processor) — логический процессор Go: право исполнять Go-код плюс
  **локальная очередь готовых горутин**. Число P равно `GOMAXPROCS`.

Чтобы исполнять горутины, поток M должен держать P. Если горутина ушла в
блокирующий системный вызов, M застревает вместе с ней, а P передаётся другому
потоку, чтобы остальные горутины не стояли.

```text
   P0: [G1][G2][G3]       P1: [G4][G5]       Глобальная очередь
        │                     │              [G99][G100]...
     ┌──▼──┐               ┌──▼──┐           (запас, как общая
     │ M0  │               │ M1  │            очередь)
     └──┬──┘               └──┬──┘
     Ядро 0                Ядро 1
```

Локальные очереди P — это очереди на ядро из этой главы: каждый P берёт горутины из
своей очереди без общего замка. Глобальная очередь — общий запас; чтобы горутины в
ней не голодали, P заглядывает туда регулярно, раз в 61 итерацию планирования,
даже если локальная очередь не пуста.

Когда локальная очередь P опустела, он ищет работу так:

```text
  1) глобальная очередь
  2) сеть (netpoller): горутины, чьи сокеты стали готовы
  3) кража: случайный другой P, забрать ПОЛОВИНУ его очереди

   P0: [G1][G2][G3][G4]             P1: (пусто)
   P0: [G1][G2]          ──────►    P1: [G3][G4]
```

Это то самое правило «кради половину». Подробнее о внутреннем устройстве — в
курсе Go: [горутины](/go/book/goroutines) и
[планировщик изнутри](/go/book/scheduler-deep).

| Понятие из главы | В Go |
|---|---|
| Задача | G (горутина) |
| Поток, который исполняется на ядре | M (поток ОС) |
| Очередь на ядро | локальная очередь P |
| Общая очередь | глобальная очередь горутин |
| Кража работы | кража половины очереди у другого P |

### Сколько P по умолчанию

Обычно `GOMAXPROCS` равен числу логических ядер, доступных процессу. Начиная с
Go 1.25, на Linux рантайм учитывает ещё и CPU-квоту контейнера (cgroup): если у
контейнера лимит 4 CPU на ноде с 64 ядрами, `GOMAXPROCS` будет 4, а не 64. Зачем это
нужно и что было раньше — в главе
[пропорциональное планирование](/os/book/proportional-scheduling), в разделе про
троттлинг.

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/multiprocessor-scheduling

## Частые ошибки

- Считать, что больше ядер автоматически значит быстрее. Если все ядра борются за
  один замок, выигрыша почти нет.
- Думать, что миграция бесплатна. Перенесённая задача теряет прогретый кэш, а при
  переезде между сокетами — ещё и близость к своей памяти.
- Ставить `GOMAXPROCS` больше лимита CPU контейнера «для параллелизма». Это
  приводит к троттлингу.

## Проверьте себя

1. Почему общая очередь плохо масштабируется на 64 ядрах?
2. Два ядра, четыре одинаковые задачи, распределены 3 и 1. Какую долю ядра
   получает каждая задача? Как это исправить?
3. Зачем красть сразу половину очереди, а не одну задачу?
4. Какой будет `GOMAXPROCS` у Go 1.25 на 8-ядерной машине без контейнерных
   лимитов? А в контейнере с лимитом 1,5 CPU?

Ответы. (1) Все ядра ждут одного замка, и задачи теряют аффинность. (2) Три задачи
получают по 1/3 ядра, одна — целое ядро; перенести одну задачу на второе ядро,
чтобы вышло 2 и 2. (3) Чтобы реже ходить к соседу: каждый поход стоит замка и
холодного кэша. (4) 8; в контейнере квота 1,5 округляется вверх до 2, значит 2.

## Что спрашивают на собесе

- Что такое кэш-аффинность и почему миграция задачи между ядрами стоит дорого.
- Общая очередь против очереди на ядро: масштабируемость, аффинность, балансировка.
- Что такое work stealing и почему крадут половину.
- Расшифруйте GMP: что такое G, M, P и зачем у P своя очередь.
- Как Go-рантайм ищет работу для простаивающего P.
- Как `GOMAXPROCS` связан с CPU-лимитом контейнера.

## Вопросы для самопроверки

**Квиз «Планирование на многоядерных»**

1. Что такое кэш-аффинность?
   - Способность ядра выполнять несколько потоков сразу
   - Размер кэша процессора
   - Механизм синхронизации кэшей между ядрами
   - Выгода задачи работать быстрее на ядре, где её данные уже в кэше (верно)
   - Пояснение: Аффинность — это то, что задача быстрее работает на «своём» ядре: там кэш уже прогрет её данными, и не надо лезть в медленную RAM.
2. Чем плоха одна общая очередь задач на много ядер? (выберите всё верное)
   - Невозможно добавить новые задачи
   - Очередь занимает слишком много памяти
   - Cache bouncing: задача скачет по ядрам и теряет прогретый кэш (верно)
   - Ядра дерутся за один lock — плохая масштабируемость (верно)
   - Пояснение: Общая очередь требует одного лока, за который дерутся все ядра (масштабируемость падает), и одна задача каждый раз попадает на случайное ядро, теряя кэш-аффинность (cache bouncing).
3. При work-stealing сколько задач обычно крадёт простаивающее ядро у занятого?
   - Ничего, оно просто ждёт
   - Одну задачу
   - Всю очередь целиком
   - Примерно половину очереди (верно)
   - Пояснение: Берут примерно половину очереди (так делает и Go). Одну задачу воровать слишком мелко: простаивающее ядро будет постоянно бегать за новой порцией и стучаться в чужой замок. Всю очередь воровать нельзя, иначе перекос просто переедет на другое ядро. Половина даёт баланс при небольшом числе краж.
4. Что в модели Go GMP реально исполняется на ядре процессора?
   - P (processor)
   - M (OS thread) (верно)
   - G (горутина)
   - Глобальная очередь
   - Пояснение: M (machine) — это поток ОС, именно он крутит код на ядре. G — задача (горутина), P — логический слот с локальной очередью, дающий M право исполнять горутины.
5. Где у Go-рантайма хранятся локальные очереди готовых горутин?
   - В каждой G
   - В одной глобальной очереди на весь процесс
   - В каждом P (processor) (верно)
   - В каждой M
   - Пояснение: Локальная очередь горутин принадлежит P — это прямой аналог per-CPU runqueue в ОС. Есть и глобальная очередь, но как резерв (аналог общей очереди SQMS).
6. Сколько P (processor) по умолчанию создаёт Go-рантайм, если на машине 8 ядер и GOMAXPROCS не менялся?
   - Ответ: 8
   - Пояснение: По умолчанию GOMAXPROCS равен числу логических ядер. На 8-ядерной машине будет 8 P, каждый со своей локальной очередью.
