ОС · 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».
В чём главный недостаток опроса (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 копирует в память → прерывание → ядро будит процесс → возврат. - Какие три типа регистров у устройства? Статус (читать состояние), команда (отдать приказ), данные (гонять байты).