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

Залізо очима ОС

Практично кожна абстракція в цьому курсі спирається на щось конкретне в залізі й без цієї опори просто не існувала б.

Витісняльна багатозадачність можлива тому, що є таймер, здатний перервати процес. Ізоляція процесів можлива тому, що є MMU. Віртуальна пам’ять можлива тому, що апаратура вміє повідомити про відсутнє відображення. А захист від запису в чужу пам’ять реалізований не перевіркою в коді ядра, як можна було б подумати, а одним бітом у таблиці сторінок.

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

У процесора є рівні привілеїв. На x86 їх формально чотири, кільця 0–3, але реально використовуються два: режим ядра (кільце 0) і режим користувача (кільце 3).

У режимі ядра доступно все: перепрограмувати MMU, вимкнути переривання, звернутися до порту пристрою, зупинити процесор. У режимі користувача спроба виконати таку інструкцію не ігнорується, а викликає виняток, і керування переходить до ядра.

Перемкнутися в режим ядра можна трьома способами, і всі три передають керування коду ядра за наперед заданою адресою:

  1. системний виклик, коли програма свідомо просить обслуговування;
  2. виняток, коли програма зробила щось, чого апаратура не змогла виконати;
  3. переривання, тобто зовнішня подія, яка до програми взагалі не стосується.

Інструкції «перейти в кільце 0 і продовжити виконувати мій код» не існує. На цьому й тримається вся ізоляція.

І те, і те передає керування ядру, але з різних причин.

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

Виняток, навпаки, синхронний: його спричинила саме та інструкція, яка зараз виконується. Ділення на нуль, звернення за недійсною адресою, привілейована інструкція в режимі користувача, сторінковий виняток.

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

Відрізняється те, що робити потім. Після переривання виконання продовжується з наступної інструкції, а після сторінкового винятку та сама інструкція виконується повторно, бо відображення тепер уже є.

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

Ядру MMU потрібен одразу для трьох речей. Для ізоляції, бо процес фізично не може сформувати адресу до чужої пам’яті. Для переміщуваності, бо процес не знає, де він лежить насправді. І для віртуальної пам’яті, бо відображення може бути відсутнє, і про це повідомлять.

Деталі будуть у модулях 10 і 11, а тут важливо тільки одне: це апаратура, і без неї захист пам’яті неможливий у принципі. Мікроконтролери без MMU виконують увесь код у спільному просторі, тому там немає ні процесів, ні SIGSEGV.

Затримки рівнів памʼяті в логарифмічному масштабі, від регістра до диска, з перерахунком у людський масштаб типова затримка звернення (логарифмічна шкала)якби L1 = 1 секундарегістр0.3 нс0.3 скеш L11 нс1 секундакеш L24 нс4 секундикеш L315 нс15 секундоперативна пам’ять80 нс1.3 хвилиниSSD (NVMe)50 мкс14 годинмережа в межах ЦОД0.5 мс6 дібдиск, що обертається5 мс58 дібпорядки величин, не точні заміри конкретного заліза
Права колонка — той самий ряд у людському масштабі. Поки звернення до L1 триває секунду, читання з диска, що обертається, займає два місяці.

Швидка пам’ять дорога, дешева повільна, і компроміс тут завжди один: кілька рівнів, де кожен наступний більший і повільніший за попередній.

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

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

Ті самі два порядки, які відділяють оперативну пам’ять від SSD, пояснюють, чому процес переводять у стан «заблокований» замість того, щоб чекати: за 50 мікросекунд процесор виконав би сотні тисяч інструкцій.

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

Наслідок побачите в модулі 13 — саме тому копіювання великого файлу майже не завантажує процесор.

Одне ядро процесора виконує один потік. Щойно ядер стає кілька, з’являються задачі, яких на одному не існувало.

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

Далі — порядок операцій. Процесор виконує інструкції поза чергою і має буфери запису, тому записи стають видимими іншим ядрам не в тій послідовності, у якій виконувались. Через це й існують бар’єри пам’яті (модуль 9).

І нарешті NUMA. На кількох сокетах пам’ять фізично приєднана до конкретного процесора, тож звернення до «чужої» пам’яті йде через міжпроцесорний зв’язок і коштує помітно дорожче. Тому планувальник намагається тримати процес там, де лежить його пам’ять (модуль 7).

Terminal window
lscpu | grep -E 'Model name|^CPU\(s\)|Thread|Core|Socket|NUMA|L1d|L2|L3'

Ядра, потоки на ядро, сокети, розміри кешів і вузли NUMA — уся топологія, з якою працює планувальник.

Terminal window
getconf LEVEL1_DCACHE_LINESIZE

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

Terminal window
head -20 /proc/interrupts

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

Terminal window
grep -E 'LOC|RES|CAL|TLB' /proc/interrupts

LOC — переривання локального таймера, ті самі, що дають витіснення. Їхня кількість росте безперервно, поки система працює.

Terminal window
numactl --hardware 2>/dev/null || echo 'один вузол NUMA'

Вузли NUMA і відносна вартість доступу між ними. На одному сокеті матриця буде тривіальною, на сервері — ні.

Terminal window
perf stat -e cache-misses,cache-references,instructions,cycles -- \
sh -c 'i=0; while [ $i -lt 300000 ]; do i=$((i+1)); done'

Промахи кеша, інструкції й такти. Відношення інструкцій до тактів (IPC) менше за одиницю зазвичай означає, що процесор чекає на пам’ять, а не рахує.

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

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

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

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

«Кеш читає байти». Кеш оперує рядками по 64 байти, і одного байта як одиниці обміну просто не існує. Через це порядок обходу масиву й впливає на швидкість у рази.

«Захист пам’яті реалізований у ядрі». Реалізований він в апаратурі. Ядро тільки заповнює таблиці, а перевіряє їх MMU при кожному зверненні.

«Багато ядер — просто швидше». До швидкості додаються когерентність кешів, переставляння операцій із пам’яттю і NUMA. Наївно розпаралелений код на 32 ядрах цілком може виявитися повільнішим, ніж на одному.

Перевір себе

1. Що станеться, якщо прибрати апаратний таймер?
2. Чим переривання відрізняється від винятку?
3. Скільки байтів фізично приїде в кеш, якщо програма прочитає один байт із памʼяті?
4. Якщо звернення до кеша L1 умовно триває секунду, скільки триває читання з диска, що обертається?
5. Де реалізована перевірка, що процес не пише в чужу памʼять?
6. Двоє ядер пишуть у сусідні незалежні змінні, і паралельний код працює повільніше за послідовний. Чому?

B2 — mini-top. Читання /proc/stat і /proc/interrupts: побачити переривання таймера, розподіл переривань по ядрах і обчислити завантаження за різницею лічильників.