45. Scheduler диска и очередь запросов
blk-mq, mq-deadline и io_uring
«Лифт, обслуживающий пассажиров строго в порядке вызова, весь день проносится между подвалом и крышей, не перевезя толком почти никого».
— Классический принцип планирования дискового лифта (SCAN)
Роль планировщика в стеке ввода-вывода
В предыдущей статье мы исследовали физическое устройство накопителей 44. Диски и устройства хранения. Если операционная система будет передавать команды READ и WRITE на контроллер накопителя в строгом порядке их поступления от приложений (наивный FIFO), производительность сервера рухнет до уровня 1990-х годов.
Причина проста: сотни процессов и горутин генерируют хаотичный, перемежающийся поток системных вызовов. Один поток читает логи, другой сбрасывает транзакционный журнал (WAL), третий делает случайную выборку из B-дерева базы данных.
Именно здесь вступает в игру Планировщик ввода-вывода (I/O Scheduler) — критический компонент блочного слоя ядра Linux. Его задача — преобразовать разрозненный шквал запросов в упорядоченную, оптимальную последовательность команд, сглаживая физические задержки накопителей и распределяя полосу пропускания между процессами.
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
App1["Go Process (WAL fsync)"]:::data -->|"syscall write/fsync"| VFS["VFS & Page Cache"]
App2["Go Process (ReadAt)"] -->|"syscall read"| VFS
App3["Background Worker"] -->|"Direct I/O"| VFS
VFS -->|"Поток неструктурированных bio"| BlockLayer["Generic Block Layer"]
subgraph Scheduler["I/O Scheduler (blk-mq)"]
Merge["Слияние (Merge: front/back)"]
Sort["Сортировка (Sort: LBA / Deadline)"]
Queue["Диспетчеризация (Dispatch Queues)"]
Merge --> Sort --> Queue
end
BlockLayer --> Scheduler
Queue -->|"Команды blk_mq_dispatch_rq_list"| Driver["Драйвер (nvme.ko / ahci.ko)"]
Driver --> Hardware["Физический накопитель (NVMe / SSD / HDD)"]
Для Go-разработчика понимание работы планировщика напрямую отвечает на животрепещущие вопросы эксплуатации:
- Почему системный вызов
fsync()на SSD иногда занимает 100 микросекунд, а иногда внезапно замирает на 10–100 мс? - Обходит ли прямой ввод-вывод (
O_DIRECTилиsyscall.O_DIRECT) планировщик ядра? - Как подружить планировщик Go с современным механизмом асинхронного блочного ввода-вывода
io_uring?
Физика HDD: почему случайный доступ убивает производительность
Исторически алгоритмы дисковых планировщиков создавались в эпоху механических жестких дисков (HDD). Механика накладывала жесткие физические ограничения, где каждый лишний миллиметр движения головки карался катастрофическим падением пропускной способности.
Для HDD существуют два непреодолимых физических фактора задержки:
- Seek Time (Время наводки): Физическое перемещение каретки с головками чтения/записи на нужный радиальный цилиндр (track). Для серверов это типично 3–10 мс.
- Rotational Latency (Вращательная задержка): Ожидание проворота шпинделя, пока требуемый сектор окажется точно под считывающим элементом. Для диска 7200 RPM один полный оборот длится 8.33 мс, а средняя задержка составляет ~4.17 мс (для 15000 RPM — около 2 мс).
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
subgraph HDDMechanics["Физика HDD: анатомия задержки 10 мс"]
Seek["Seek Time: 3-10 мс
(разгон и позиционирование каретки)"]
Rotate["Rotational Latency: ~4.17 мс
(поворот блина 7200 RPM)"]
Transfer["Data Transfer: < 0.1 мс
(считывание 4 КБ сектора)"]
Seek --> Rotate --> Transfer
end
Суммарно случайный доступ к HDD занимает ~10 мс. Это означает, что при строгом поочередном чтении диск способен выдать в лучшем случае всего 100 IOPS! Если бы ядро не оптимизировало эти запросы, механический диск превратился бы в непреодолимое «бутылочное горлышко» всей операционной системы.
Классический планировщик Linux работал по алгоритму лифта (Elevator Algorithm / SCAN): вместо того чтобы метаться между крайними дорожками 1, 99, 2, 85, головка плавно движется от внешнего края диска к внутреннему (по возрастанию номеров секторов LBA), попутно обслуживая все встречные запросы, а затем совершает обратный ход.
А что же SSD и NVMe?
В твердотельных накопителях физические головки и шпиндели отсутствуют — случайный доступ к ячейке flash занимает 20–100 мкс независимо от ее адреса. Однако планировщик не потерял актуальности:
- Накопители имеют ограниченный ресурс циклов перезаписи (P/E cycles).
- Внутренний сборщик мусора SSD страдает от фрагментированной записи, вызывающей write amplification.
- Потоки на запись могут полностью заблокировать критически важные синхронные операции чтения, приводя к starvation (голоданию) транзакций.
Алгоритмы планирования в Linux
Ядро Linux позволяет выбирать алгоритм планирования для каждого блочного устройства индивидуально через псевдофайловую систему sysfs: /sys/block/sdX/queue/scheduler.
В современных дистрибутивах Linux доступны четыре основных планировщика архитектуры blk-mq:
| Планировщик | Архитектура и алгоритм | Для каких задач предназначен | Поведение при пиковой нагрузке |
|---|---|---|---|
mq-deadline |
Двухуровневая очередь: сортировка по LBA + жесткие тайм-ауты (дедлайны) | Реляционные СУБД (PostgreSQL, MySQL), WAL, базы данных, чувствительные к p99-задержке | Гарантирует отсутствие голодания (starvation); отдает безусловный приоритет операциям чтения |
bfq (BFQ) |
Budget Fair Queueing: распределение полосы пропускания пропорционально весам процессов | Настольные системы, интерактивные сервера, смешанная нагрузка (видео + компиляция) | Высокая отзывчивость интерактивных задач, но ощутимый оверхед на CPU при высоких IOPS |
kyber |
Latency-oriented: мониторинг задержек чтения/записи и динамическое дросселирование очередей | Быстрые NVMe, облачные виртуальные машины, высоконагруженные API-шлюзы | Держит целевую задержку (target latency), динамически ограничивая фоновые сбросы записи |
none (noop) |
Чистый FIFO: запросы направляются напрямую в аппаратные очереди устройства без сортировки | Современные серверные NVMe накопители с десятками аппаратных очередей | Нулевой расход тактов CPU хоста; всю логику берет на себя мощный контроллер NVMe |
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 MQDeadline["Алгоритм mq-deadline"]
Incoming["Входящие запросы (bio)"]
SortedRead["Read FIFO (Дедлайн: 500 мс)"]
SortedWrite["Write FIFO (Дедлайн: 5000 мс)"]
LBATree["LBA Red-Black Tree (Сортировка по адресу)"]
Incoming --> SortedRead
Incoming --> SortedWrite
Incoming --> LBATree
Dispatch{"Истек дедлайн запроса?"}:::warning
SortedRead -->|"Да"| Dispatch
SortedWrite -->|"Да"| Dispatch
LBATree -->|"Нет"| Dispatch
Dispatch -->|"Передача на исполнение"| HW["Аппаратная очередь"]
end
Эволюция: от единой очереди к blk-mq и NVMe
В классических ядрах Linux (до версии 4.8) блочный слой ядра использовал архитектуру с единой очередью запросов (Single Queue Block Layer). Все процессорные ядра сервера при выполнении дискового ввода-вывода конкурировали за один глобальный спинлок очереди: q->queue_lock.
В эпоху, когда сервер имел 2 или 4 ядра, а диск отдавал 150 IOPS, этой блокировки никто не замечал. Но с появлением серверов на 64–256 процессорных ядер и NVMe-накопителей, выдающих миллион IOPS, глобальный спинлок стал катастрофой. Процессоры проводили до 80% времени в холостом вращении вокруг q->queue_lock (spin contention), ожидая своей очереди поставить команду в стек!
В ядре Linux произошла масштабная архитектурная революция — переход на blk-mq (Multi-Queue Block Layer).
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 SoftwareQueues["Программные очереди (Software Staging Queues - per CPU)"]
SQ0["CPU 0: Software Queue"]:::data
SQ1["CPU 1: Software Queue"]:::data
SQ2["CPU 2: Software Queue"]:::data
SQN["CPU N: Software Queue"]:::data
end
subgraph HardwareQueues["Аппаратные очереди (Hardware Submission Queues - per NVMe Controller)"]
HQ0["Hardware Queue 0"]:::data
HQ1["Hardware Queue 1"]:::data
HQM["Hardware Queue M"]:::data
end
SQ0 --> HQ0
SQ1 --> HQ0
SQ2 --> HQ1
SQN --> HQM
HQ0 --> NVMe["NVMe Controller (PCIe)"]
HQ1 --> NVMe
HQM --> NVMe
Принципы архитектуры blk-mq:
- Software Staging Queues (Программные очереди): Создаются независимо для каждого процессорного ядра (
nr_cpu_ids). Когда поток на CPU 3 выполняет запись, он кладет запрос в программную очередь CPU 3 без межпроцессорных блокировок. - Hardware Dispatch Queues (Аппаратные очереди): Напрямую отображаются на аппаратные submission queues контроллера NVMe. Ядро балансирует программные очереди по аппаратным каналам с учетом топологии NUMA.
- Линейная масштабируемость: Нагрузка идеально распараллеливается по всем ядрам, позволяя выжимать миллионы IOPS без единой глобальной блокировки.
Инспектировать состояние очередей blk-mq в рабочей системе можно через интерфейс sysfs:
# Посмотреть лимит глубины очереди для устройства sda
cat /sys/block/sda/queue/nr_requests
# Посмотреть текущий активный планировщик
cat /sys/block/sda/queue/scheduler
# Пример вывода: none [mq-deadline] kyber bfq
# Проверить аппаратные очереди blk-mq (для nvme устройств)
ls /sys/block/sda/mq/ 2>/dev/null || ls /sys/block/nvme0n1/mq/
Под капотом: очередь запросов и слияние
Когда приложение на Go вызывает системный вызов write() или fsync(), запрос не попадает на диск мгновенно. Он проходит через строгий конвейер трансформаций блочного слоя ядра:
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
App["Go app: WriteAt()"]:::data -->|"1. Буферизация"| PC["Page Cache (struct page)"]
PC -->|"2. Инициализация"| Bio["struct bio (LBA, Pages, Flags)"]
Bio -->|"3. Merge & Sort"| Sched["I/O Scheduler (struct request)"]
Sched -->|"4. Dispatch"| Driver["blk_mq_dispatch_rq_list"]
Driver -->|"5. DMA transfer"| Disk["Контроллер накопителя"]
- Page Cache (
struct page): Если не установлен флагO_DIRECT, данные копируются из пользовательского буфера в страницы оперативной памяти ядра, а страница помечается как dirty. - Bio (Block I/O —
struct bio): Базовая единица блочного ввода-вывода. Структураstruct bioописывает операцию: тип (READилиWRITE), целевое блочное устройство, начальный сектор диска и массив физических страниц памяти (bi_io_vec). - Request Queue (
struct request): Планировщик формирует структуру запроса, выполняя две критические оптимизации:- Merge (Слияние): Если новый
bioпримыкает к уже существующему в очереди запросу (Back Merge или Front Merge), ядро объединяет их в один крупныйstruct request. Вместо четырех запросов по 4 КБ на контроллер уходит один запрос на 16 КБ. Это кардинально снижает накладные расходы на шину. - Sort (Сортировка): Запрос упорядочивается в красно-черном дереве (LBA Red-Black Tree) по физическому адресу сектора.
- Merge (Слияние): Если новый
- Dispatch: Диспетчер планировщика передает готовые пачки запросов в функцию драйвера
blk_mq_dispatch_rq_list, которая записывает дескрипторы команд в DMA-кольцо контроллера.
Влияние на Go-приложения и бэкенд-разработку
В сетевом программировании рантайм Go ведет себя почти безупречно благодаря сетевому поллеру (netpoller), который превращает асинхронные события epoll в удобную блокирующую модель горутин.
Однако дисковый ввод-вывод — темная сторона системного программирования. Системные вызовы write(), WriteAt, sync, fsync и fdatasync в Linux всегда блокируют поток операционной системы (M). Если очередь ввода-вывода накопителя переполняется или диск испытывает задержки, фоновый монитор Go sysmon вынужден запускать новые потоки ОС через handoffp(). Это может привести к взрывному росту числа потоков в процессе, деградации кэшей процессора и чудовищному latency p99.
sequenceDiagram
participant GoApp as Go Goroutine
participant Runtime as Go Scheduler (P & M)
participant Kernel as Linux blk-mq & Scheduler
participant SSD as NVMe SSD Hardware
GoApp->>Runtime: file.Sync() (fsync)
Runtime->>Kernel: syscall.Fsync(fd)
Note over Runtime,Kernel: Поток M переходит в статус D (Uninterruptible Sleep)
Kernel->>Kernel: Помещение bio в request_queue
Kernel->>SSD: Dispatch команды через blk_mq
Note over SSD: SSD занят Garbage Collection!<br/>Задержка 40 мс...
SSD-->>Kernel: Прерывание: Flush Complete!
Kernel-->>Runtime: Сисколл fsync вернул управление
Runtime-->>GoApp: file.Sync() завершен
1. O_DIRECT и обход планировщика?
Распространенное заблуждение среди разработчиков гласит: «Если я использую syscall.O_DIRECT, мои запросы летят прямо в контроллер диска в обход ядра».
Это неправда. Флаг O_DIRECT обходит только Page Cache (оперативную память ОС). Запросы все равно заворачиваются в структуры struct bio, попадают в программные очереди request_queue планировщика blk-mq и проходят проверку лимитов глубины очередей. Если очередь переполнена, поток с O_DIRECT точно так же зависнет в ожидании свободного слота в очереди ядра.
2. io_uring и асинхронный I/O
Механизм io_uring (появившийся в Linux 5.1+) стал фундаментальным прорывом для I/O в Linux. Вместо блокирующих системных вызовов io_uring организует два кольцевых буфера в разделяемой между ядром и процессором памяти:
- Submission Queue (SQ): Приложение помещает дескрипторы I/O операций в очередь без единого системного вызова.
- Completion Queue (CQ): Ядро асинхронно обрабатывает запросы через
blk-mqи рапортует об успехе.
Для Go это означает возможность совершать дисковый ввод-вывод со скоростью сетевого сокета: горутины не блокируют системные потоки M, рантайму не нужно привлекать sysmon для создания аварийных тредов, а ядра CPU не тратят такты на переключение контекста.
3. Настройка для production
В производственной среде высоконагруженные серверы баз данных требуют осознанной калибровки параметров блочной подсистемы:
#!/usr/bin/env bash
# Оптимальная настройка блочной очереди для SSD / NVMe под СУБД (PostgreSQL / BadgerDB)
DEV="nvme0n1"
# 1. Выбор планировщика:
# mq-deadline защищает от голодания чтений при тяжелых сбросах WAL/чекпоинтов
echo "mq-deadline" > /sys/block/${DEV}/queue/scheduler
# 2. Увеличение глубины очереди запросов (по умолчанию 128 или 256)
# Позволяет сглаживать пиковые всплески транзакционной нагрузки
echo "256" > /sys/block/${DEV}/queue/nr_requests
# 3. Индикация типа накопителя (0 = Non-rotational SSD/NVMe, 1 = Вращающийся HDD)
# Ядро обычно определяет это автоматически, но в виртуальных средах полезно зафиксировать явно
echo "0" > /sys/block/${DEV}/queue/rotational
# 4. Отключение сбора энтропии для /dev/random блочным устройством (снижает оверхед CPU)
echo "0" > /sys/block/${DEV}/queue/add_random
Итог
- Планировщик I/O — это регулировщик дорожного движения блочного слоя Linux. Он защищает физический накопитель от захлебывания, сливает смежные запросы и гарантирует баланс между чтением и записью.
- HDD vs SSD: Для магнитных дисков планировщик спасал систему от медлительности механического привода с помощью лифтовых алгоритмов. Для SSD/NVMe он обеспечивает многоочередную параллелизацию и предотвращает деградацию задержек из-за фоновых сбросов.
- Архитектура blk-mq: Ликвидировала единый спинлок ядра
q->queue_lock, внедрив программные очереди на каждое процессорное ядро и аппаратные очереди к шине PCIe, обеспечив линейное масштабирование миллионов IOPS. - Go и системные вызовы: Вызовы
fsync(),fdatasync()иwrite()неизбежно блокируют поток ОСM. ФлагO_DIRECTобходит только Page Cache, но остается клиентом планировщикаblk-mq. Современный путь для максимальной производительности в Linux — это безблокировочный асинхронный интерфейсio_uring. - Инженерия эксплуатации: Настройка алгоритмов (
mq-deadline,kyber,none) и размера очередейnr_requestsдолжна строго опираться на метрикиiostatи специфику прикладного профиля нагрузки.
Мы изучили, как операционная система управляет очередями одного накопителя. Но в реальных production-системах диски редко работают поодиночке. В следующей главе мы разберем, как операционная система объединяет физические диски в отказоустойчивые массивы и гибкие логические разделы: 46. RAID, LVM и логические тома.