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, о завершении сообщает прерыванием, а под высокой нагрузкой драйвер может перейти на опрос очереди завершений.

Проверь себя· Ввод-вывод: прерывания и DMA

В чём главный недостаток опроса (polling) для медленного устройства?

Что делает DMA-контроллер?

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

Какие из перечисленных — типы регистров устройства?

Почему блокирующий read() обычно не держит CPU на 100%?

Сколько прерываний в идеале шлёт DMA на одну крупную передачу данных?

Частые ошибки

  • Считать, что блокирующий read «грузит процессор». Процесс спит до прерывания.
  • Забывать про page cache: «каждый read ходит на диск».
  • Думать, что O_DIRECT делает запись долговечной. Он только обходит page cache.
  • Объяснять высокий si в top проблемами с диском: это обработка сетевых прерываний, часто на одном ядре процессора.
  • Путать прерывание с переключением контекста: прерывание — вход в ядро ОС, переключение случается, только если обработчик кого-то разбудил.

Проверьте себя

  1. Почему polling выгоден для очень быстрых устройств и невыгоден для HDD?
  2. Чем MMIO отличается от портов ввода-вывода?
  3. Сколько раз копируются данные при обычном read файла с промахом page cache?
  4. Сервис читает один и тот же конфиг-файл на 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? Способы не утонуть в прерываниях под сетевой нагрузкой: объединять события и переходить на опрос пачками.
  • Какие три вида регистров у устройства в учебной модели? Статус, команда, данные.

Продолжить