Виртуальная память: иллюзия бесконечности

Виртуальная память: иллюзия бесконечности

Откройте 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 ГБ клонировался бы несколько секунд. Вместо этого:

  1. Создаётся новая таблица страниц — структурная копия родительской.
  2. Все общие страницы помечаются «только для чтения» в обеих таблицах.
  3. При первой попытке записи в любую страницу срабатывает 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/enabledmadvise или 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 и со страницами ОС напрямую не связаны.

Источники

Предыдущий
Наверх