Підсистема вводу-виводу
Навіщо це
Section titled “Навіщо це”Процесор виконує мільярди операцій за секунду. Звернення до SSD займає десятки мікросекунд, до мережі мілісекунди, а до людини за клавіатурою цілі секунди. Між швидкістю процесора і швидкістю всього іншого лежать сім порядків, і майже вся підсистема вводу-виводу існує, щоб цю прірву приховати.
Наслідки ви бачите щодня. Чому копіювання файлу не завантажує процесор
на сто відсотків? Чому веб-сервер із потоком на з’єднання лягає на десяти
тисячах клієнтів, а на epoll не лягає? Чому strace показує, що програма
дев’яносто відсотків часу сидить у read? Усе це одна тема.
Передумови. Переривання і DMA (модуль 2), межа user/kernel і вартість системного виклику (модуль 3), стан «заблокований» у процесів.
Пристрої з точки зору ядра
Section titled “Пристрої з точки зору ядра”Пристроїв існують тисячі, але знати про кожен ядру не потрібно. Драйвер зводить будь-який пристрій до однієї з двох моделей.
Блоковий пристрій оперує блоками фіксованого розміру з довільним доступом: можна прочитати блок 5000, а потім блок 12. Такими є диски, SSD і USB-накопичувачі, і саме над ними будуються файлові системи.
Символьний пристрій оперує потоком байтів без довільного доступу.
Це клавіатура, послідовний порт, /dev/random, звукова карта. Перемотати
назад тут здебільшого не вийде.
Поділ цей суто практичний: блокові пристрої отримують кеш і планування запитів, а символьні ні, бо ні кешувати, ні переупорядковувати потік сенсу немає.
Ядру драйвер надає однаковий набір операцій: відкрити, прочитати, записати,
керувати. Тому read() працює однаково і для файлу на NVMe, і для сокета,
і для /dev/null — уся різниця захована в драйвері, а не в системному виклику.
Хто переносить дані
Section titled “Хто переносить дані”Найдорожче питання всієї підсистеми вводу-виводу звучить так: хто саме перекладає байти з контролера пристрою в оперативну пам’ять.
За опитування (polling) процесор у циклі перевіряє регістр стану: готово? готово? готово? І дані переносить теж сам. Реалізується просто й марнує ресурси катастрофічно, бо весь час передавання процесор зайнятий самим лише очікуванням.
За перериваннями процесор запускає операцію і йде виконувати інші процеси. Коли пристрій готовий, він смикає лінію переривання, процесор зупиняє поточну роботу й виконує обробник. Процесорний час витрачається тільки на обробники, щоправда обробник спрацьовує на кожен блок даних.
DMA доручає перенесення контролеру прямого доступу до пам’яті, який робить усе самостійно, без участі процесора. Ядро повідомляє йому адресу буфера й розмір, а натомість отримує одне переривання, коли все скінчено. Саме тому копіювання гігабайтного файлу не завантажує ядро процесора: дані йдуть повз нього.
Шлях одного read()
Section titled “Шлях одного read()”-
Процес викликає
read(fd, buf, n). Відбувається перехід у режим ядра. -
Ядро за номером
fdзнаходить у таблиці відкритих файлів об’єкт файлу, а за ним — драйвер, якому належить цей об’єкт. -
Якщо дані вже є в кеші сторінок, ядро копіює їх у буфер процесу і повертає керування. Пристрій не турбували взагалі.
-
Якщо даних немає, драйвер ставить запит у чергу до пристрою, а процес переводиться у стан «заблокований» і знімається з процесора.
-
Контролер виконує операцію і через DMA кладе дані в пам’ять ядра. Приходить переривання.
-
Обробник переривання позначає запит виконаним і переводить процес у стан «готовий». Планувальник поверне його на процесор, коли дійде черга.
-
Ядро копіює дані з кеша в буфер процесу,
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 іде ще далі й прибирає сам системний виклик. Програма й ядро
обмінюються двома кільцевими чергами в спільній пам’яті: заявки лягають
в одну, результати забираються з іншої. За великого навантаження це знімає
мільйони переходів у режим ядра щосекунди.
Як це насправді в Linux
Section titled “Як це насправді в Linux”lsblk -o NAME,TYPE,SIZE,ROTA,SCHED,MODELБлокові пристрої системи. ROTA=1 — обертовий диск, 0 — SSD.
SCHED показує планувальник запитів для цього пристрою (модуль 14).
ls -l /dev/null /dev/sda /dev/tty 2>/dev/nullПерший символ типу — c для символьного пристрою, b для блокового.
Замість розміру у файлу пристрою стоять два числа: старший і молодший номери,
за якими ядро знаходить драйвер.
grep -E 'NVME|nvme|eth|xhci' /proc/interrupts | headЛічильники переривань за джерелом і за ядром процесора. Стовпці — ядра; якщо всі переривання мережевої карти падають на одне ядро, воно стане вузьким місцем раніше за решту.
free -hСтовпець buff/cache — кеш сторінок. available — скільки реально можуть
отримати процеси, з урахуванням того, що кеш звільниться.
strace -T -e trace=openat,read,epoll_wait -- curl -s https://example.com -o /dev/null-T друкує тривалість кожного виклику. Видно, що весь час програми
зосереджений у кількох викликах очікування, а не в обчисленнях.
cat /proc/$$/ioСкільки байтів цей процес прочитав і записав — окремо всього
(rchar) і окремо з реального пристрою (read_bytes). Різниця між ними
і є ефект кеша.
Типові помилки розуміння
Section titled “Типові помилки розуміння”«Програма гальмує, бо не вистачає процесора». Коли процес у стані S
або D, процесор тут ні до чого. top покаже низьке навантаження і високий
%wa, тобто час, який процесор простояв, чекаючи на ввід-вивід.
«Кеш з’їв усю пам’ять». Кеш сторінок займає всю вільну пам’ять за задумом і звільняється на першу ж вимогу. Порожня пам’ять — це просто змарнована пам’ять.
«epoll робить ввід-вивід асинхронним». Не робить. Він лише каже,
на яких дескрипторах читання не заблокує, а саме читання далі виконує
програма, і воно синхронне. Асинхронний інтерфейс — це io_uring.
«Більше потоків, швидший ввід-вивід». Диск від потоків швидшим
не стає. Понад певну межу вони додають хіба що перемикань контексту
й конкуренції за блокування. Щоб чекати, потрібен не потік, а дескриптор
у наборі epoll.
«DMA прискорює передавання». Диск від DMA швидшим не стає. Він звільняє процесор від перекладання байтів, щоб той міг займатися чимось корисним, поки передавання триває.
Перевір себе
Лабораторна
Section titled “Лабораторна”A7 — HTTP-сервер у трьох редакціях. Спершу потік на з’єднання, потім epoll,
потім io_uring. Замір під однаковим навантаженням показує, де саме
ламається кожна модель — це найпереконливіша частина всього курсу.
B6 — профілювання. strace -T, perf, bpftrace: знайти, чому програма
повільна, і відрізнити брак процесора від очікування вводу-виводу.
Джерела
Section titled “Джерела”- 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