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

Віртуальна пам'ять у роботі

Програма виділяє 8 ГіБ на машині з чотирма — і працює. Файл на 40 ГіБ відкривається через mmap миттєво. fork процесу, який займає 2 ГіБ, триває мікросекунди.

За всіма трьома випадками стоїть один механізм: сторінка може бути в адресному просторі, не будучи в пам’яті. Біт дійсності в записі таблиці сторінок (модуль 11) перетворює будь-яке звернення на подію, яку ядро обробляє на власний розсуд.

Передумови. Таблиця сторінок і біт дійсності (модуль 11), переривання і винятки (модуль 2).

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

Підкачка на вимогу (demand paging) означає, що сторінка потрапляє в пам’ять у момент першого звернення до неї, і не раніше. Ядро скидає біт дійсності в записах усіх ще не завантажених сторінок, а апаратура сама повідомляє, коли якась із них знадобилася.

Послідовність обробки сторінкового винятку з двома розгалуженнями1процес звертається до адреси2MMU перевіряє біт дійсності3сторінковий виняток, керування переходить у ядро4адреса належить процесу?5знайти вільний фрейм — або витіснити чужу сторінку6завантажити вміст сторінки7оновити запис і повторити ту саму інструкціюдійсний — доступбез участі ядрані —SIGSEGVз кеша, з файлуабо з диска
Крок 7 — головний: інструкція виконується повторно, і цього разу трансляція вдається. Процес не має жодного способу помітити, що щось сталося.

Сторінковий виняток (page fault) не є помилкою, попри назву. Це штатне повідомлення апаратури: відображення відсутнє, розберися. Помилкою він стає лише тоді, коли адреса справді не належить процесу, і тоді ядро надсилає SIGSEGV.

До диска більшість винятків узагалі не доходить:

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

Порядки величин тут такі: м’який виняток коштує одиниці мікросекунд (на віртуальній машині з першого дотику до анонімної пам’яті виходить близько 2.3 мкс на сторінку), жорсткий на SSD — сотні мікросекунд, на диску, що обертається, — одиниці мілісекунд. Різниця в два-три порядки, і саме вона вирішує, чи система відповідає, чи стоїть.

Копіювання під час запису

Section titled “Копіювання під час запису”

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

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

Наслідки видно щодня. fork великого процесу коштує майже стільки ж, скільки малого. Сотня процесів однієї програми ділить один екземпляр коду. А щойно виділена через malloc пам’ять фізично не існує, поки в неї не запишуть.

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

Алгоритм Правило Проблема
Оптимальний той, до кого звернуться найпізніше потребує знання майбутнього; використовується як еталон
FIFO найдавніше завантажений давність завантаження не пов’язана з корисністю
LRU найдавніше використаний точна реалізація потребує оновлення позначки на кожному зверненні
Годинниковий наближення LRU через біт звернення працює, тому й застосовується насправді

Оптимальний алгоритм нереалізовний, але потрібен: без нього немає з чим порівнювати решту.

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

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

Робочий набір і трешинг

Section titled “Робочий набір і трешинг”

Робочим набором процесу називають множину сторінок, до яких він звертається в поточному проміжку часу. Поки робочі набори всіх активних процесів уміщаються у фізичну пам’ять, система працює нормально.

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

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

Що з цим роблять:

  • обмежують кількість одночасно активних процесів;
  • виділяють фрейми пропорційно робочому набору, а не порівну;
  • зупиняють або завершують частину процесів — цим займається OOM killer;
  • у Linux читають індикатор тиску (/proc/pressure/memory), який показує проблему до того, як система стане.

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

Кеш сторінок — це той самий механізм з іншого боку: прочитані з диска сторінки лишаються в пам’яті. Тому повторне читання файлу миттєве, а вся вільна пам’ять у Linux зайнята кешем (модуль 13).

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

OOM killer спрацьовує, коли пам’яті немає, а витісняти вже нічого. Жертву він обирає за оцінкою oom_score, грубо кажучи, найбільшого споживача, і завершує її. Побачити його роботу можна в журналі ядра.

Terminal window
ps -o min_flt,maj_flt,rss,vsz,comm -p $$

min_flt — м’які винятки, maj_flt — жорсткі, з читанням із диска. Здоровий процес має тисячі перших і одиниці других.

Terminal window
/usr/bin/time -v ls / 2>&1 | grep -E 'page faults|Maximum resident'

Те саме для окремого запуску, разом із піковим RSS.

Terminal window
grep -E 'pgfault|pgmajfault|pswpin|pswpout' /proc/vmstat

Лічильники з моменту завантаження. Два виміри з паузою покажуть поточну інтенсивність — саме так відрізняють трешинг від звичайного навантаження.

Terminal window
cat /proc/pressure/memory

Індикатор тиску на пам’ять (PSI): частка часу, яку задачі провели в очікуванні пам’яті. Показує проблему задовго до того, як система стане.

Terminal window
free -h; swapon --show; cat /sys/block/zram0/comp_algorithm 2>/dev/null

Пам’ять, кеш і підкачка. Дивитись треба на available, а не на free.

Terminal window
journalctl -k --grep 'Out of memory|oom_reaper' | tail -5

Історія спрацювань OOM killer: кого обрано жертвою і скільки пам’яті той займав.

Terminal window
python3 -c "
import mmap, os
f = os.open('/usr/bin/python3', os.O_RDONLY)
m = mmap.mmap(f, 0, prot=mmap.PROT_READ)
print('відображено', len(m), 'байтів, прочитано з диска: 0')
print('перший байт:', m[0:4])
"

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

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

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

«Сторінковий виняток — це помилка». Це штатний механізм. Тисячі винятків за секунду означають нормальну роботу, а проблемою є лише жорсткі винятки, які йдуть на диск.

«Вимкну swap, система стане швидшою». Без swap ядро не може витіснити жодної анонімної сторінки, тож рідковживані дані витісняють із пам’яті корисний кеш сторінок. Результат нерідко протилежний очікуваному, а замість плавного сповільнення система одразу зустрічає OOM killer.

«malloc повернув адресу, значить пам’ять виділено». Виділено адреси. Фізичні сторінки з’являться під час першого запису, і от тоді може з’ясуватися, що їх немає.

«Процес убито, значить у програмі баг». Спершу подивіться journalctl -k на предмет OOM killer. Процес міг бути цілком коректним і просто виявитися найбільшим на момент, коли пам’ять закінчилася.

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

«Трешинг — це коли не вистачає пам’яті». Трешинг настає, коли не вміщаються робочі набори. Вільна пам’ять при цьому може бути, просто не там, де потрібно, і система витрачає весь час на переміщення сторінок.

Перевір себе

1. Що робить ядро останнім кроком обробки сторінкового винятку?
2. Чому fork процесу на 2 ГіБ виконується за мікросекунди?
3. Чим мʼякий сторінковий виняток відрізняється від жорсткого?
4. Чому точний LRU не використовується в операційних системах?
5. Завантаження процесора близьке до нуля, диск завантажений на 100%, система не реагує. Що це?
6. malloc(8 ГіБ) повернув валідний вказівник на машині з 4 ГіБ памʼяті. Чому?

A5 — симулятор заміщення сторінок. FIFO, LRU, годинниковий і оптимальний на реальному трасі звернень. Обов’язкова частина — відтворити аномалію Беладі на FIFO і переконатися, що на LRU вона не відтворюється.

B3 — cgroups і OOM. Обмежити процесу пам’ять через cgroups v2, довести його до межі й простежити в журналі, як спрацьовує OOM killer.