GraphLMS

ОС
Начать

ОС · Persistence · 14 мин

Устройства ввода-вывода: прерывания и DMA

Зачем вообще об этом думать

Backend-инженер редко пишет драйверы. Но почти весь его код в итоге упирается в ввод-вывод: запрос ушёл по сети, ответ из БД пришёл с диска, лог записался в файл. Понимать, как процессор общается с железом, нужно, чтобы осознанно отвечать на вопросы: «почему один поток, ждущий ответа от диска, не съедает 100% CPU?», «что такое блокирующий вызов на самом деле?», «куда уходит время в read()?».

В этой главе разберём три способа CPU работать с устройством — опрос (polling), прерывания (interrupts) и прямой доступ к памяти (DMA) — и пройдём путь системного вызова read() от приложения до железа и обратно.

Аналогия на весь текст. Представьте офис. Вы (CPU) сидите за столом, а курьер (устройство) везёт вам посылку (данные). Есть три стратегии узнать, что посылка приехала: каждые 10 секунд бегать к двери и проверять (polling); попросить курьера позвонить в дверь, когда приедет (interrupt); и нанять помощника, который сам примет посылку и положит на ваш стол, пока вы работаете (DMA).

Как устройство выглядит для процессора

Любое устройство — диск, сетевая карта, клавиатура — подключено к процессору через шину (bus). Шина — это, грубо говоря, набор проводов, по которым CPU и устройства обмениваются сигналами. Чем ближе устройство к CPU и чем оно быстрее, тем «дороже» и быстрее шина, на которой оно сидит.

        ┌──────┐        ┌────────┐
        │ CPU  │        │  RAM   │   (память)
        └──┬───┘        └───┬────┘
           │                │
   ════════╪════════════════╪═════════  системная шина (быстрая)

   ════════╪═══════════════════════════  шина устройств (помедленнее)
       │        │            │
   ┌───┴──┐ ┌───┴───┐    ┌────┴────┐
   │ Диск │ │ Сеть  │    │ Клавиа- │
   │      │ │ карта │    │  тура   │
   └──────┘ └───────┘    └─────────┘

Что нарисовано: CPU и RAM — на самой быстрой шине, потому что к ним обращаются чаще всего. Устройства висят на более медленных шинах ниже. Это не каприз: быстрая шина короткая и дорогая, на неё много устройств не повесишь.

Теперь главное: как именно CPU «разговаривает» с устройством? У каждого устройства есть набор регистров — маленьких ячеек, которые CPU умеет читать и писать. Обычно их три типа:

   Устройство (упрощённая модель)
   ┌─────────────────────────────────────┐
   │  СТАТУС   │ что сейчас делает устр-во │  ← CPU читает
   │  КОМАНДА  │ что устр-ву сделать       │  ← CPU пишет
   │  ДАННЫЕ   │ байты туда/обратно        │  ← CPU читает/пишет
   └─────────────────────────────────────┘

Что нарисовано: три регистра. Статус — CPU читает его, чтобы узнать «занято / готово / ошибка». Команда — CPU пишет туда «прочитай сектор», «отправь пакет». Данные — через него гоняются собственно байты.

Базовый протокол общения с устройством почти всегда такой:

1. while (СТАТУС == BUSY)   ; // ждём, пока устройство свободно
2. записать данные в регистр ДАННЫЕ
3. записать команду в регистр КОМАНДА (запускаем работу)
4. while (СТАТУС == BUSY)   ; // ждём, пока устройство закончит

Шаги 1 и 4 — это и есть опрос (polling): CPU в цикле читает регистр статуса и ждёт. Просто, но, как мы сейчас увидим, расточительно.

Polling: CPU крутится в ожидании

Опрос (polling) — CPU снова и снова читает регистр статуса: «готово? готово? готово?». Пока устройство возится, процессор не делает ничего полезного — он сжигает такты вхолостую (busy-waiting, активное ожидание).

ВРЕМЯ →   CPU делает что?
        ┌───┬───┬───┬───┬───┬───┬───┬───┐
   CPU  │ A │ p │ p │ p │ p │ p │ A │ A │
        └───┴───┴───┴───┴───┴───┴───┴───┘
   диск │   │█████ работает █████│       │
        └───────────────────────────────┘
 
   A = полезный процесс
   p = poll: CPU впустую опрашивает диск

Что нарисовано: процесс A запустил операцию на диске, а потом CPU четыре такта подряд (p p p p) просто опрашивал статус — это потерянное время. Только когда диск закончил, A продолжил.

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

Interrupts: устройство само позовёт

Решение — прерывания (interrupts). Вместо того чтобы опрашивать, CPU говорит устройству «начинай» и засыпает по этой задаче: ОС снимает процесс с CPU и ставит на ядро другой процесс. Когда устройство закончит, оно посылает CPU сигнал-прерывание — как звонок в дверь. CPU бросает текущее дело, прыгает в обработчик прерывания (interrupt handler) в ядре, тот будит ждавший процесс.

ВРЕМЯ →   CPU делает что?
        ┌───┬───┬───┬───┬───┬───┬───┬───┐
   CPU  │ A │ B │ B │ B │ B │ B │ A │ A │
        └───┴───┴───┴───┴───┴───┴───┴───┘

   диск │   │█████ работает █████││      │
        └───────────────────────┘└──────┘
                          прерывание ⚡ → разбудить A
 
   A ждёт диск → CPU отдан процессу B → диск кончил → ⚡ → вернулись к A

Что нарисовано: пока диск работает, CPU не простаивает — он крутит процесс B. Когда диск закончил, он шлёт прерывание (⚡), ОС будит A. Ни один такт не потрачен впустую — в этом вся прелесть.

Это та же механика, что в главе про прямое исполнение с ограничениями: прерывание — это управляемая передача руля от пользовательского кода ядру. CPU сохраняет контекст текущего процесса, переключается в режим ядра, выполняет обработчик, возвращается.

А прерывания — бесплатны? Нет

У прерывания есть цена: переключение контекста. Сохранить регистры, прыгнуть в ядро, отработать обработчик, вернуться обратно — это сотни-тысячи тактов. И тут неожиданный вывод:

Если устройство очень быстрое, прерывание может оказаться дороже, чем просто подождать в polling.

Представьте: устройство отвечает за время, сравнимое с одним переключением контекста. Тогда пока вы оформляете «засыпание» процесса и потом «пробуждение», вы бы уже успели получить результат опросом. Поэтому в реальности используют гибриды:

  • быстрое устройство (или ждём совсем чуть-чуть) → покрутиться в polling;
  • медленное / непредсказуемое → заснуть на прерывании;
  • interrupt coalescing (объединение прерываний): устройство не дёргает CPU на каждый байт, а копит несколько событий и шлёт одно прерывание на пачку — меньше переключений. Сетевые карты на высоких нагрузках именно так и делают; крайний случай — режим polling в драйвере (например NAPI в Linux), когда под потоком пакетов выгоднее опрашивать, чем тонуть в прерываниях.

Это прямая параллель с дилеммой спинлока против сна из мира конкурентности в Go: см. примитивы синхронизации — там тот же выбор «покрутиться или заснуть».

DMA: данные мимо процессора

Осталась ещё одна потеря. Допустим, надо перекинуть 1 МБ с диска в память. Даже с прерываниями кто-то должен физически переложить байты из регистра ДАННЫЕ устройства в RAM. Если этим занят CPU — он копирует слово за словом, опять сжигая такты на тупую перекачку (это называют programmed I/O, PIO).

   БЕЗ DMA (programmed I/O): CPU сам носит байты
   ┌──────┐  байт   ┌────────┐  байт   ┌──────┐
   │ Диск │ ──────► │  CPU   │ ──────► │ RAM  │
   └──────┘         └────────┘         └──────┘
                  CPU занят перекладыванием!

DMA (Direct Memory Access, прямой доступ к памяти) — это отдельный маленький контроллер на материнской плате, который умеет сам перекачивать данные между устройством и памятью, не трогая CPU. Процессор только говорит DMA: «вот адрес в памяти, вот сколько байт, вот откуда — поехали» и идёт заниматься другим. Когда перекачка закончена, DMA шлёт прерывание «готово».

   С DMA: помощник носит байты, CPU работает
   ┌──────┐         ┌──────────┐         ┌──────┐
   │ Диск │ ◄─────► │   DMA    │ ◄─────► │ RAM  │
   └──────┘         │контроллер│         └──────┘
                    └────┬─────┘
                         │ ⚡ "готово"
                    ┌────┴─────┐
                    │   CPU    │  ← всё это время свободен
                    └──────────┘

Что нарисовано: данные идут по маршруту Диск ↔ DMA ↔ RAM, минуя CPU. Процессор лишь дал старт и получит одно прерывание в конце. Сравните с предыдущей схемой, где CPU был узким горлышком.

На временной диаграмме разница такая:

   Programmed I/O (CPU копирует сам):
   CPU  │ A │ c c c c c │ B │     c = CPU копирует данные
 
   DMA:
   CPU  │ A │ B  B  B  B │ A │     CPU свободен на время копирования
   DMA  │   │▒▒ копирует ▒▒│⚡│

Что нарисовано: вверху CPU сам тратит такты c на копирование; внизу копирует DMA (), а CPU крутит полезный B. Именно благодаря DMA один Go-горутина, ждущая ответа от БД, не нагружает ядро — копированием занято железо, а горутина просто снята с потока планировщиком (см. планировщик Go).

Путь системного вызова read() до железа и обратно

Соберём всё вместе. Что происходит, когда ваше приложение вызывает read() с диска:

 ПРИЛОЖЕНИЕ (user space)
   │  read(fd, buf, n)               ← обычный вызов функции
   ▼  ───────── trap (системный вызов) ─────────
 ЯДРО / ФАЙЛОВАЯ СИСТЕМА
   │  найти, где на диске лежат эти байты
   ▼  передать запрос драйверу
 ДРАЙВЕР УСТРОЙСТВА
   │  записать команду в регистры устройства
   │  настроить DMA (адрес buf, длина n)
   ▼  процесс УСНУЛ (блокирован), CPU отдан другим
 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
 ЖЕЛЕЗО
   │  диск читает сектор
   │  DMA перекачивает данные → в память (buf)
   ▼  устройство шлёт ⚡ ПРЕРЫВАНИЕ
 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
 ЯДРО (обработчик прерывания)
   │  «данные на месте», пометить процесс готовым
   ▼  ───────── возврат из ядра ─────────
 ПРИЛОЖЕНИЕ
   │  read() вернул n байт, продолжаем

Что нарисовано: запрос спускается по слоям сверху вниз (приложение → ядро → драйвер → железо), процесс засыпает, железо с помощью DMA делает работу и будит процесс прерසванием снизу вверх. Ключевой момент: между «уснул» и «прерывание» ваш поток не занимает CPU — поэтому блокирующий read() не означает «сжигаем процессор», он означает «процесс снят с ядра до готовности данных». Подробнее про устройство самого диска и почему он такой медленный — в главе жёсткий диск (HDD).

Сравнительная таблица

Свойство Polling (опрос) Interrupts (прерывания) DMA
Кто узнаёт о готовности CPU сам, в цикле устройство дёргает CPU DMA шлёт прерывание в конце
Кто копирует данные CPU CPU отдельный контроллер
Нагрузка на CPU при ожидании 100% (busy-wait) ~0% (CPU занят другим) ~0%
Накладные расходы нет переключений переключение контекста настройка + 1 прерывание
Когда выгодно очень быстрые устройства, короткое ожидание медленные/редкие события большие объёмы данных
Минус жжёт CPU впустую дорого при частых событиях сложнее, нужен контроллер

Что показано: ни один способ не «лучший» вообще. Реальные системы комбинируют: например, «опросить чуть-чуть → если не готово, заснуть на прерывании → данные притащит DMA».

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

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

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

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

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

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

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

Что спрашивают на собесе

  • Чем polling отличается от прерываний и когда polling выгоднее? Polling жжёт CPU в цикле ожидания; прерывания освобождают CPU, но стоят переключения контекста. Для очень быстрых устройств polling может выиграть, потому что ожидание короче цены прерывания.
  • Что такое DMA и какую проблему он решает? Отдельный контроллер перекачивает данные устройство↔память без участия CPU; решает проблему того, что иначе процессор тратил бы такты на тупое копирование байтов (programmed I/O).
  • Почему блокирующий read() не нагружает CPU на 100%? Потому что процесс снимается с ядра (засыпает) до прерывания о готовности; CPU всё это время крутит другие процессы. Блокировка ≠ busy-wait.
  • Что такое interrupt coalescing и зачем оно сетевым картам? Объединение многих событий в одно прерывание, чтобы под высокой нагрузкой не утонуть в переключениях контекста; крайний случай — переход драйвера в режим polling (NAPI).
  • Опишите путь read() от приложения до железа. trap в ядро → ФС находит блоки → драйвер пишет команду в регистры и настраивает DMA → процесс засыпает → железо читает, DMA копирует в память → прерывание → ядро будит процесс → возврат.
  • Какие три типа регистров у устройства? Статус (читать состояние), команда (отдать приказ), данные (гонять байты).
Событийная модель и epollЖёсткие диски и планирование запросов