MAATRIX / Блог / Очереди команд в NVMe: откуда берётся глубина 32 и почему она вам не достаётся

Очереди команд в NVMe: откуда берётся глубина 32 и почему она вам не достаётся

MAATRIX

В спецификации накопителя и в маркетинговых материалах производителя мелькает магическое число — глубина очереди 32, а то и больше. Звучит как готовая скорость, которую можно просто взять. На практике программа, которая пишет файл построчно и ждёт подтверждения после каждой записи, эту глубину не увидит никогда, сколько бы ни было заявлено в характеристиках диска. Разберёмся, откуда вообще берётся эта цифра, что она физически означает внутри контроллера NVMe и почему её использование — задача не диска, а вашего приложения.

Глубина очереди — свойство протокола, а не гарантия скорости

Глубина очереди команд (queue depth, QD) — это количество запросов на чтение или запись, которые контроллер накопителя может держать «в работе» одновременно, ещё не ответив на предыдущие. Диск с поддержкой глубины 32 умеет принять от хоста тридцать две независимые команды, ни на одну из них ещё не ответив, и начать обрабатывать их параллельно, распределяя между внутренними ресурсами — каналами доступа к флеш-памяти, буферами, планировщиком.

Важно с самого начала развести два понятия: способность диска и фактическую загрузку диска. Способность — то, что написано в спецификации протокола и реализовано в контроллере: сколько команд диск в принципе готов принять параллельно. Загрузка — сколько команд реально находится в очереди в конкретный момент, а это целиком зависит от того, что диску отправляет операционная система и, в конечном счёте, приложение. Диск не может «занять сам себя» работой — он может только ответить на то, что ему прислали. Если хост присылает по одной команде и ждёт ответа на каждую, прежде чем отправить следующую, глубина очереди на стороне диска весь день равна единице, и остальные 31 слот из паспортных тридцати двух просто пустуют.

Это не абстрактная теория — это прямое следствие того, как вообще устроен интерфейс между операционной системой и накопителем, и стоит один раз разобраться в механике, чтобы дальше не удивляться разнице между цифрой на коробке и цифрой в вашем логе. Если вы уже сталкивались с похожей путаницей на тесте одним потоком, она подробно разобрана в статье почему один поток не выжимает NVMe — здесь мы пойдём дальше и разберём, что с этим делать на уровне архитектуры приложения.

Как NVMe устроен изнутри: много очередей, много команд одновременно

NVMe (Non-Volatile Memory Express) проектировался с нуля под флеш-память и шину PCIe, в отличие от AHCI, унаследованного от эпохи вращающихся дисков с одной головкой. У AHCI была одна общая очередь команд (Native Command Queuing) глубиной до 32 запросов на весь диск. NVMe устроен иначе: он поддерживает множество независимых пар очередей — submission queue (куда драйвер кладёт команды) и completion queue (куда контроллер кладёт результаты), — и по спецификации их может быть до 65535, каждая глубиной до 65536 команд. Конкретный контроллер обычно реализует не максимум спецификации, а какое-то практическое число, но даже у массовых потребительских накопителей это на порядки больше одной очереди AHCI.

Практически это означает, что операционная система обычно создаёт отдельную пару очередей на каждое процессорное ядро (или группу ядер): команда потока с ядра 3 попадает в «свою» очередь и не конкурирует за общий ресурс с командой от потока на ядре 7. Это первый уровень параллелизма — множество независимых очередей. Второй уровень — глубина каждой отдельной очереди: пока контроллер обрабатывает одну команду, драйвер уже может положить туда следующую, и она будет ждать своей обработки, не блокируя ничего.

Внутри самого накопителя это параллелизм с продолжением: у современного NVMe SSD обычно несколько независимых NAND-каналов, каждый из которых может читать или писать отдельно от других. Когда в очередях у контроллера скопилось много команд, внутренний планировщик может раздать их по разным каналам одновременно — именно так набегает суммарная пропускная способность, которую производитель указывает как «до N ГБ/с». Эта цифра почти всегда получена при большой глубине очереди и нескольких параллельных потоках — иначе каналам физически нечего делать одновременно.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Почему один синхронный поток не может занять глубину очереди

Теперь ключевой момент, из-за которого глубина 32 (или 64, или 128 — у разных контроллеров по-разному) на бумаге редко превращается в реальную скорость. Классический синхронный вызов записи или чтения — блокирующая операция: поток вызывает write() (или pwrite(), fwrite() и подобные), передаёт управление ядру и физически не может продолжить выполнение, пока ядро не сообщит, что операция завершена. Пока поток ждёт ответа, он не отправляет вторую команду — следующая строчка кода выполнится только после того, как текущий вызов вернёт управление.

Значит, для контроллера диска такой поток в любой момент времени создаёт не более одной незавершённой команды. Диск может быть физически способен параллельно обрабатывать 32 запроса — но если ему присылают запросы по одному, ожидая ответа на каждый, у него просто нет второго запроса, который можно обрабатывать параллельно с первым. Причина не в диске — модель взаимодействия «отправил — жду — получил ответ — отправил следующий» физически не может создать больше одной незавершённой операции одновременно.

Отсюда прямое следствие для пропускной способности: при синхронном последовательном выполнении она жёстко привязана к задержке одной операции (latency), а не к параллельной ёмкости диска. Если одна операция в среднем занимает время T, то при строго последовательной отправке команд за секунду пройдёт примерно 1/T таких операций — и это никак не зависит от того, способен диск теоретически принять параллельно 32 команды или 3200. Грубо это описывается законом Литтла из теории очередей: пропускная способность примерно равна глубине очереди, делённой на среднюю задержку операции. При фактической глубине очереди 1 (даже если диск паспортно поддерживает 32) в формуле стоит единица, а не тридцать два — вся паспортная параллельная ёмкость диска для этого потока просто не существует.

Это ограничение накладывает не диск и не файловая система — оно встроено в саму последовательную логику блокирующего вызова. Даже гипотетически бесконечно быстрый диск не спасёт: однопоточная программа с синхронным I/O всё равно будет ограничена задержкой одной операции, потому что следующий вызов физически не может начаться, пока не завершился предыдущий.

Асинхронный I/O: как приложение всё-таки заполняет очередь

Чтобы реально задействовать глубину очереди диска, приложению нужно физически держать «в полёте» несколько незавершённых операций одновременно — не ждать ответа на команду, прежде чем отправить следующую. Есть два практических способа, и они не взаимоисключающие.

Первый — настоящий асинхронный ввод-вывод на уровне ядра. В Linux это исторически POSIX AIO и libaio, а на современных ядрах — io_uring, который сейчас считается наиболее эффективным способом добиться высокой параллельности I/O с минимальными накладными расходами. Идея одна: приложение отправляет запрос, немедленно получает управление обратно, не дожидаясь завершения, и позже — через колбэк, событие или опрос — узнаёт о готовности результата. Пока одна операция обрабатывается диском, поток может отправить следующую, третью, и так далее — вплоть до нужной глубины.

Грубая схема работы с io_uring на уровне логики (без деталей конкретного API):

// псевдокод, иллюстрирующий модель, а не рабочий код
for (i = 0; i < N; i++) {
    io_uring_prep_write(&sqe[i], fd, buf[i], size, offset[i]);
    io_uring_queue_submit(&ring); // не ждём завершения
}
// теперь диску отправлено N команд "в полёте" одновременно
while (completed < N) {
    io_uring_wait_cqe(&ring, &cqe); // забираем готовые результаты по мере поступления
    completed++;
}

Ключевое отличие от синхронного варианта — цикл отправки команд полностью отделён от цикла получения результатов. Все N команд оказываются в очереди диска примерно одновременно, а не по одной с ожиданием между ними.

Второй способ — многопоточность как «бедный, но рабочий» аналог асинхронности: если каждый из нескольких потоков делает обычный блокирующий вызов, но потоков несколько и они работают параллельно, то с точки зрения диска в очереди в любой момент может оказаться несколько незавершённых команд — по одной от каждого активного потока. Это дороже по накладным расходам, чем честный io_uring (создание и переключение потоков стоит ресурсов ядра), но реально работает и именно так устроена параллельность в большинстве обычных приложений.

Где параллелизм есть от природы, а где его нет

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

Естественный параллелизм есть там, где одновременно активно много независимых источников запросов:

  • СУБД с пулом соединений. PostgreSQL или MySQL с несколькими десятками активных клиентов создают естественную параллельную нагрузку — каждое соединение независимо ждёт своего диска, и суммарно к накопителю в моменте могут идти сразу несколько десятков запросов. У PostgreSQL есть параметр effective_io_concurrency, который прямо указывает планировщику, сколько параллельных запросов чтения имеет смысл отправлять диску при последовательном сканировании — явное признание, что параллелизм нужно создавать сознательно (подробнее — в статье про тюнинг PostgreSQL на сервере).
  • Веб-сервер с асинхронной моделью. Nginx с директивой aio threads выносит блокирующие файловые операции в пул потоков, чтобы одна медленная операция не блокировала обработку остальных соединений — и заодно даёт диску несколько параллельных запросов вместо одного.
  • Node.js и подобные event-loop-рантаймы. Файловые операции по умолчанию уходят в libuv-пул потоков ограниченного размера (UV_THREADPOOL_SIZE, по умолчанию 4) — параллельность для диска есть, но ограниченная размером пула.

Параллелизма нет или почти нет там, где по конструкции запросы идут строго друг за другом: однопоточная утилита копирования вроде dd if=/dev/zero of=file bs=4k без специальных флагов или cp одного большого файла — по сути последовательность блокирующих вызовов записи; приложение, которое пишет лог построчно синхронным вызовом и ждёт подтверждения перед следующей записью — типичный паттерн QD1; однопоточный процесс, читающий файлы по одному и полностью обрабатывающий каждый перед открытием следующего.

Отдельный практический нюанс: если нужно скопировать много отдельных небольших файлов, а не один большой, параллельность легко получить без переписывания кода — запустить несколько копирований одновременно через xargs -P:

find /data -type f -name '*.log' -print0 | xargs -0 -P 8 -I{} cp {} /backup/

Здесь восемь параллельных процессов cp создают для диска реальную параллельную нагрузку — каждый со своим потоком блокирующих вызовов, но одновременно их несколько, и суммарно диск видит не глубину 1, а что-то в районе восьми.

Практические выводы для проектирования приложений

Из разобранного следует несколько прикладных правил для проектирования и отладки производительности дискового ввода-вывода.

Во-первых, прежде чем ругать диск за низкую скорость, проверьте, какую реальную глубину очереди генерирует ваше приложение. Команда iostat -x 1 во время типичной нагрузки покажет колонку aqu-sz (average queue size) — это фактическая средняя глубина очереди на диске прямо сейчас. Если там стабильно единица, а приложение однопоточное и синхронное — это ожидаемо, и дело не в диске.

Во-вторых, покупка более быстрого NVMe не ускорит однопоточное приложение с синхронным I/O пропорционально паспортным характеристикам — оно упирается не в параллельную пропускную способность диска, а в задержку одной операции, а задержка у разных моделей NVMe отличается не так драматично, как заявленная пиковая пропускная способность.

В-третьих, если приложению действительно нужна высокая пропускная способность, параллелизм нужно создавать осознанно на уровне архитектуры: честным асинхронным I/O (io_uring, libaio), пулом потоков подходящего размера или естественной параллельностью множества независимых клиентов (как в СУБД с пулом соединений). Размер подбирайте не наугад, а по реальной конкурентности задачи — маленький пул недоиспользует диск, слишком большой добавляет накладные расходы на переключение контекста без прироста скорости.

В-четвёртых, RAID поверх нескольких NVMe не снижает требование к параллелизму со стороны приложения, а повышает его: чтобы задействовать несколько дисков одновременно, нужно ещё больше параллельных запросов, иначе выигрыш от массива останется на бумаге так же, как выигрыш от одной глубокой очереди (когда такой массив вообще нужен — отдельный вопрос, разобранный в статье про NVMe в RAID на выделенном сервере).

В-пятых, тестировать диск стоит с параметрами, которые соответствуют реальному профилю нагрузки, а не паспортным максимумам. В fio это управляется ключами --iodepth и --numjobs:

[app-like-load]
ioengine=libaio
rw=randrw
rwmixread=70
bs=8k
direct=1
iodepth=16
numjobs=4
size=4G
runtime=60
time_based

Если приложение по факту синхронное и однопоточное, честный тест для планирования мощностей — это iodepth=1, numjobs=1: он покажет ту скорость, которую вы реально получите, а не ту, что нарисована на коробке диска.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если диск поддерживает глубину очереди 32, а моё приложение однопоточное и синхронное — есть ли смысл брать NVMe вместо более дешёвого SATA SSD?

Да: даже при глубине очереди 1 задержка одной операции у NVMe обычно ниже за счёт более короткого пути команды до контроллера через PCIe, без промежуточного уровня AHCI. Прирост будет не в кратности паспортной пропускной способности, а в снижении задержки отдельной операции — но это тоже реальная выгода для синхронной нагрузки.

Как понять, использует ли моя СУБД параллелизм диска на практике, а не только теоретически поддерживает его?

Смотрите на iostat -x 1 и колонку aqu-sz под реальной нагрузкой. Если там стабильно 1-2 даже при настроенном пуле соединений — узкое место, возможно, не в диске, а в блокировках СУБД, недостаточном effective_io_concurrency или ограничении по числу активных соединений.

Даёт ли увеличение числа потоков неограниченный рост параллелизма для диска?

Нет. После какого-то числа параллельных запросов диск выходит на насыщение реальной пропускной способности, и дальнейший рост параллелизма только добавляет задержку без прироста скорости — плюс растут накладные расходы самого приложения. Оптимальное число подбирается замером под конкретную задачу.

Чем io_uring лучше пула потоков с блокирующими вызовами для той же задачи?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →