Операционные системы Ввод-вывод, драйверы, прерывания и DMA
0%

Ввод-вывод, драйверы, прерывания и DMA

Ввод-вывод, драйверы, прерывания и DMA

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

Прикладной программист живёт в иллюзии, что read(fd, buf, 4096) — это как чтение из массива, только «чуть медленнее». Иллюзию держит ядро, и держит дорого. Разница между «чуть медленнее» и реальностью — пять порядков:

Операция Латентность В масштабе «одна секунда = такт CPU»
L1 cache ~1 нс 1 секунда
Обращение к DRAM ~80 нс 1,5 минуты
MMIO-чтение регистра PCIe ~1–2 мкс ~30 минут
NVMe SSD, случайное чтение 4 КиБ ~80 мкс сутки
SATA SSD ~200 мкс 2,5 суток
HDD, seek ~8 мс 3 месяца
Сеть внутри ДЦ, round-trip ~200 мкс 2,5 суток

Обратите внимание на строку MMIO: чтение одного регистра устройства дороже, чем чтение из DRAM, в 20 раз. Это фундаментальный факт, из которого растёт почти вся архитектура современного ввода-вывода — и DMA, и кольца дескрипторов, и объединение прерываний. Задача всего стека — трогать устройство как можно реже.

В этой статье мы пройдём путь снизу вверх: как CPU вообще адресует устройство, как устройство сообщает о готовности, как данные переносятся без участия CPU, как это склеено в подсистемы Linux и BSD, и что из этого меняет решения в прикладном коде. Про границу ядра и пользователя предполагается прочитанным Архитектуры ядер, про страницы и кэш — Управление памятью.

Три способа поговорить с устройством

Port I/O: исторический тупик, который до сих пор жив

На x86 есть отдельное 16-битное адресное пространство портов, никак не пересекающееся с памятью. Доступ — специальными инструкциями in/out:

/* Классика: чтение статуса контроллера клавиатуры i8042 */
static inline unsigned char inb(unsigned short port) {
    unsigned char v;
    asm volatile ("inb %1, %0" : "=a"(v) : "Nd"(port));
    return v;
}
unsigned char status = inb(0x64);   /* порт статуса клавиатуры */

Порты — наследие эпохи, когда адресных линий было мало и жалко было отдавать память под устройства. Их всего 65536, они не масштабируются, не работают за пределами x86 и требуют привилегий (в Linux — capability CAP_SYS_RAWIO и ioperm(2)/iopl(2)). ARM, RISC-V, POWER портов не имеют вовсе. Сегодня port I/O остался в legacy-железе: i8042, PIT, CMOS, конфигурационное пространство PCI через 0xCF8/0xCFC, отладочный порт 0x80.

MMIO: регистры устройства выглядят как память

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

Физическое адресное пространство: DRAM, MMIO-окна и порты

Для PCI/PCIe эти окна называются BAR (Base Address Register). Прошивка или ядро при перечислении шины назначает каждому устройству физические адреса и записывает их в конфигурационное пространство:

$ lspci -v -s 00:1f.6
00:1f.6 Ethernet controller: Intel Corporation Ethernet Connection (2) I219-V
	Subsystem: Dell Device 0704
	Flags: bus master, fast devsel, latency 0, IRQ 130
	Memory at ec200000 (32-bit, non-prefetchable) [size=128K]
	Capabilities: [c8] Power Management version 2
	Capabilities: [d0] MSI: Enable+ Count=1/1 Maskable- 64bit+
	Kernel driver in use: e1000e

Memory at ec200000 ... [size=128K] — это BAR0. bus master в флагах значит, что устройству разрешено самому инициировать транзакции на шине, то есть делать DMA. Всё это видно и из sysfs:

$ cat /sys/bus/pci/devices/0000:00:1f.6/resource | head -2
0x00000000ec200000 0x00000000ec21ffff 0x0000000000040200
0x0000000000000000 0x0000000000000000 0x0000000000000000
# начало, конец, флаги ресурса

Драйвер не может просто разыменовать физический адрес — ядро работает в виртуальных адресах. Нужен ioremap(), который создаёт отображение с правильными атрибутами страницы (некэшируемая, «строгий порядок»):

/* Упрощённый фрагмент типичного PCI-драйвера */
static int probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    void __iomem *regs;
    int err;

    err = pcim_enable_device(pdev);         /* включить устройство, managed-версия */
    if (err) return err;

    err = pcim_iomap_regions(pdev, BIT(0), KBUILD_MODNAME); /* запросить и отобразить BAR0 */
    if (err) return err;
    regs = pcim_iomap_table(pdev)[0];

    pci_set_master(pdev);                   /* разрешить устройству DMA */

    /* Чтение и запись — ТОЛЬКО через аксессоры */
    u32 ctrl = readl(regs + REG_CTRL);
    writel(ctrl | CTRL_RESET, regs + REG_CTRL);
    ...
}

Три причины, почему *(volatile u32*)regs — недостаточно и опасно:

  1. Компилятор. volatile спасает от выбрасывания и слияния обращений, но не от переупорядочивания относительно неволатильных операций.
  2. Процессор. Ядро x86 может переставить запись после чтения; ARM переставит почти всё. Порядок записей в регистры устройства часто критичен: «сначала адрес, потом команда». Отсюда wmb(), rmb(), mb() и «MMIO-специфичные» барьеры.
  3. Тип отображения. readl/writel на некоторых архитектурах ещё и делают little-endian-конверсию (PCI всегда little-endian), поэтому один и тот же драйвер работает на big-endian POWER.

Аналоги в других системах: FreeBSD — bus_space_read_4() / bus_space_write_4() с дескриптором bus_space_tag_t (более абстрактная модель, покрывающая и порты, и MMIO); NetBSD — та же bus_space(9), откуда FreeBSD её и взяла; Windows — READ_REGISTER_ULONG() / WRITE_REGISTER_ULONG() и MmMapIoSpace(); macOS/IOKit — IOMemoryMap и IOPCIDevice::ioRead32().

Прерывания: устройство само стучится

Третий канал — обратный. Как узнать, что диск дочитал сектор? Вариантов два.

Опрос (polling) — крутиться в цикле, читая регистр статуса. Просто, минимальная латентность реакции, но сжигает ядро CPU. Оправдан при очень высоких скоростях: DPDK и SPDK опрашивают NIC и NVMe именно так, потому что при 10 миллионах пакетов в секунду прерывания физически не успеют.

Прерывание (interrupt) — устройство дёргает линию, CPU прекращает текущую работу, сохраняет минимальный контекст и прыгает по вектору в таблице прерываний (IDT на x86, вектор через VBAR_EL1 на ARM64). Не тратит CPU впустую, но каждое прерывание стоит 1–3 мкс с учётом порчи кэша и конвейера.

Правильный ответ на практике — гибрид. Об этом ниже, в разделе про NAPI.

Эволюция контроллеров прерываний

Это не археология — разница видна прикладно. Со старыми PIC-прерываниями несколько устройств делили одну линию, и ядро при каждом прерывании обходило список зарегистрированных обработчиков, спрашивая «это твоё?» (флаг IRQF_SHARED). При MSI-X у каждой очереди сетевой карты свой вектор, привязанный к своему ядру CPU, — и вот отсюда растёт RSS, многопоточная обработка сети и разница между «сервер держит 200 Кпакетов/с» и «сервер держит 5 Мпакетов/с».

Посмотрите на реальную систему:

$ cat /proc/interrupts | head -1; grep -E 'nvme|eth|enp' /proc/interrupts
            CPU0       CPU1       CPU2       CPU3
 130:          0          0          0          0  PCI-MSI 1572864-edge      enp0s31f6
 145:     412094          0          0          0  PCI-MSI 524288-edge       nvme0q0
 146:   18274531          0          0          0  PCI-MSI 524289-edge       nvme0q1
 147:          0   17993402          0          0  PCI-MSI 524290-edge       nvme0q2
 148:          0          0   18110284          0  PCI-MSI 524291-edge       nvme0q3
 149:          0          0          0   17842116  PCI-MSI 524292-edge       nvme0q4

Видно всё: PCI-MSI — тип, nvme0q1..q4 — по одной очереди на ядро, и каждая очередь бьёт строго в своё ядро (диагональ в матрице). Это ровно та структура, которая позволяет blk-mq не брать глобальных блокировок. Привязка настраивается:

$ cat /proc/irq/146/smp_affinity_list
1
$ echo 2 | sudo tee /proc/irq/146/smp_affinity_list   # перевести IRQ 146 на CPU2

В FreeBSD то же самое смотрится через vmstat -i и procstat -t, привязка — через cpuset -x <irq> -l <cpu>. В OpenBSD — vmstat -i, привязки нет по идеологии. В Windows — Performance Monitor и Interrupt-Affinity Policy, в macOS — ioreg -l.

Путь прерывания: верхняя и нижняя половины

Обработчик прерывания выполняется в особом контексте: не в контексте процесса, со запрещёнными прерываниями (или частью из них), без права спать, без права брать мьютексы и вызывать kmalloc(GFP_KERNEL). Если он будет работать долго — вся система встанет: подскочит латентность, начнут теряться другие прерывания.

Отсюда классическое разделение: верхняя половина (top half, hard IRQ) делает минимум — подтверждает прерывание устройству, забирает данные из регистров, планирует продолжение; нижняя половина (bottom half) делает всю тяжёлую работу в более комфортном контексте.

В Linux нижних половин исторически несколько, и выбор между ними — реальное инженерное решение:

Механизм Контекст Может спать Параллельно на разных CPU Когда применять
softirq атомарный, приоритет выше задач нет да, один тип на разных CPU одновременно сеть, блочный I/O, таймеры; статически заданный набор
tasklet поверх softirq нет нет (один тасклет — на одном CPU) легаси; в новом коде не используют
workqueue поток ядра, обычный контекст да да всё, что требует сна, аллокаций, I/O
threaded IRQ выделенный поток ядра на IRQ да да request_threaded_irq(), основа PREEMPT_RT
$ cat /proc/softirqs | head -5
                    CPU0       CPU1       CPU2       CPU3
          HI:          0          1          0          0
       TIMER:   14208741   13871293   14022183   13998201
      NET_TX:      22843      19203      20117      19844
      NET_RX:   18274102     172041     169822     171203

Перекос NET_RX на CPU0 — типичный симптом того, что весь приём сети обрабатывается одним ядром: либо у карты одна очередь, либо RSS не настроен, либо irqbalance привязал всё в одно место. Диагноз: одно ядро в si (softirq) на 100 % в top, остальные простаивают, а пропускная способность упёрлась.

Ключевой момент про NAPI: когда пакетов много, прерывания вредны. NAPI (New API, в Linux с 2.4.20) переключается на опрос: первое прерывание маскирует IRQ на карте и запускает поллинг, драйвер выгребает пакеты пачками по budget (по умолчанию 64), и только когда очередь опустела, прерывания включаются обратно. Получается автоматический гибрид: низкая латентность при малой нагрузке, высокая пропускная способность при большой. То же самое на уровне железа делает interrupt coalescing:

$ ethtool -c enp3s0
rx-usecs: 8          # подождать 8 мкс перед прерыванием
rx-frames: 32        # или пока не накопится 32 кадра
adaptive-rx: on      # драйвер сам подстраивает под нагрузку

$ sudo ethtool -C enp3s0 rx-usecs 0 adaptive-rx off   # минимальная латентность, максимум IRQ

Классический trade-off: rx-usecs 0 даёт минимальную латентность и убивает CPU на высоком PPS; rx-usecs 100 даёт отличную пропускную способность и +100 мкс к p99. Для трейдинга и low-latency-сервисов крутят в ноль, для стриминга видео — вверх.

DMA: перенос данных без участия процессора

Если бы CPU сам копировал каждый байт из регистра устройства в память (это называется PIO, programmed I/O), NVMe на 7 ГБ/с потребовал бы больше пропускной способности ядра, чем оно физически имеет. Поэтому современные устройства — bus masters: они сами лезут в память.

Драйвер не передаёт устройству указатель. Он передаёт DMA-адрес — адрес в том адресном пространстве, которое видит устройство. Без IOMMU он совпадает с физическим (плюс возможное смещение шины), с IOMMU это IOVA, транслируемый таблицами IOMMU.

Проблема, которую решает это разделение, — когерентность кэшей. На x86 DMA когерентен аппаратно: если устройство пишет в память, снуп-протокол инвалидирует соответствующие строки кэша CPU. На многих ARM, MIPS и старых архитектурах — нет, и драйвер обязан явно сбрасывать и инвалидировать кэш. API dma_sync_single_for_cpu() / ..._for_device() существует именно для того, чтобы один и тот же код драйвера работал на обеих. На x86 эти вызовы компилируются почти в ничего, на ARM — в реальные инструкции обслуживания кэша. Пропущенный dma_sync — классический баг «работает на моём x86-стенде, разваливается на embedded-плате».

Скелет типичной streaming-передачи:

/* Отправка буфера skb в TX-кольцо */
dma_addr_t dma;

dma = dma_map_single(&pdev->dev, skb->data, skb->len, DMA_TO_DEVICE);
if (dma_mapping_error(&pdev->dev, dma))
    goto drop;                       /* маппинг может не удаться — IOVA кончились */

desc->addr = cpu_to_le64(dma);       /* 1. заполнить дескриптор */
desc->len  = cpu_to_le16(skb->len);
desc->flags = cpu_to_le16(DESC_OWN | DESC_EOP);

wmb();                               /* 2. БАРЬЕР: дескриптор должен быть виден раньше doorbell */
writel(tx_tail, regs + REG_TX_TAIL); /* 3. doorbell — «забирай» */

/* ...позже, в обработчике завершения: */
dma_unmap_single(&pdev->dev, dma, skb->len, DMA_TO_DEVICE);
dev_kfree_skb(skb);

Шаг 2 — то место, где ломаются драйверы. Без барьера процессор имеет полное право сделать MMIO-запись раньше, чем запись дескриптора дойдёт до памяти; устройство прочитает недописанный дескриптор со старым адресом и запишет DMA в чужую страницу. Отладка такого — ад: баг воспроизводится раз в сутки под нагрузкой.

Кольца дескрипторов

Почти всё быстрое железо — NIC, NVMe, virtio, GPU — общается через кольцевые буферы дескрипторов в обычной DRAM. Устройство и драйвер владеют разными частями кольца, границы задаются указателями head/tail. Ключевая экономия: на N операций приходится одна MMIO-запись (doorbell) и одно прерывание.

Кольцо дескрипторов, DMA и doorbell

NVMe строится на той же идее и потому так быстр: у него до 65535 пар очередей submission/completion, каждая привязана к своему ядру, каждая со своим MSI-X-вектором. Ни блокировок, ни межъядерного трафика, ни единой точки сериализации — в отличие от AHCI/SATA с его одной очередью на 32 команды.

$ sudo nvme id-ctrl /dev/nvme0 | grep -E '^(mdts|sqes|cqes)'
mdts      : 5          # max data transfer = 2^5 * min page = 128 КиБ за команду
sqes      : 0x66
cqes      : 0x44
$ ls /sys/block/nvme0n1/mq/     # видимые ядру аппаратные очереди
0  1  2  3  4  5  6  7

IOMMU: почему DMA — это ещё и вопрос безопасности

Устройство, делающее DMA по физическим адресам, может писать куда угодно, включая структуры ядра. Это не теория: атаки через Thunderbolt/FireWire (DMA-attack, «Thunderclap», 2019) позволяли считать ключи из памяти заблокированного ноутбука, просто воткнув устройство. IOMMU (Intel VT-d, AMD-Vi, ARM SMMU) ставит между устройством и памятью таблицу трансляции: устройство видит только явно отображённые страницы.

$ dmesg | grep -i -E 'dmar|iommu' | head -4
DMAR: IOMMU enabled
DMAR: Intel(R) Virtualization Technology for Directed I/O
iommu: Default domain type: Translated
pci 0000:00:1f.6: Adding to iommu group 12

Побочные эффекты IOMMU, которые надо знать: небольшой оверхед (IOTLB-промахи на больших потоках), режим iommu.passthrough=1 для максимальной производительности ценой защиты, и режим iommu.strict=0 (ленивая инвалидация) — компромисс. И именно IOMMU делает возможным VFIO: безопасный проброс устройства в виртуальную машину или в userspace-драйвер, см. Виртуализация и контейнеры.

Модель устройства и драйвера в Linux

Ядро видит три классических класса устройств:

  • Символьные (character) — поток байтов без адресации: терминалы, /dev/null, /dev/random, звук, датчики. Работают через read/write/ioctl/mmap.
  • Блочные (block) — адресуемые блоки фиксированного размера, кэшируются page cache, поддерживают произвольный доступ: диски, разделы, RAID, LVM.
  • Сетевые — вообще не файлы. У сетевого интерфейса нет узла в /dev; он живёт в пространстве имён net и доступен через сокеты, netlink и ioctl на сокете. Подробнее — Сетевой стек.
$ ls -l /dev/null /dev/nvme0n1 /dev/ttyS0
crw-rw-rw- 1 root root   1, 3 Jul 16 09:12 /dev/null      # c = character, major 1, minor 3
brw-rw---- 1 root disk 259, 0 Jul 16 09:12 /dev/nvme0n1   # b = block,     major 259
crw-rw---- 1 root dialout 4, 64 Jul 16 09:12 /dev/ttyS0
$ cat /proc/devices | head -8
Character devices:
  1 mem
  4 tty
  5 /dev/tty
 10 misc
Block devices:
  8 sd
259 blkext

Пара (major, minor) — это то, как open("/dev/nvme0n1") находит драйвер: VFS видит в inode тип «блочное устройство» и номера, идёт в таблицу драйверов и подставляет нужный набор операций. Файловая система при этом ни при чём — сам файл /dev/nvme0n1 не содержит данных, он лишь ссылка на драйвер. Поэтому mknod может создать рабочий узел устройства в любой директории, и поэтому монтирование с nodev — важная мера безопасности.

Наполнение /dev устроено по-разному:

Система Механизм Особенности
Linux (совр.) devtmpfs + udev/eudev/mdev ядро создаёт узлы, udev в userspace переименовывает, ставит права, symlinks в /dev/disk/by-uuid/
FreeBSD devfs (в ядре) + devd узлы создаются ядром динамически; devd реагирует на события правилами в /etc/devd.conf
OpenBSD статический /dev + MAKEDEV принципиально без динамики; узлы созданы заранее скриптом
NetBSD статический /dev или devpubd обе модели
macOS devfs + IOKit /dev минималистичен, реальная модель устройств — в ioreg
Windows NT объектный менеджер \Device\Harddisk0, символические ссылки \DosDevices\C:, никакого /dev

Минимальный символьный драйвер

Ниже — компилируемый каркас, который стоит прочитать целиком: он показывает всю модель «файловые операции + регистрация» в 60 строк.

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>

#define BUFSZ 4096
static char buf[BUFSZ];
static size_t used;
static dev_t devno;
static struct cdev cdev;
static struct class *cls;

static ssize_t demo_read(struct file *f, char __user *ubuf, size_t n, loff_t *off)
{
    if (*off >= used) return 0;                  /* EOF */
    n = min(n, used - (size_t)*off);
    /* copy_to_user — единственный законный способ тронуть память процесса:
       он проверяет права и корректно обрабатывает page fault */
    if (copy_to_user(ubuf, buf + *off, n)) return -EFAULT;
    *off += n;
    return n;                                     /* вернуть МЕНЬШЕ запрошенного — законно! */
}

static ssize_t demo_write(struct file *f, const char __user *ubuf, size_t n, loff_t *off)
{
    n = min(n, (size_t)BUFSZ);
    if (copy_from_user(buf, ubuf, n)) return -EFAULT;
    used = n;
    return n;
}

static const struct file_operations fops = {
    .owner  = THIS_MODULE,
    .read   = demo_read,
    .write  = demo_write,
    .llseek = default_llseek,
};

static int __init demo_init(void)
{
    alloc_chrdev_region(&devno, 0, 1, "demo");   /* динамический major */
    cdev_init(&cdev, &fops);
    cdev_add(&cdev, devno, 1);
    cls = class_create("demo");
    device_create(cls, NULL, devno, NULL, "demo"); /* udev создаст /dev/demo */
    pr_info("demo: major=%d\n", MAJOR(devno));
    return 0;
}

static void __exit demo_exit(void)
{
    device_destroy(cls, devno);
    class_destroy(cls);
    cdev_del(&cdev);
    unregister_chrdev_region(devno, 1);
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");

Три вещи, которые отсюда полезно унести даже если вы никогда не напишете драйвер:

  1. read() имеет право вернуть меньше, чем запрошено. Не «может теоретически», а постоянно так делает — на сокетах, пайпах, терминалах, при получении сигнала. Код n = read(fd, buf, 4096); assert(n == 4096); сломается в проде. Всегда цикл.
  2. copy_to_user может заснуть (страница пользователя выгружена → page fault → чтение со свопа). Поэтому файловые операции нельзя вызывать из атомарного контекста, и поэтому у драйверов есть сложные правила блокировок.
  3. ioctl — это дыра в типизации. Всё, что не укладывается в read/write, идёт через ioctl с произвольной структурой. Отсюда бесконечные CVE на неправильной валидации пользовательских указателей и разница compat_ioctl для 32-битных процессов.

sysfs, kobject и «правильный» способ настроить драйвер

Современный Linux не любит ioctl для конфигурации: вместо него — файлы в /sys. Каждый объект ядра (struct kobject) отображается в директорию, каждый атрибут — в файл.

$ ls /sys/block/nvme0n1/queue/
add_random  discard_max_bytes  io_poll  logical_block_size  max_sectors_kb
nomerges    nr_requests        optimal_io_size  read_ahead_kb  rotational  scheduler

$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline kyber bfq
$ cat /sys/block/nvme0n1/queue/rotational
0
$ cat /sys/block/nvme0n1/queue/nr_requests
1023

Это не только диагностика — это рабочий инструмент тюнинга. rotational=0 меняет поведение readahead и планировщика; read_ahead_kb напрямую влияет на скорость последовательного чтения (для базы данных с случайным доступом большой readahead — вред, лишний трафик); scheduler определяет, что делать с очередью запросов.

Блочный стек: от write() до сектора

Здесь для прикладного программиста спрятаны самые дорогие практические выводы:

Ваш write() не записал ничего на диск. Он положил данные в page cache и пометил страницу грязной. Данные окажутся на носителе, когда сработает writeback-поток (по таймеру dirty_expire_centisecs, по порогу dirty_ratio) или когда вы вызовете fsync(). Падение машины между write() и записью теряет данные. Это причина, по которой каждая нормальная СУБД делает fsync() (или fdatasync()) на журнале перед подтверждением транзакции.

$ sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs
vm.dirty_ratio = 20                  # с 20% RAM грязных страниц пишущий процесс блокируется
vm.dirty_background_ratio = 10       # с 10% начинается фоновая запись
vm.dirty_expire_centisecs = 3000     # страница старше 30 с пойдёт на запись

Классическая беда: сервер с 256 ГБ RAM, dirty_ratio=20 → до 50 ГБ грязных данных могут копиться, а потом разом хлынуть на диск. Латентность всех операций I/O улетает на секунды. Лечение — снижать dirty_background_bytes до абсолютного значения (не процента).

O_DIRECT — не «быстрее», а «в обход». Он выключает page cache: нет двойного кэширования, нет прогрева, но появляются жёсткие требования выравнивания (буфер, смещение и длина кратны логическому размеру блока — отсюда posix_memalign) и полностью пропадает readahead. Полезен там, где приложение само кэширует лучше ядра (PostgreSQL частично, Oracle, ScyllaDB, любая СУБД со своим буферным пулом). Для типичного приложения — вред. Даже O_DIRECT не гарантирует, что данные на пластинах: он обходит page cache, но не кэш самого диска, поэтому нужен ещё O_DSYNC или fsync().

Планировщик I/O имеет значение только там, где очередь длиннее устройства. Для NVMe с внутренним параллелизмом в тысячи команд правильный выбор — none: любая сортировка только добавит задержку. Для HDD, где seek стоит 8 мс, bfq или mq-deadline дают кратный выигрыш. Для смешанной нагрузки на SATA SSD с задачей «не дать батчу задушить интерактив» — bfq с cgroup-весами.

$ echo mq-deadline | sudo tee /sys/block/sda/queue/scheduler
$ cat /sys/block/sda/queue/iosched/write_expire   # дедлайн записи, мс
5000

Реальная диагностика блочного слоя:

$ iostat -xz 1 /dev/nvme0n1
Device  r/s     w/s   rkB/s   wkB/s  r_await  w_await  aqu-sz  %util
nvme0n1 8241.0  312.0 33124.0 2496.0    0.09     0.31    0.78   41.2
# r_await 0.09 мс — здоровый NVMe; aqu-sz < 1 — устройство не насыщено;
# %util для NVMe почти бессмыслен: параллельное устройство может быть "100% busy" и скучать

$ sudo bpftrace -e 'tracepoint:block:block_rq_issue { @[args->comm] = count(); }'
$ sudo blktrace -d /dev/sda -o - | blkparse -i -   # полная трассировка жизни запроса

Про то, как файловая система превращает ваш файл в LBA, — в статье Файловые системы.

Модели ввода-вывода для прикладного кода

Всё вышеописанное всплывает в API, которым вы пользуетесь ежедневно.

Фундаментальное различие, которое стоит понять раз и навсегда: модель готовности против модели завершения.

  • Готовность (readiness): epoll (Linux), kqueue (FreeBSD/macOS/NetBSD/OpenBSD), /dev/poll (Solaris), poll/select (везде). Ядро сообщает, что операция не заблокируется; сам системный вызов вы делаете потом. Отлично работает для сокетов и пайпов. Не работает для обычных файлов: файл на диске «всегда готов» с точки зрения epoll, а read() на нём всё равно заблокируется на 80 мкс, пока NVMe не ответит. Это причина, по которой Node.js, libuv и Netty держат отдельный пул потоков под файловый I/O.
  • Завершение (completion): io_uring (Linux 5.1+), IOCP (Windows NT — с 1994 года!), POSIX AIO (де-факто мёртв: в glibc эмулируется потоками, в Linux io_submit блокируется на многих путях). Вы отправляете описание операции, ядро выполняет и кладёт результат. Работает одинаково для файлов и сокетов, позволяет батчить сотни операций за один системный вызов, а в режиме SQPOLL — вообще без системных вызовов.
/* io_uring: чтение файла без единого read(), через liburing */
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, (void *)42);
io_uring_submit(&ring);                    /* можно отправить сразу пачку */

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);            /* дождаться завершения */
printf("read %d bytes, user_data=%llu\n", cqe->res, (unsigned long long)cqe->user_data);
io_uring_cqe_seen(&ring, cqe);

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

Практическая матрица выбора:

Задача Linux FreeBSD/macOS Windows
10–100 соединений потоки, блокирующий I/O то же то же
10к+ сокетов epoll (или io_uring) kqueue IOCP / RIO
Файловый I/O с высоким QD io_uring пул потоков + aio_* IOCP
Передача файла в сокет sendfile, splice sendfile TransmitFile
Zero-copy сеть, max PPS AF_XDP, DPDK netmap, DPDK RIO, DPDK

Драйверы в пользовательском пространстве

Драйвер в ядре — это код без защиты памяти, с полным доступом ко всему, где падение равно краху системы. Соблазн вынести его в userspace огромен, и есть три пути.

FUSE — файловые системы в userspace (sshfs, s3fs, rclone, gocryptfs, ntfs-3g). Цена: каждая операция — два лишних перехода границы. stat() в FUSE-ФС стоит 20–50 мкс против 1 мкс на ext4. Для архивов и сетевых хранилищ приемлемо, для базы данных — нет.

UIO / VFIO — ядро отдаёт в userspace MMIO-регионы и прерывания, а вся логика драйвера живёт в процессе. VFIO при этом использует IOMMU, поэтому «дикий» DMA невозможен: процесс может писать только в отображённые ему страницы. Это фундамент DPDK (сеть) и SPDK (NVMe), где polling-драйвер в userspace обрабатывает десятки миллионов пакетов в секунду на ядро.

$ sudo dpdk-devbind.py --bind=vfio-pci 0000:03:00.0   # отобрать NIC у ядра
$ ls /dev/vfio/
12  vfio

libusb / hidapi — для USB большинство «драйверов» вообще не в ядре: ядро предоставляет generic-транспорт, а логика устройства (принтера, программатора, осциллографа) живёт в библиотеке.

Здесь же — важное различие философий. Windows начиная с Vista активно двигает драйверы в userspace через UMDF (User-Mode Driver Framework): драйвер принтера или сканера падает — падает служба, не система. macOS с версии 10.15 объявила kext’ы устаревшими и перевела сторонние драйверы на DriverKit — userspace-расширения с ограниченным API. Linux остаётся самым «ядерным», но и там VFIO и io_uring активно размывают границу.

Разные ОС — разные модели драйвера

Три различия, которые действительно важны на практике:

Стабильность ABI. Windows держит стабильный интерфейс драйверов десятилетиями: драйвер для Windows 10 обычно работает на Windows 11. Linux принципиально этого не делает (см. Documentation/process/stable-api-nonsense.rst в дереве ядра): API меняется свободно, взамен все in-tree драйверы правятся вместе с изменением. Следствие для практики: проприетарный драйвер под Linux (NVIDIA, старые VPN-модули) ломается при каждом обновлении ядра, и существует DKMS для пересборки. Это не техническая недоработка, а осознанный выбор в пользу скорости эволюции ядра.

Модель стека запросов. В Windows запрос — это объект IRP, который идёт вниз по стеку драйверов (фильтр → класс → порт → шина), и каждый может его перехватить, изменить, отложить. Отсюда мощная система фильтр-драйверов (антивирусы, шифрование дисков) — и отсюда же знаменитая нестабильность, когда три антивируса встают в один стек. В Linux аналогичная композиция делается через device mapper и стекируемые блочные устройства (dm-crypt, dm-thin, md), но она явная и настраивается администратором.

Ограничения контекста. В Windows формализованы IRQL-уровни: код на DISPATCH_LEVEL не может брать страничную память или ждать. В Linux то же самое выражено неявно — «в атомарном контексте нельзя спать», проверяется отладочными опциями (CONFIG_DEBUG_ATOMIC_SLEEP). Формализация Windows строже, но и жёстче.

Подробнее про семейства — Семейство BSD и Другие операционные системы.

Типичные заблуждения

«write() вернулся — значит записано». Нет. Записано в page cache. Гарантию даёт только fsync()/fdatasync() (и проверка их кода возврата! fsync() может вернуть EIO, и в Linux до 4.13 ошибка при этом терялась — знаменитая проблема «fsyncgate», из-за которой PostgreSQL пересматривал стратегию обработки ошибок).

«O_DIRECT = запись гарантирована». Нет, O_DIRECT обходит page cache, но данные могут осесть в volatile-кэше самого диска. Нужен fsync() или O_DSYNC, которые пошлют устройству FLUSH/FUA.

«Прерывания — это быстро». Одно прерывание — 1–3 мкс с учётом входа/выхода, порчи кэша и конвейера. При 1 Мпакет/с прерывание на пакет означает 100 % CPU только на прерывания. Отсюда NAPI, coalescing и polling-драйверы.

«%util в iostat = загрузка диска». Для HDD с одной головкой — да. Для NVMe с 64 очередями значение 100 % означает лишь «была хотя бы одна операция в полёте», и устройство может быть загружено на 3 %. Смотрите aqu-sz и await.

«DMA — это медленно, оно же через шину». DMA не занимает CPU вовсе; узкое место — пропускная способность PCIe (x4 Gen4 ≈ 7,8 ГБ/с) и памяти, а не «медленность» механизма.

«Драйвер обязан быть в ядре». VFIO, UIO, FUSE, libusb, DriverKit, UMDF доказывают обратное. В ядре нужен минимум: то, что требует привилегированного доступа к железу и низкой латентности.

«epoll работает с файлами». Работает формально, но бесполезен: обычный файл всегда «готов». Для файлового I/O нужен io_uring или пул потоков.

Практический чек-лист диагностики I/O

$ sudo iotop -oPa                      # 1. кто грузит диск
$ iostat -xz 1                         # 2. латентность и глубина очереди устройства
$ sudo biolatency-bpfcc -D 10 1        # 3. ГИСТОГРАММА задержек, а не среднее
$ strace -f -T -e trace=read,write,fsync -p 1234   # 4. что делает процесс
$ mpstat -P ALL 1                      # 5. перекос %irq/%soft по ядрам
$ watch -d -n1 'grep -E "nvme|eth" /proc/interrupts'
$ ethtool -S enp3s0 | grep -E 'drop|error|miss'    # 6. дропы на карте
$ ethtool -g enp3s0                    #    размеры колец RX/TX — дефолт часто занижен
$ grep -E 'Dirty|Writeback' /proc/meminfo          # 7. сколько ещё не на диске

В FreeBSD аналоги: gstat вместо iostat -x, vmstat -i для прерываний, top -m io, dtrace -n 'io:::start'. В macOS — fs_usage, iosnoop, sudo powermetrics. Глубже про инструментарий — Наблюдаемость и производительность.

Что из этого влияет на ваш код

Свернём в набор решений, которые реально принимает прикладной программист:

  1. Батчите. Каждое пересечение границы и каждое обращение к устройству дороги: writev() вместо десяти write(), io_uring с пачкой SQE, буферизация в приложении.
  2. Размер запроса важнее, чем кажется. Чтение по 512 байт и по 128 КиБ отличается по пропускной способности в десятки раз. Ориентир — optimal_io_size в sysfs.
  3. Средняя латентность врёт. Гистограмма (biolatency, hist() в bpftrace) показывает бимодальность «попал в page cache / пошёл на устройство» — среднее её маскирует.
  4. Модель I/O выбирают под нагрузку, а не по моде. Сто соединений — потоки проще и быстрее в разработке; десять тысяч — epoll/kqueue; файловый I/O с глубокой очередью — io_uring.
  5. fsync() дорог и обязателен. Не на каждую запись, но обязательно перед подтверждением клиенту, что данные сохранены. И с проверкой кода возврата.
  6. Настройка ядра — часть приложения. read_ahead_kb, scheduler, nr_requests, dirty_background_bytes, rx-usecs, IRQ affinity дают кратный выигрыш без единой строчки кода.

Источники

Что дальше

Мы разобрали, как ядро говорит с железом. Осталась вторая граница — между ядром и вашим процессом: как именно read() попадает в ядро, сколько это стоит, и какими способами процессы общаются друг с другом.

Следующая статья: Системные вызовы и межпроцессное взаимодействие.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов