Перейти до вмісту

Підсистема вводу-виводу

Процесор виконує мільярди операцій за секунду. Звернення до SSD займає десятки мікросекунд, до мережі мілісекунди, а до людини за клавіатурою цілі секунди. Між швидкістю процесора і швидкістю всього іншого лежать сім порядків, і майже вся підсистема вводу-виводу існує, щоб цю прірву приховати.

Наслідки ви бачите щодня. Чому копіювання файлу не завантажує процесор на сто відсотків? Чому веб-сервер із потоком на з’єднання лягає на десяти тисячах клієнтів, а на epoll не лягає? Чому strace показує, що програма дев’яносто відсотків часу сидить у read? Усе це одна тема.

Передумови. Переривання і DMA (модуль 2), межа user/kernel і вартість системного виклику (модуль 3), стан «заблокований» у процесів.

Пристрої з точки зору ядра

Section titled “Пристрої з точки зору ядра”

Пристроїв існують тисячі, але знати про кожен ядру не потрібно. Драйвер зводить будь-який пристрій до однієї з двох моделей.

Блоковий пристрій оперує блоками фіксованого розміру з довільним доступом: можна прочитати блок 5000, а потім блок 12. Такими є диски, SSD і USB-накопичувачі, і саме над ними будуються файлові системи.

Символьний пристрій оперує потоком байтів без довільного доступу. Це клавіатура, послідовний порт, /dev/random, звукова карта. Перемотати назад тут здебільшого не вийде.

Поділ цей суто практичний: блокові пристрої отримують кеш і планування запитів, а символьні ні, бо ні кешувати, ні переупорядковувати потік сенсу немає.

Ядру драйвер надає однаковий набір операцій: відкрити, прочитати, записати, керувати. Тому read() працює однаково і для файлу на NVMe, і для сокета, і для /dev/null — уся різниця захована в драйвері, а не в системному виклику.

Найдорожче питання всієї підсистеми вводу-виводу звучить так: хто саме перекладає байти з контролера пристрою в оперативну пам’ять.

Порівняння опитування, переривань і DMA за часткою процесорного часу опитуванняpollingперериванняinterruptDMAпо одному обробнику на кожен блокконтролер переносить дані в пам'ять самостійнопочаток операціїдані в пам'ятіпроцесор зайнятий вводом-виводомпроцесор вільний для інших процесів
Три покоління відповіді на одне питання. Зафарбоване — процесорний час, витрачений на обслуговування передавання, а не на корисну роботу.

За опитування (polling) процесор у циклі перевіряє регістр стану: готово? готово? готово? І дані переносить теж сам. Реалізується просто й марнує ресурси катастрофічно, бо весь час передавання процесор зайнятий самим лише очікуванням.

За перериваннями процесор запускає операцію і йде виконувати інші процеси. Коли пристрій готовий, він смикає лінію переривання, процесор зупиняє поточну роботу й виконує обробник. Процесорний час витрачається тільки на обробники, щоправда обробник спрацьовує на кожен блок даних.

DMA доручає перенесення контролеру прямого доступу до пам’яті, який робить усе самостійно, без участі процесора. Ядро повідомляє йому адресу буфера й розмір, а натомість отримує одне переривання, коли все скінчено. Саме тому копіювання гігабайтного файлу не завантажує ядро процесора: дані йдуть повз нього.

  1. Процес викликає read(fd, buf, n). Відбувається перехід у режим ядра.

  2. Ядро за номером fd знаходить у таблиці відкритих файлів об’єкт файлу, а за ним — драйвер, якому належить цей об’єкт.

  3. Якщо дані вже є в кеші сторінок, ядро копіює їх у буфер процесу і повертає керування. Пристрій не турбували взагалі.

  4. Якщо даних немає, драйвер ставить запит у чергу до пристрою, а процес переводиться у стан «заблокований» і знімається з процесора.

  5. Контролер виконує операцію і через DMA кладе дані в пам’ять ядра. Приходить переривання.

  6. Обробник переривання позначає запит виконаним і переводить процес у стан «готовий». Планувальник поверне його на процесор, коли дійде черга.

  7. Ядро копіює дані з кеша в буфер процесу, read повертає кількість байтів.

З усього списку варто запам’ятати два пункти. Третій крок пояснює, чому повторне читання того самого файлу миттєве: працює кеш сторінок, а диск навіть не турбують. Четвертий пояснює, чому програма, яка начебто нічого не робить, має в ps стан S: вона заблокована в read і процесора не витрачає.

Буферизація і кешування

Section titled “Буферизація і кешування”

Дві різні речі, які регулярно плутають.

Буферизація узгоджує швидкості й розміри. Пристрій віддає дані блоками по 512 байтів, програма просить по три — між ними потрібен буфер. До того ж віддати процесу дані частинами не можна, поки операція не завершилась.

Кешування зберігає копію вже прочитаного на випадок, що його прочитають ще раз. Уся вільна оперативна пам’ять у Linux зайнята кешем сторінок, і це нормальний стан: free показує її у стовпці buff/cache, а звільняється вона миттєво, щойно знадобиться процесам.

Звідси й наслідок, який лякає новачків: «у мене вільно 300 МБ із 16 ГБ». Дивитися треба на стовпець available, а не на free. Пам’ять під кешем не є витраченою.

Блокуючий і неблокуючий ввід-вивід

Section titled “Блокуючий і неблокуючий ввід-вивід”

За замовчуванням дескриптор блокуючий, тобто read не поверне керування, поки не буде даних. Для одного файлу це зручно, а от для сервера на десять тисяч з’єднань уже ні: щоб чекати на десяти тисячах дескрипторів, довелося б завести десять тисяч потоків, кожен зі своїм стеком і своєю часткою перемикань контексту.

Виходом стало питати в ядра одразу про всі дескриптори: які з них зараз готові?

Механізм Складність на виклик Проблема
select O(n), обмеження на 1024 дескриптори список передається щоразу заново
poll O(n), без жорсткого обмеження те саме: ядро щоразу переглядає весь список
epoll O(готових) набір дескрипторів живе в ядрі, повертаються тільки готові
io_uring O(готових), без системного виклику на операцію найскладніший API з трьох

Основою всіх сучасних серверів epoll став саме тому, що вартість виклику залежить від кількості готових дескрипторів, а не від загальної. Дев’ять тисяч дев’ятсот сплячих з’єднань не коштують нічого.

io_uring іде ще далі й прибирає сам системний виклик. Програма й ядро обмінюються двома кільцевими чергами в спільній пам’яті: заявки лягають в одну, результати забираються з іншої. За великого навантаження це знімає мільйони переходів у режим ядра щосекунди.

Terminal window
lsblk -o NAME,TYPE,SIZE,ROTA,SCHED,MODEL

Блокові пристрої системи. ROTA=1 — обертовий диск, 0 — SSD. SCHED показує планувальник запитів для цього пристрою (модуль 14).

Terminal window
ls -l /dev/null /dev/sda /dev/tty 2>/dev/null

Перший символ типу — c для символьного пристрою, b для блокового. Замість розміру у файлу пристрою стоять два числа: старший і молодший номери, за якими ядро знаходить драйвер.

Terminal window
grep -E 'NVME|nvme|eth|xhci' /proc/interrupts | head

Лічильники переривань за джерелом і за ядром процесора. Стовпці — ядра; якщо всі переривання мережевої карти падають на одне ядро, воно стане вузьким місцем раніше за решту.

Terminal window
free -h

Стовпець buff/cache — кеш сторінок. available — скільки реально можуть отримати процеси, з урахуванням того, що кеш звільниться.

Terminal window
strace -T -e trace=openat,read,epoll_wait -- curl -s https://example.com -o /dev/null

-T друкує тривалість кожного виклику. Видно, що весь час програми зосереджений у кількох викликах очікування, а не в обчисленнях.

Terminal window
cat /proc/$$/io

Скільки байтів цей процес прочитав і записав — окремо всього (rchar) і окремо з реального пристрою (read_bytes). Різниця між ними і є ефект кеша.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Програма гальмує, бо не вистачає процесора». Коли процес у стані S або D, процесор тут ні до чого. top покаже низьке навантаження і високий %wa, тобто час, який процесор простояв, чекаючи на ввід-вивід.

«Кеш з’їв усю пам’ять». Кеш сторінок займає всю вільну пам’ять за задумом і звільняється на першу ж вимогу. Порожня пам’ять — це просто змарнована пам’ять.

«epoll робить ввід-вивід асинхронним». Не робить. Він лише каже, на яких дескрипторах читання не заблокує, а саме читання далі виконує програма, і воно синхронне. Асинхронний інтерфейс — це io_uring.

«Більше потоків, швидший ввід-вивід». Диск від потоків швидшим не стає. Понад певну межу вони додають хіба що перемикань контексту й конкуренції за блокування. Щоб чекати, потрібен не потік, а дескриптор у наборі epoll.

«DMA прискорює передавання». Диск від DMA швидшим не стає. Він звільняє процесор від перекладання байтів, щоб той міг займатися чимось корисним, поки передавання триває.

Перевір себе

1. Копіювання великого файлу триває хвилину, а процесор при цьому завантажений на кілька відсотків. Чому?
2. Чому epoll масштабується на десятки тисяч зʼєднань, а poll — ні?
3. free показує 300 МБ вільних із 16 ГБ, решта — у buff/cache. Що це означає?
4. Чим неблокуючий ввід-вивід відрізняється від асинхронного?
5. Чому на дуже швидких NVMe-накопичувачах опитування може бути вигіднішим за переривання?
6. Процес у ps має стан D і не реагує на kill -9. Куди дивитись?

A7 — HTTP-сервер у трьох редакціях. Спершу потік на з’єднання, потім epoll, потім io_uring. Замір під однаковим навантаженням показує, де саме ламається кожна модель — це найпереконливіша частина всього курсу.

B6 — профілювання. strace -T, perf, bpftrace: знайти, чому програма повільна, і відрізнити брак процесора від очікування вводу-виводу.

  • OSTEP, I/O Devices
  • Silberschatz, Operating System Concepts, розділ 12
  • man 7 epoll, man 2 io_uring_setup, man 5 proc
  • Efficient IO with io_uring — пояснення від автора
  • The C10K problem — історичний текст, з якого почалася епоха epoll