Persistence · 13 мин
Контент v0.12.0Устройства ввода-вывода: прерывания и DMA
Зачем бэкендеру устройства
Драйверы бэкендер не пишет, но почти всё время его сервиса уходит на ввод-вывод:
запрос пришёл по сети, данные прочитаны с диска, ответ ушёл обратно. Эта глава
отвечает на вопросы, которые иначе остаются магией. Почему горутина, ждущая
ответа от базы, не ест CPU? Куда уходит время внутри read()? Что значит
колонка si в top и почему одно ядро процессора сервера бывает загружено сетью на 100%?
Начинаем блок про постоянное хранение данных, поэтому сначала разберёмся, как процессор вообще разговаривает с железом, а в следующих главах — что это за железо: жёсткие диски и SSD.
Как устройство выглядит для процессора
Устройства подключены к процессору через шину — набор линий связи с протоколом обмена. В современном сервере это в основном PCI Express: к ней подключены NVMe-диски, сетевые карты, GPU. Память подключена к процессору отдельно и быстрее.
┌───────────┐ ┌──────────┐
│ CPU │═════│ RAM │ контроллер памяти в процессоре
└─────┬─────┘ └──────────┘
│ PCIe
┌────────┼──────────┬────────────┐
┌──┴───┐ ┌──┴─────┐ ┌──┴───┐ ┌────┴────┐
│ NVMe │ │ сетевая│ │ GPU │ │ чипсет: │
│ диск │ │ карта │ │ │ │ USB,SATA│
└──────┘ └────────┘ └──────┘ └─────────┘Для процессора у каждого устройства есть набор регистров: небольших ячеек, которые можно читать и писать. В простейшей учебной модели их три вида:
- статус — что сейчас с устройством: занято, готово, ошибка;
- команда — что сделать: «прочитай блок 12345», «отправь пакет»;
- данные — через них передаются сами байты.
Как процессор до них добирается? Есть два способа.
- Порты ввода-вывода (port I/O): отдельные инструкции
in/outна x86 и отдельное адресное пространство портов. Это наследие старых ПК, сегодня так общаются с немногими legacy-устройствами. - Отображение в память (memory-mapped I/O, MMIO): регистры устройства
получают физические адреса, как будто это кусок RAM. Процессор пишет в них
обычной инструкцией
mov, а контроллер шины отправляет эту запись не в память, а в устройство. Так работает всё современное: NVMe, сетевые карты, GPU. Доступ к таким адресам разрешён только ядру (и драйверам).
Базовый протокол общения с устройством в учебной модели выглядит так:
1. while (статус == ЗАНЯТО) { } // ждём, пока устройство свободно
2. записать данные в регистр данных
3. записать команду в регистр команд // устройство начинает работу
4. while (статус == ЗАНЯТО) { } // ждём, пока закончитШаги 1 и 4 — это опрос (polling): процессор в цикле читает статус. Посмотрим, чем это плохо.
Polling: процессор ждёт в цикле
Пока устройство работает, процессор, который его опрашивает, ничего полезного не делает.
время →
CPU │ A │ p │ p │ p │ p │ p │ A │ A │ p — опрос статуса впустую
диск │ │███████ работает ███│ │ │Для медленного устройства это катастрофа. Чтение с жёсткого диска занимает порядка 10 мс, а процессор на 3 ГГц за это время выполнил бы 30 миллионов тактов чужой работы.
Прерывания: устройство само позовёт
Вместо опроса процессор даёт устройству команду и уходит заниматься другим. ОС переводит процесс, который ждёт данных, в состояние «заблокирован» (см. состояния процесса) и запускает другой. Когда устройство закончит, оно посылает процессору прерывание (interrupt) — электрический (на PCIe — специальное сообщение, MSI-X) сигнал «у меня событие». Процессор приостанавливает текущий код, переходит в режим ядра ОС и выполняет обработчик прерывания (interrupt handler), который смотрит, что случилось, и будит ждавший процесс.
время →
CPU │ A │ B │ B │ B │ B │ B │ A │ A │
диск │ │███████ работает ███│
↑ прерывание: обработчик будит AПока диск работает, процессор исполняет процесс B. Это тот же механизм, что таймерное прерывание из главы про ограниченное прямое исполнение: управление передаётся ядру по сигналу железа.
Прерывания не бесплатны
Принять прерывание — значит прервать текущий код, сохранить его регистры, войти в ядро, выполнить обработчик и вернуться. Это порядка микросекунды. Если обработчик разбудил процесс, планировщик может ещё и переключить контекст. Для медленного диска это копейки, но в двух случаях цена становится заметной.
Очень быстрое устройство. Если ответ придёт через несколько микросекунд, ушедший спать процесс проснётся позже, чем если бы просто подождал. Поэтому Linux умеет для быстрых NVMe-дисков опрашивать очередь завершений вместо ожидания прерывания (режим polled I/O). Та же дилемма «крутиться или спать», что у спинлока против сна.
Очень частые события. Сетевая карта под нагрузкой получает сотни тысяч пакетов в секунду. Прерывание на каждый пакет — и процессор только и делает, что входит в обработчики. Два лекарства:
- объединение прерываний (interrupt coalescing): карта копит несколько пакетов или ждёт несколько микросекунд и шлёт одно прерывание на пачку;
- NAPI в Linux: получив прерывание, драйвер отключает дальнейшие прерывания и забирает пакеты опросом, пачками, пока очередь не опустеет. Только потом включает прерывания обратно.
Обработку пакетов ядро делает в softirq (отложенная часть обработки
прерывания). Время, потраченное на неё, в top показывает колонка si. Если
у одного ядра процессора si под 100%, а остальные свободны — вероятно, все прерывания
сетевой карты приходят на одно ядро процессора. Распределение видно в /proc/interrupts,
его меняют настройками RSS (несколько очередей на карте) и irqbalance.
DMA: данные идут мимо процессора
Осталась ещё одна потеря. Допустим, с диска нужно прочитать 1 МБ. Даже с прерываниями кто-то должен перенести байты из устройства в RAM. Если это делает процессор, читая регистр данных слово за словом, — это программный ввод-вывод (programmed I/O, PIO), и процессор снова занят тупой работой.
DMA (Direct Memory Access, прямой доступ к памяти) — устройство само пишет данные в RAM и само читает их оттуда, без участия процессора. Процессору нужно только сказать, где в памяти лежат буферы и сколько байт перенести.
PIO: процессор носит байты DMA: устройство пишет в RAM само
диск ──► CPU ──► RAM диск ═══════════════► RAM
(CPU занят копированием) │
└─ прерывание «готово» ─► CPUВ учебниках DMA часто рисуют как отдельный контроллер на материнской плате. Так было в старых ПК. Сегодня почти каждое быстрое устройство само умеет обращаться к памяти по шине (это называется bus mastering). NVMe-диск и сетевая карта работают через очереди в памяти: драйвер кладёт в кольцевой буфер в RAM описание команды («прочитай блоки с 1000 по 1007 в физические страницы X и Y») и записывает номер последней команды в регистр-«звонок» (doorbell) устройства через MMIO. Устройство само забирает команду из памяти, само пишет данные в указанные страницы, кладёт запись о завершении в очередь завершений и шлёт прерывание. Между устройством и памятью обычно стоит IOMMU — аналог MMU для устройств: он ограничивает, в какие страницы устройство имеет право писать (важно для безопасности и для виртуальных машин).
Сравним загрузку процессора:
PIO: CPU │ A │ c c c c c │ B │ c — процессор копирует данные
DMA: CPU │ A │ B B B B B │ A │ процессор свободен
диск│ │▒ DMA пишет в RAM ▒│Путь read() от программы до диска и обратно
Соберём всё вместе. Программа вызывает read(fd, buf, 4096) для файла на
NVMe-диске. Сначала вспомним page cache из главы
о памяти в проде: ядро держит недавно прочитанные куски
файлов в свободной RAM.
ПРОГРАММА
read(fd, buf, 4096)
──── системный вызов, переход в ядро ────
ЯДРО: виртуальная ФС
есть ли нужная страница файла в page cache?
├─ да (cache hit): скопировать 4 КБ в buf, вернуться. ~1 мкс
└─ нет (cache miss):
ЯДРО: файловая система
по inode найти, в каком блоке диска лежат эти байты
выделить страницу в page cache под данные
ЯДРО: блочный слой и драйвер NVMe
сформировать команду «прочитать блок N в физическую страницу P»
положить в очередь отправки, позвонить в doorbell
процесс заблокирован, CPU отдан другим
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
ЖЕЛЕЗО
диск читает данные, DMA пишет их в страницу P ~50–100 мкс (NVMe)
запись в очередь завершений, прерывание
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
ЯДРО: обработчик прерывания
отметить страницу как прочитанную, разбудить процесс
ЯДРО (уже в контексте процесса)
скопировать 4 КБ из страницы P в buf
──── возврат из системного вызова ────
ПРОГРАММА
read вернул 4096Три вещи, которые стоит из этого вынести.
- Блокирующий
read()не ест CPU. Между отправкой команды и прерыванием процесс снят с процессора. «Заблокирован» значит «спит», а не «крутится». - Данные копируются дважды: устройство → page cache (DMA), page cache →
ваш буфер (процессор). Зато следующий
readтого же места не тронет диск. Базы данных, которые держат свой кэш, иногда открывают файлы с флагомO_DIRECT: тогда DMA пишет прямо в буфер программы, а page cache не используется. Об этом подробнее в главе про fsync. - Ядро читает с запасом. Заметив последовательное чтение, оно заранее
запрашивает следующие блоки (readahead), чтобы следующий
readпопал в кэш.
А что с горутинами
Когда горутина ждёт ответа от базы по сети, путь похож, только устройство — сетевая карта. Карта DMA-ет пакеты в кольцевые буферы в RAM, прерывание и NAPI передают их сетевому стеку ядра, тот раскладывает данные по сокетам, и сокет становится «готов к чтению». Дальше вступает netpoller: горутина, ждавшая этот сокет, снова становится готовой к запуску. Ни один поток ОС всё это время не спал ради этой горутины и не крутился в цикле.
С файлами иначе: os.File.Read — блокирующий системный вызов, и поток ОС спит
внутри него, пока не придёт прерывание от диска. Рантайм Go на это время отдаёт
логический процессор P другому потоку, чтобы остальные горутины не стояли.
Сравнение
| Polling | Прерывания | DMA | |
|---|---|---|---|
| Кто узнаёт о готовности | процессор в цикле | устройство сигналит | устройство сигналит в конце |
| Кто переносит данные | процессор | процессор | устройство |
| CPU во время ожидания | занят на 100% | свободен | свободен |
| Накладные расходы | нет входов в ядро | вход в обработчик, пробуждение | настройка команды, одно прерывание |
| Когда выгодно | ответ через микросекунды | редкие или медленные события | большие объёмы |
Реальные системы их комбинируют: NVMe-диск переносит данные через DMA, о завершении сообщает прерыванием, а под высокой нагрузкой драйвер может перейти на опрос очереди завершений.
В чём главный недостаток опроса (polling) для медленного устройства?
Что делает DMA-контроллер?
Почему для очень быстрого устройства прерывание может быть невыгодным?
Какие из перечисленных — типы регистров устройства?
Почему блокирующий read() обычно не держит CPU на 100%?
Сколько прерываний в идеале шлёт DMA на одну крупную передачу данных?
Частые ошибки
- Считать, что блокирующий
read«грузит процессор». Процесс спит до прерывания. - Забывать про page cache: «каждый
readходит на диск». - Думать, что
O_DIRECTделает запись долговечной. Он только обходит page cache. - Объяснять высокий
siвtopпроблемами с диском: это обработка сетевых прерываний, часто на одном ядре процессора. - Путать прерывание с переключением контекста: прерывание — вход в ядро ОС, переключение случается, только если обработчик кого-то разбудил.
Проверьте себя
- Почему polling выгоден для очень быстрых устройств и невыгоден для HDD?
- Чем MMIO отличается от портов ввода-вывода?
- Сколько раз копируются данные при обычном
readфайла с промахом page cache? - Сервис читает один и тот же конфиг-файл на 4 КБ на каждый запрос, 10 000 раз в секунду. Сколько раз в секунду ядро ОС будет обращаться к диску и сколько раз копировать данные? Что изменится после перезагрузки сервера?
Показать ответы
Ответы. (1) Ответ быстрого устройства приходит раньше, чем окупится вход в обработчик прерывания и пробуждение; HDD отвечает миллисекунды, за которые процессор сделал бы миллионы тактов полезной работы. (2) При MMIO регистры устройства отображены на физические адреса и доступны обычными инструкциями чтения и записи; порты — отдельное адресное пространство и отдельные инструкции in/out. (3) Дважды: устройство → page cache через DMA, page cache → буфер программы процессором. (4) После первого чтения обращений к диску нет, файл в page cache; копирований — 10 000 в секунду. После перезагрузки кэш пуст: первое чтение снова пойдёт на диск, дальше всё как прежде.
Что спрашивают на собесе
- Polling или прерывания? Polling жжёт CPU, но не платит за вход в обработчик. Прерывания освобождают CPU, но каждое стоит порядка микросекунды. Для очень быстрых устройств и очень частых событий выгоднее опрос (NVMe polled I/O, NAPI в сети).
- Что такое DMA? Устройство само переносит данные в RAM и из неё; процессор только описывает буферы и получает прерывание в конце.
- Как процессор пишет в регистры устройства? В основном через MMIO: регистры отображены на физические адреса, запись — обычная инструкция.
- Почему блокирующий
read()не грузит CPU? Процесс спит до прерывания о готовности данных; процессор в это время исполняет другие задачи. - Опишите путь
read(). Системный вызов → проверка page cache → при промахе ФС находит блок → драйвер ставит команду в очередь устройства → процесс спит → устройство DMA-ет данные в page cache → прерывание → процесс просыпается → копирование в буфер → возврат. - Что такое interrupt coalescing и NAPI? Способы не утонуть в прерываниях под сетевой нагрузкой: объединять события и переходить на опрос пачками.
- Какие три вида регистров у устройства в учебной модели? Статус, команда, данные.