
Виртуальная память: иллюзия бесконечности
Откройте htop на ноутбуке с 16 ГБ оперативной памяти и посмотрите на колонку VIRT. Chrome показывает 40 ГБ. Slack — 8 ГБ. VS Code — 12 ГБ. Сумма виртуальной памяти всех процессов легко переваливает за 200 ГБ. А комп работает, не тормозит и не падает.
Это не ошибка учёта и не утечка. Это иллюзия, которую операционная система старательно поддерживает для каждой программы: будто бы в её руках бесконечный, непрерывный и персональный адресный простор. Программа на C обращается по адресу 0x7fff_abcd_1234 и не подозревает, что этого адреса может не быть ни в одной микросхеме памяти. Программа на Go резервирует 64 МБ «арену» и пользуется ей годами, физически не заняв ни килобайта.
Этот фокус — одно из самых важных изобретений в истории вычислений. Он родился в 1961 году в Манчестерском университете: компьютер Atlas под руководством Тома Килбурна умел адресовать больше памяти, чем было установлено физически, подгружая недостающие фрагменты с магнитного барабана. Программа видела 1 МБ, хотя в машине стояло 16 КБ. Пользователю — иллюзия, аппаратуре — честная работа.
С тех пор мы живём внутри этой иллюзии. Без неё современная ОС не смогла бы ни изолировать процессы друг от друга, ни запускать больше программ, чем помещается в ОЗУ, ни безопасно подгружать динамические библиотеки. В этом посте разберёмся, как она устроена: страницы и таблицы, кэш TLB и page fault, Copy-on-Write и mmap, OOM Killer и ASLR. А ещё выясним, почему malloc на Linux «никогда не ошибается», даже когда обещает память, которой нет.
Аналогия: библиотека с магической адресацией
Представьте городскую библиотеку. У неё огромный архив в подвале и небольшой читальный зал наверху. Каждый читатель приходит со своей картой индекса: там написано, что нужная книга — на полке 42, ряд 17, место 8. Он идёт туда и находит свою книгу.
Но вот подвох: каждый читатель думает, что именно он занимает полку 42. На самом деле библиотекарь по-тихому подменяет физическое расположение. Двое читателей могут смотреть на «одну и ту же полку 42» и получать совершенно разные книги. Если книги физически нет в читальном зале, библиотекарь быстро сбегает в архив, приносит её и ставит на свободное место. Читатель даже не заметит задержки. Ну, почти.
Это и есть виртуальная память. Каждый процесс — читатель со своей картой индекса (таблицей страниц). Операционная система — библиотекарь. Физическая память — читальный зал. Диск — архив. Магия в том, что аппаратура процессора делает такую подмену миллиарды раз в секунду, без пауз и без ведома программы.
Страница — квант памяти
Виртуальная память работает не с отдельными байтами, а со страницами — блоками фиксированного размера. На x86-64 стандартная страница — 4 КБ (4096 байт). На Apple Silicon (ARM64) — 16 КБ. Это минимальная единица, которой оперирует аппаратура трансляции адресов.
Аналогия: город обслуживается не адресами отдельных домов, а почтовыми индексами. Почта сортирует посылки по индексам, а внутри индекса разбираться — дело курьера. Так и здесь: железо маршрутизирует страницами, а со смещением внутри страницы разбирается сам процесс.
Когда программа обращается к виртуальному адресу, процессор разбивает его на две части: номер страницы (что искать) и смещение внутри страницы (где именно внутри). Номер страницы ищется в таблице страниц — структуре, которую ведёт ядро ОС. Каждая запись таблицы содержит номер физического фрейма и набор флагов: права доступа (чтение, запись, исполнение), бит присутствия, бит «грязности» (страница была изменена).
Многоуровневые таблицы: как упаковать 256 ТБ
На x86-64 используется 48-битная виртуальная адресация — это 256 ТБ. Если бы таблица страниц была плоской, она заняла бы 512 ГБ только для себя: 2^36 записей × 8 байт. Абсурд.
Поэтому таблица многоуровневая, устроенная как дерево. На x86-64 — четыре уровня: PML4 → PDPT → PD → PT. Каждый уровень — массив из 512 записей, и каждая запись указывает на страницу следующего уровня.
Аналогия: атлас мира. Сначала ищете континент, потом страну, потом город, и только потом улицу. Не хранить же полный перечень всех улиц планеты одним списком. И если какой-то континент вас сейчас не интересует — его детальные карты можно вообще не печатать.
Страницы таблицы создаются только при необходимости. Минимальный процесс обходится четырьмя страницами таблицы (16 КБ), покрывая 2 МБ виртуального пространства. Дерево разворачивается лишь по мере того, как программа реально использует память.
Современные процессоры пошли ещё дальше: Intel (начиная с Ice Lake, 2019) и AMD (начиная с Zen 4, 2022) поддерживают пятиуровневые таблицы, расширяя адресацию до 57 бит — это 128 ПБ (петабайт). Linux включил поддержку в ядре 5.5, а с версии 6.10 (2024) пять уровней безусловно включены для всех сборок x86-64.
TLB — ускоритель трансляции
Проход по четырём уровням таблицы — это четыре обращения к памяти, прежде чем добраться до собственно данных. Каждое обращение — десятки или сотни тактов. Если делать так каждый раз, производительность рухнет.
Чтобы не платить за эти четыре обращения снова и снова, в процессор встраивают TLB (Translation Lookaside Buffer) — аппаратный кэш последних трансляций. По сути это список быстрого набора на смартфоне: не лезть же в полный справочник каждый раз, когда звонишь маме.
- Попадание в TLB — около 1 такта, фактически бесплатно;
- промах TLB — процессор выполняет page walk, проходя по уровням таблицы;
- промах страницы (page fault) — страницы нет в физической памяти, и вмешиваться должно ядро.
Типичный TLB содержит 32–128 записей и полностью ассоциативен (любой адрес может лежать в любом слоте). Современные CPU имеют двухуровневый TLB: крохотный L1 (один такт) и более вместительный L2 (несколько тактов). Для большинства нагрузок коэффициент попаданий превышает 99 % — выручает принцип локальности: программы обычно перемалывают небольшой кусок памяти подолгу, а не скачут по всей карте.
Страничные промахи: мягкие, жёсткие и криминальные
Когда процесс обращается к адресу, для которого нет актуальной записи в таблице страниц, возникает страничный промах. Ядро перехватывает его и решает, что делать. Есть три сценария.
Мягкий промах (minor fault): страница уже в физической памяти — её загрузил другой процесс или она была предзагружена, — но пока не отображена в таблице текущего процесса. Ядро дописывает запись и возвращает управление. Микросекунды.
Жёсткий промах (major fault): страницы нет в памяти, она живёт на диске — в свопе или в самом файле, который отображён в память. Нужен дисковый ввод-вывод — миллисекунды на HDD, порядка 100 мкс на NVMe SSD. Если программа промахивается часто, время простоя становится заметным на глаз.
Недопустимый промах (invalid fault): адрес не принадлежит ни одному отображению процесса. Ядро отправляет сигнал SIGSEGV — знакомый segmentation fault. Это тот самый случай, когда программа пыталась разыменовать NULL или выйти за границы массива.
Demand paging: лень как стратегия
Когда процесс запускается, ядро не грузит в память весь его код и данные. Вместо этого оно заводит «обещания»: здесь будет секция .text из исполняемого файла, здесь — куча, здесь — стек. Физические страницы выделяются только при первом обращении. Это demand paging — загрузка по требованию.
Такая ленивая стратегия работает потому, что программы подчиняются принципу локальности: в каждый момент времени большинство из них перемалывает лишь малую часть своего кода и данных. Не надо тащить в ОЗУ весь LibreOffice, если пользователь открыл один документ и сидит в текстовом редакторе — проверка орфографии, графика и макросы могут подождать. Иллюзия «вся программа загружена» поддерживается ровно за счёт того, что непрочитанные страницы так никогда и не читаются.
Copy-on-Write: fork без копирования
Теперь любопытный фокус. Когда процесс вызывает fork(), Linux не копирует всю его память для дочернего. Наивная реализация была бы катастрофой: процесс в 10 ГБ клонировался бы несколько секунд. Вместо этого:
- Создаётся новая таблица страниц — структурная копия родительской.
- Все общие страницы помечаются «только для чтения» в обеих таблицах.
- При первой попытке записи в любую страницу срабатывает page fault. Ядро копирует именно эту страницу, выдаёт копию одному из процессов и возвращает права на запись.
Результат: fork() — операция за O(N), где N — размер таблицы страниц, а не объём данных. А если сразу после fork() вызывается exec() (дочерний процесс заменяет свой образ на другую программу) — данные вообще не копируются: таблица страниц просто заменяется целиком.
Аналогия: Google Docs. Все видят один и тот же документ. Как только кто-то начинает редактировать свою копию — именно для этой копии создаётся версия с правками. Остальные продолжают работать с оригиналом. Платим только за реально изменённые страницы.
Redis и подводные камни CoW
Redis использует fork() для фоновых снимков (BGSAVE): дочерний процесс сериализует данные на диск, а родительский продолжает обслуживать запросы. Теоретически CoW должен означать почти нулевые накладные расходы. На практике — не всегда.
Если родитель активно пишет в базу во время снимка, каждая изменённая страница (4 КБ) копируется целиком — ведь дочерний процесс должен видеть «замороженную» версию базы, пока родитель её модифицирует. При интенсивной записи потребление памяти может почти удвоиться. А если включены прозрачные огромные страницы (THP, 2 МБ), даже изменение одного байта приводит к копированию 2 МБ.
Эксплуатационные рекомендации: отключать THP для Redis (echo never > /sys/kernel/mm/transparent_hugepage/enabled), устанавливать vm.overcommit_memory=1 (чтобы fork() не отказывал из-за консервативного учёта памяти), следить за метрикой rdb_last_cow_size.
mmap: файлы как память
Системный вызов mmap() отображает файл (или анонимный регион) в виртуальное адресное пространство процесса. Файл становится доступен через указатели — будто это обычный массив в памяти. Загрузка по-прежнему ленивая: первое чтение вызывает мягкий page fault, и ядро подтягивает страницу из файла.
Три основных режима:
MAP_PRIVATE— Copy-on-Write: изменения видны только текущему процессу и в файл не попадают;MAP_SHARED— изменения видны всем отображениям и (послеmsync()или автоматически) записываются в файл;MAP_ANONYMOUS— без файла, просто обнулённая память. Именно такmallocполучает у ОС большие блоки.
Аналогия: вы приходите в библиотеку с карандашом. MAP_PRIVATE — на странице появляется заметка, но только в вашей копии; следующий читатель увидит чистую страницу. MAP_SHARED — вы пишете прямо в оригинал, и заметки останутся для всех. MAP_ANONYMOUS — чистая тетрадь, которую библиотека выдала лично вам.
Павло против mmap
В 2022 году Эндрю Павло (Carnegie Mellon) опубликовал резонансную работу «Are You Sure You Want to Use MMAP in Your DBMS?». Вывод: для движков баз данных mmap — почти всегда плохой выбор.
Причины:
- page fault синхронный — нет интерфейса для асинхронного ввода-вывода, которым дышат современные СУБД;
- ядро не понимает паттерн доступа базы и вытесняет «не те» страницы (например, горячие индексы);
- при конкурентном доступе таблица страниц становится узким местом;
- TLB shootdown — при вытеснении страницы на многоядерной машине нужно разослать межпроцессорные прерывания всем ядрам, чтобы они сбросили свои кэши трансляций. Дорого.
При подключении нескольких SSD mmap может быть в 2–20 раз медленнее прямого ввода-вывода. Мораль: mmap прекрасен для простых случаев (отображение конфигов, исполняемых файлов, разделяемых библиотек), но не для СУБД, которой нужен тонкий контроль над буферным пулом.
Overcommit: malloc, который «никогда не ошибается»
Теперь главный парадокс, с которого начинался пост. На Linux по умолчанию malloc(1 ГБ) может вернуть указатель даже на машине с 512 МБ ОЗУ. И это не баг, а политика.
Механика такая: у ОС malloc получает лишь виртуальные страницы. Физические фреймы выделяются только при первой записи (тот самый page fault → demand paging). Виртуальной памяти — терабайты, фактически занятой — сколько влезло. Пока программа не пишет во все запрошенные байты, всё работает.
Поведение настраивается через vm.overcommit_memory:
| Режим | Поведение |
|---|---|
| 0 (по умолчанию) | Эвристика: отклоняет «очевидно безумные» запросы (больше, чем RAM + swap) |
| 1 | Всегда разрешать: malloc никогда не возвращает NULL |
| 2 | Строгий учёт: committed-память не может превышать swap + RAM × overcommit_ratio |
OOM Killer: когда блеф раскрывается
Что делать, если процессы начинают взаправду использовать обещанную память, а физические ресурсы исчерпаны? Вступает OOM Killer. Ядро выбирает процесс-жертву на основе oom_score (0–1000), который в основном учитывает долю потребляемой памяти. Администратор может влиять через oom_score_adj (от −1000 до +1000): значение −1000 делает процесс неприкосновенным.
Аналогия: авиакомпании продают больше билетов, чем мест в самолёте, полагаясь, что часть пассажиров не придёт. Обычно работает. Но иногда приходят все — и тогда кого-то снимают с рейса. OOM Killer — это тот самый сотрудник на стойке регистрации, который решает, кого.
PostgreSQL рекомендует vm.overcommit_memory=2 с overcommit_ratio=80: для СУБД катастрофа, если OOM Killer убьёт сам postmaster — это перезапуск всего кластера. Redis, наоборот, требует vm.overcommit_memory=1: иначе fork() для снимков может не состояться из-за консервативного учёта. Одна и та же настройка — два противоположных правильных значения, и оба сосуществуют в продакшене.
Огромные страницы: когда размер имеет значение
Одна стандартная страница (4 КБ) — одна запись TLB. Чтобы покрыть 1 ГБ, нужно 262 144 записей — больше, чем помещается в любой TLB. Приложения с большими рабочими наборами (базы данных, JVM с огромной кучей, виртуальные машины) постоянно промахиваются по TLB.
Выход — огромные страницы: вместо того чтобы возить груз сотнями маленьких коробок, используем один морской контейнер. Меньше манипуляций, меньше накладных расходов.
| Размер страницы | Записей TLB на 1 ГБ |
|---|---|
| 4 КБ | 262 144 |
| 2 МБ | 512 |
| 1 ГБ | 1 |
В Linux есть два механизма.
Статические огромные страницы (HugeTLBfs) выделяются администратором при загрузке системы и запрашиваются приложением явно (mmap с MAP_HUGETLB). Не подлежат свопу, не фрагментируются, работают предсказуемо. Минус — требуют ручной настройки.
Прозрачные огромные страницы (THP) — ядро автоматически и незаметно объединяет стандартные страницы в 2 МБ. Преимущество: приложения получают выгоду без изменений кода. Недостаток: демон khugepaged непрерывно сканирует память и периодически компактирует её — перемещает страницы, чтобы собрать непрерывные 2 МБ-блоки. Это приводит к пикам задержки 10–100 мс, часто в самых неудачных местах.
Почти все базы данных рекомендуют отключать THP: Redis, MongoDB (до 7.0), Oracle, PostgreSQL, Cassandra. Причина — разреженный случайный доступ, характерный для СУБД, не выигрывает от расширенного TLB-покрытия, но страдает от компактификации. Рекомендация: /sys/kernel/mm/transparent_hugepage/enabled → madvise или never.
ASLR: рандомизация как защита
До сих пор мы говорили об эффективности. Теперь — о безопасности.
Address Space Layout Randomization (ASLR) — рандомизация расположения ключевых областей памяти: стека, кучи, разделяемых библиотек и (при сборке с PIE) кода самой программы. При каждом запуске адреса будут другими.
Аналогия: каждую ночь кто-то переставляет в вашем доме всю мебель. Грабитель, заранее запомнивший, где лежат ключи, утром окажется в пустом коридоре. Если он хочет что-то украсть, ему придётся искать заново — и успеть, пока не проснулись хозяева.
Многие эксплойты полагаются на фиксированные адреса: переполнение буфера с переходом на известный участок кода, атаки return-to-libc (перенаправление потока выполнения в системные функции), ROP-цепочки (комбинирование фрагментов существующего кода). Если адреса непредсказуемы, эти техники перестают работать.
На 32-битных системах энтропия была около 16 бит — перебрать можно за минуты. На 64-битных — 28–40 бит, перебор непрактичен.
Хронология: проект PaX (2001) → OpenBSD 3.4 (2003, первая ОС с ASLR по умолчанию) → Linux mainline (2005) → Windows Vista (2007).
Слабое место: утечка одного указателя (через format string, side-channel или логическую ошибку) раскрывает базовый адрес всего образа. В пределах одного ELF-файла все сегменты сдвигаются вместе, а не независимо — один раскрытый адрес выдаёт остальные.
ARM Memory Tagging Extension
Свежая попытка защититься от ошибок работы с памятью уже на аппаратном уровне. Memory Tagging Extension (MTE) — расширение ARM (v8.5-A / v9). Каждые 16 байт физической памяти получают 4-битный тег. Указатель несёт свой 4-битный тег в старших битах (которые всё равно не используются в 64-битной адресации). При обращении к памяти процессор сравнивает теги; несовпадение вызывает исключение.
Аналогия: каждый замок и каждый ключ окрашены. Ключ подходит по форме, но если цвета не совпадают — дверь не откроется. Это ловит use-after-free (память переиспользована, но в указателе остался старый цвет) и out-of-bounds (соседний блок окрашен иначе).
Реализации: Google Pixel 8 (2023, Tensor G3) — первое потребительское устройство с активным MTE; Apple iPhone 17 (сентябрь 2025, A19) — Apple называет это Memory Integrity Enforcement.
Ограничения: 4 бита — всего 16 возможных тегов. Значит, защита вероятностная (1/16 шанс случайного совпадения), а не детерминированная. В июне 2024 года исследователи описали атаку TikTag, которая обошла MTE на Pixel 8 через спекулятивное выполнение.
Виртуальная память глазами языков
Теория — одно, реальные рантаймы — другое. Посмотрим, как виртуальную память используют четыре современных языка: Go, Rust, Zig и Nim.
Go: большие виртуальные резервации
Go при старте резервирует десятки мегабайт виртуальной памяти — это видно в runtime.MemStats.Sys. Каждая «арена» — 64 МБ виртуального пространства, из которых физически используется лишь часть. Аллокатор Go основан на TCMalloc (Google): трёхуровневая иерархия — mcache (локально для P, без блокировок) → mcentral (по классам размеров) → mheap (глобальный). Go оперирует внутренними страницами по 8 КБ — вдвое больше системных 4 КБ.
Rust: аллокатор как трейт
До версии 1.32 Rust по умолчанию использовал jemalloc. Затем перешёл на системный аллокатор — из-за размера бинарника (+300 КБ), проблем с Valgrind и кроссплатформенности. Трейт GlobalAlloc позволяет подменить аллокатор через атрибут #[global_allocator]. Популярные альтернативы — jemallocator, mimalloc (Microsoft). Нестабильный трейт Allocator (issue #32838) добавит аллокаторы на уровне коллекции: Vec::new_in(alloc).
Zig: page_allocator — прямо из ОС
std.heap.page_allocator — самый базовый аллокатор Zig. Каждое выделение — отдельный системный вызов (mmap). Выделение 1 байта займёт минимум одну страницу (4–16 КБ). Медленно для мелких объектов, но честно и прозрачно — ты видишь, что происходит. Для практической работы — GeneralPurposeAllocator (с проверками в отладке) или ArenaAllocator (массовое освобождение одним вызовом).
Nim: ARC, ORC и прямой доступ к mmap
Nim интересен тем, что совмещает высокоуровневый синтаксис (как у Python) с выбором модели управления памятью на уровне ключа компилятора. Начиная с Nim 2.0, по умолчанию используется ORC — подсчёт ссылок плюс отдельный сборщик циклических ссылок. Освобождение происходит детерминированно, без stop-the-world пауз, как в Swift. Если циклов точно нет (или вы готовы разорвать их вручную), можно собираться с --mm:arc и получить чистый подсчёт ссылок без накладных расходов на сборщик циклов.
На низком уровне Nim даёт несколько аллокаторов: alloc/dealloc — это обычный malloc/free в локальной куче потока; allocShared — разделяемая между потоками куча; createShared[T] — типизированный вариант. А через модуль posix можно вызывать mmap напрямую, минуя и кучу Nim, и системный аллокатор. Такое сочетание удобно в задачах, где нужна и эргономика ref-типов для 90 % кода, и жёсткий контроль над страницами ОС для горячего пути — например, для кольцевых буферов или отображения файлов.
Примеры кода
К посту прилагаются четыре небольшие программы — по одной на Rust, Go, Zig и Nim. Они демонстрируют одни и те же концепции с разных сторон: узнают размер страницы, печатают адреса стека, кучи и кода, показывают переаллокацию динамических массивов, вызывают mmap напрямую. Запустите их на своей машине и сравните вывод — многое из того, что в тексте звучит абстрактно, становится наглядным только на живых числах.
vmem_demo.rs
Программа начинается с того, что вызывает getpagesize() через libc и печатает результат. На Intel-ноутбуке вы увидите 4096, на Mac с Apple Silicon — 16 384. Это напоминание, что страничный квант — не универсальная константа 4 КБ, как принято считать: на современных ARM-чипах страница в четыре раза больше.
Дальше программа печатает адреса трёх переменных: локальной (лежит на стеке), выделенной через Box (лежит на куче) и указателя на функцию main (лежит в секции .text). Числа получатся сильно разными: стек обычно где-то около 0x7ffe…, куча — 0x55d4…, код — ещё ниже. Между этими регионами зияют терабайты «неиспользованного» виртуального пространства. Физически их, разумеется, нет — просто адреса никем не заняты.
Самое интересное здесь — имитация роста Vec. Программа добавляет по одному элементу и печатает указатель на начало буфера каждый раз, когда он меняется. Видно, как ёмкость растёт последовательностью 4 → 8 → 16 → 32, и каждое удвоение указатель прыгает в новое место. Это и есть переаллокация: аллокатор создаёт новый блок вдвое большего размера, копирует туда старые данные, освобождает прежний. Именно поэтому Vec::push стоит в среднем O(1), а не O(n).
Напоследок запустите программу дважды подряд и сравните вывод. Все адреса окажутся другими — стек съехал, куча съехала, даже код съехал. Это работа ASLR: при каждом запуске базовые смещения пересчитываются заново.
vmem_demo.go
Программа читает runtime.MemStats и печатает четыре поля. Смотреть тут нужно на соотношение чисел: HeapAlloc будет единицы килобайт (столько реально занято живыми объектами), а Sys — десятки мегабайт (столько рантайм зарезервировал у ОС). Разница в тысячу раз — это нормально и ровно то, про что шла речь в посте. Go забирает у системы большое виртуальное окно заранее, но физические страницы в нём появляются только по мере реального использования. Sys говорит «адресное пространство занято», а не «оперативная память занята».
Следующий блок объявляет две структуры с одинаковыми полями — bool, int64, bool, — но в разном порядке, и печатает их размеры через unsafe.Sizeof. У первого варианта получится 24 байта, у второго — 16. Разница — выравнивание: компилятор обязан положить int64 по адресу, кратному восьми, и если между bool и int64 нет места, он вставляет «пустую» набивку. Быстрый урок по structure packing — иногда переставить поля значит сэкономить память в каждом экземпляре.
Последний блок — не код, а текстовое напоминание про overcommit: на Linux malloc(1 ГБ) вернёт валидный указатель даже на машине с 512 МБ ОЗУ, тогда как macOS такого не допустит и вернёт nil. Это подпись к разделу про overcommit выше в посте.
vmem_demo.zig
Программа делает два последовательных выделения через std.heap.page_allocator по 4096 байт каждое. Каждое такое выделение — отдельный системный вызов mmap. Затем она печатает оба адреса и разницу между ними.
Интересное тут вот что: разница почти никогда не равна ровно 4096. Казалось бы, раз я только что попросил страницу и сразу попросил ещё одну, ОС могла бы отдать соседнюю. Но ядро намеренно разбрасывает выделения по адресному пространству — это часть защиты от эксплойтов, эксплуатирующих смежность буферов. На практике разница будет в несколько страниц, иногда в мегабайты, иногда в сотни мегабайт.
Дальше программа демонстрирует @intFromPtr — явное приведение указателя к целому числу. В Zig, в отличие от Rust или Go, указатель и число — это честно одно и то же, просто представленное разными типами, и переход между ними делается одной встроенной функцией. Это дух языка: никакой магии, никаких скрытых слоёв.
В комментариях упомянуты три стандартных аллокатора, между которыми в Zig принято выбирать сознательно: page_allocator для крупных блоков напрямую у ОС, FixedBufferAllocator — всё из заранее отведённого буфера (удобно для embedded), ArenaAllocator — массовое освобождение одним вызовом.
vmem_demo.nim
Программа начинается с того же — узнаёт размер страницы, но уже через стандартный POSIX-интерфейс sysconf(SC_PAGESIZE). Результат тот же, что и в Rust-демо, а путь — более портируемый.
Затем она печатает адреса трёх объектов: локальной переменной, ссылки, созданной через new, и функции demo. Потом в цикле заполняет seq[int] двадцатью элементами и печатает указатель каждый раз, когда он меняется. Поведение такое же, как у Vec в Rust-демо — последовательность удвоений с прыжками указателя. Полезно увидеть своими глазами, что «встроенный массив» в высокоуровневом языке внутри работает точно так же, как в низкоуровневом.
Главная изюминка этого демо — четвёртый блок. Программа вызывает mmap напрямую из posix-модуля, получает у ОС одну страницу анонимной памяти, пишет туда байт, читает его обратно и освобождает через munmap. Это даёт почувствовать, что под всеми высокоуровневыми аллокаторами (new, seq, alloc) в конечном счёте лежит один и тот же системный вызов. Nim, как и Zig, не прячет системный уровень — при желании можно спуститься напрямую, и это удобно для кольцевых буферов, shared memory или отображения файлов.
В комментариях — короткое объяснение разницы между ARC и ORC. ARC — это просто подсчёт ссылок: быстрый, детерминированный, но не умеющий освобождать циклы. ORC, ставший умолчанием в Nim 2.0, — тот же ARC плюс отдельный сборщик циклических ссылок. Обе модели работают в куче Nim и со страницами ОС напрямую не связаны.
Источники
- OSTEP: Operating Systems: Three Easy Pieces — главы по виртуальной памяти, TLB, page replacement
- Intel 5-Level Paging White Paper — 57-битная адресация
- Andrew Crotty, Viktor Leis, Andrew Pavlo: «Are You Sure You Want to Use MMAP in Your DBMS?» (CIDR 2022)
- Zalando Engineering: Understanding Redis Background Memory Usage — Redis и CoW
- Linux Kernel Documentation: Overcommit — три режима overcommit
- ARM MTE Whitepaper
- PingCAP: Why We Disable THP for Databases
- ASLR — Wikipedia