Ввод-вывод, драйверы, прерывания и 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 — но с совершенно
необычной семантикой.
Для 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 — недостаточно и опасно:
- Компилятор.
volatileспасает от выбрасывания и слияния обращений, но не от переупорядочивания относительно неволатильных операций. - Процессор. Ядро x86 может переставить запись после чтения; ARM переставит почти всё.
Порядок записей в регистры устройства часто критичен: «сначала адрес, потом команда».
Отсюда
wmb(),rmb(),mb()и «MMIO-специфичные» барьеры. - Тип отображения.
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) делает всю тяжёлую работу в более комфортном контексте.
спать нельзя CPU->>NIC: маскировать прерывания устройства CPU->>SIRQ: napi_schedule() — запланировать NET_RX_SOFTIRQ Note over CPU: возврат из прерывания за ~1 мкс SIRQ->>RAM: poll: забрать до budget=64 пакетов SIRQ->>SIRQ: разбор Ethernet/IP/TCP, netfilter SIRQ->>APP: положить в sk_buff очереди сокета, разбудить Note over SIRQ: если пакетов больше — повторить,
иначе размаскировать IRQ APP->>APP: epoll_wait возвращается, recv() копирует данные
В 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.
и часто трогаемый
обеими сторонами?"} B -- "да: кольца дескрипторов,
очереди команд" --> C["Coherent / consistent DMA
dma_alloc_coherent()"] B -- "нет: разовая передача
данных пользователя" --> D["Streaming DMA
dma_map_single / dma_map_sg"] C --> C1["Память некэшируемая
или аппаратно когерентная"] C1 --> C2["Барьеры нужны,
явная синхронизация — нет"] C2 --> C3["Дорого выделять,
медленно читать CPU"] D --> D1["dma_map_*: получить DMA-адрес,
сбросить кэши, настроить IOMMU"] D1 --> D2{"Устройство
умеет 64-битный
DMA?"} D2 -- нет --> D3["Bounce buffer через SWIOTLB:
лишнее копирование"] D2 -- да --> D4["Прямой доступ"] D3 --> D5["dma_sync_single_for_cpu/device
при частичном доступе"] D4 --> D5 D5 --> D6["dma_unmap_* — ОБЯЗАТЕЛЬНО,
иначе утечка 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) и одно прерывание.
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");
Три вещи, которые отсюда полезно унести даже если вы никогда не напишете драйвер:
read()имеет право вернуть меньше, чем запрошено. Не «может теоретически», а постоянно так делает — на сокетах, пайпах, терминалах, при получении сигнала. Кодn = read(fd, buf, 4096); assert(n == 4096);сломается в проде. Всегда цикл.copy_to_userможет заснуть (страница пользователя выгружена → page fault → чтение со свопа). Поэтому файловые операции нельзя вызывать из атомарного контекста, и поэтому у драйверов есть сложные правила блокировок.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() возвращается СРАЗУ"] E["Файловая система: ext4/XFS/btrfs
логический блок → физический LBA"] end subgraph BLK["Блочный слой"] F["struct bio: вектор сегментов
<страница, offset, len>"] G["blk-mq: software queue на CPU →
hardware queue на аппаратную очередь"] H["Планировщик I/O: none / mq-deadline / kyber / bfq
слияние соседних, сортировка, ограничение"] end subgraph DRV["Драйвер и железо"] I["Драйвер: bio → команда устройства,
dma_map_sg, запись дескриптора"] J["Doorbell (MMIO)"] K["Устройство: DMA-чтение данных, запись на флеш"] L["MSI-X: прерывание завершения"] M["Обработчик: blk_mq_end_request → разбудить ждущего"] end A --> B --> C C -- нет --> D --> E C -- "да: мимо кэша" --> E E --> F --> G --> H --> I --> J --> K --> L --> M D -. "позже: writeback,
fsync, dirty_ratio" .-> E
Здесь для прикладного программиста спрятаны самые дорогие практические выводы:
Ваш 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. Глубже про
инструментарий — Наблюдаемость и производительность.
Что из этого влияет на ваш код
Свернём в набор решений, которые реально принимает прикладной программист:
- Батчите. Каждое пересечение границы и каждое обращение к устройству дороги:
writev()вместо десятиwrite(), io_uring с пачкой SQE, буферизация в приложении. - Размер запроса важнее, чем кажется. Чтение по 512 байт и по 128 КиБ отличается по
пропускной способности в десятки раз. Ориентир —
optimal_io_sizeв sysfs. - Средняя латентность врёт. Гистограмма (
biolatency,hist()в bpftrace) показывает бимодальность «попал в page cache / пошёл на устройство» — среднее её маскирует. - Модель I/O выбирают под нагрузку, а не по моде. Сто соединений — потоки проще и быстрее в разработке; десять тысяч — epoll/kqueue; файловый I/O с глубокой очередью — io_uring.
fsync()дорог и обязателен. Не на каждую запись, но обязательно перед подтверждением клиенту, что данные сохранены. И с проверкой кода возврата.- Настройка ядра — часть приложения.
read_ahead_kb,scheduler,nr_requests,dirty_background_bytes,rx-usecs, IRQ affinity дают кратный выигрыш без единой строчки кода.
Источники
- Corbet, Rubini, Kroah-Hartman. Linux Device Drivers, 3rd ed. — устарела в деталях, но модель объясняет лучше всех: https://lwn.net/Kernel/LDD3/
- Linux Kernel Documentation: https://docs.kernel.org/core-api/dma-api.html, https://docs.kernel.org/driver-api/index.html, https://docs.kernel.org/block/blk-mq.html, https://docs.kernel.org/PCI/msi-howto.html
- Brendan Gregg. Systems Performance, 2nd ed. (Addison-Wesley, 2020), главы 9–10 — дисковый и сетевой I/O; инструменты: https://www.brendangregg.com/linuxperf.html
- Jens Axboe. Efficient IO with io_uring — документ автора: https://kernel.dk/io_uring.pdf
- Cloudflare Blog — практика про прерывания, RSS и тюнинг сетевого пути: https://blog.cloudflare.com/how-to-achieve-low-latency/
- NVMe Base Specification (очереди, дескрипторы): https://nvmexpress.org/specifications/
- Markettos et al. Thunderclap: Exploring Vulnerabilities in Operating System IOMMU Protection via DMA from Untrustworthy Peripherals, NDSS 2019 — https://www.cl.cam.ac.uk/research/security/thunderclap/thunderclap.pdf
- FreeBSD Architecture Handbook, главы про newbus и драйверы: https://docs.freebsd.org/en/books/arch-handbook/
- man целиком:
epoll(7),kqueue(2),io_uring(7),open(2)(проO_DIRECT),fsync(2),ethtool(8),blktrace(8)
Что дальше
Мы разобрали, как ядро говорит с железом. Осталась вторая граница — между ядром и вашим
процессом: как именно read() попадает в ядро, сколько это стоит, и какими способами
процессы общаются друг с другом.
Следующая статья: Системные вызовы и межпроцессное взаимодействие.