Фрагментация оперативной памяти звучит как абстрактная беда из учебника, пока программа с ошибкой нехватки памяти не падает на машине, где монитор честно показывает четыре свободных гигабайта. Счётчик свободной памяти отвечает на вопрос "сколько", а выделение памяти всегда задаёт вопрос "подряд ли". Между этими двумя вопросами живёт целый зоопарк проблем: виртуальные страницы, физические диапазоны, дыры в куче, закреплённые буферы и драйверы, которым нужна непрерывность любой ценой. Разобраться в механике стоит хотя бы для того, чтобы перестать удивляться, когда перезапуск службы "излечивает" утечку, которой формально не было.

Дальше текст идёт по уровням: виртуальные адреса, физические страницы, куча процесса, закреплённые буферы, а в конце осмотр и разбор того, почему перезагрузка реально убирает.

Виртуальные страницы четыре килобайта и срезы адресного пространства

Современный процессор работает с памятью порциями по 4 КБ, и весь обмен между виртуальным адресом приложения и физической микросхемой происходит именно страницами такого размера. Каждый процесс получает своё изолированное адресное пространство, разграфлённое таблицами страниц, и внутри него царит порядок, которого на самом деле не существует. Две соседние виртуальные страницы могут лежать в физической памяти на расстоянии сотен мегабайт друг от друга, и процессу всё равно, потому что аппаратное преобразование адресов прячет эту разницу.

Неприятности начинаются на уровне самого виртуального пространства. Когда программа резервирует диапазон адресов, она просит непрерывный участок именно в виртуальной нумерации. Со временем пространство превращается в швейцарский сыр: тут блок на 64 КБ от старой библиотеки, тут резерв под стек, тут отображение файла, которое уже закрыли, а тут выделение, которое держит какой-нибудь кэш. Свободных суммарных адресов хватает, но цельного куска нужной длины нет.

Классический пример этого закона живёт в 32-битных процессах. Лимит адресного пространства в 2 ГБ (или 3 ГБ с особыми флагами) кажется щедрым, пока не оказывается, что длительно работающая программа нарезала его сотнями выделений и освобождений. Когда внешняя библиотека внезапно просит непрерывные 512 МБ под буфер распаковки, такого куска может просто не остаться, хотя суммарно свободно больше гигабайта. Ошибка при этом сообщает ровно одно слово: память кончилась. Формально она не кончилась - кончилась непрерывность.

Страницы собирают физическую память из кусочков, и приложение физическую фрагментацию почти не замечает. Но накладные расходы на таблицы и на промахи буфера трансляции адресов растут, и тут на сцену выходят большие страницы.

Большие страницы два мегабайта и цена непрерывности

Большая страница размером 2 МБ работает как грузовик против легкового парка: одна запись в таблице страниц покрывает то же самое, что 512 мелких записей, процессор реже заглядывает в таблицы и реже промахивается мимо кэша трансляций. Базы данных, виртуализация и высокопроизводительные вычисления любят большие страницы именно за эту экономию на служебных операциях.

Обратная сторона медали названа в заголовке: большая страница требует 2 МБ физически непрерывной памяти, выровненной по границе 2 МБ. Виртуальная механика тут уже не помогает, потому что одна запись таблицы страниц указывает на один физический диапазон целиком. Если система проработала неделю и физическая память разрезана занятыми страницами как огород грядками, цельных двух мегабайт может не найтись, хотя свободных физических гигабайтов хватает с избытком.

Запрос на большие страницы в таком состоянии либо отклоняется, либо отправляет менеджер памяти в тяжёлую перестановку: система пытается сдвинуть подвижные страницы, освободить длинный интервал и собрать нужную полосу. Перестановка стоит времени и не всегда удаётся, поэтому производственная практика пошла простым путём - резервировать большие страницы при загрузке ОС, пока память девственно цела. Это решение надёжно, но вычитает память из общего оборота и усиливает фрагментацию для всех остальных потребителей.

Физическая свободная память, таким образом, бывает разной степени пригодности. Тонкий шрам мелких занятых страниц, пришитый посреди адресного пространства, портит выгодные диапазоны для всего, что требует длинных участков: от больших страниц до прямого доступа некоторых устройств.

Buddy allocator и степени двойки

Аллокатор физических страниц ядра должен как-то выдавать непрерывные диапазоны, и десятилетиями стандартным ответом служит buddy allocator. Идея проста и красива: вся свободная память делится на блоки размером в степени двойки - одна страница, две, четыре и так далее до максимального порядка. Запрос на блок округляется до ближайшей степени. Если свободного блока нужного размера нет, разрезается пополам блок побольше, и две половинки становятся "братиями" (buddies). Когда обе половинки снова свободны, они сливаются обратно.

Такой механизм отлично борется с внешней фрагментацией на короткой дистанции: сливаемость братьев поддерживает наличие крупных блоков. Но у него есть врождённая слабость. Если после месяцев работы единственная занятая страница сидит посреди каждого большого блока, слияния не происходят, и максимальный доступный непрерывный заказ падает до мелочёвки. Общий объём свободных страниц при этом может быть огромным - и непригодным.

Ядра современных ОС подпирают buddy allocator дополнительными приёмами. Линуксовый вариант делит выделения на подвижные и неподвижные зоны, стараясь не пускать закреплённые страницы в участки, где в будущем могут понадобиться длинные полосы под большие заказы. Windows ведёт списки свободных участков по порядкам и только потом пускает в ход компактификацию. Но фундаментальное правило не меняется: чем дольше работает система и чем разнообразнее нагрузка, тем меньше цельные куски верхних порядков, и тем чаще запросы на непрерывность упираются в стену.

Куча процесса, свободные дыры против гигантских выделений

Внутри процесса действует тот же закон, только на меньшем масштабе. Менеджер кучи (malloc в мире С, менеджеры .NET и Java в управляемых средах) режет выделенные у системы диапазоны на блоки для приложения. Циклы "выделил - освободил" оставляют после себя дыры, и через несколько часов работы куча напоминает лоскутное одеяло: свободных байт навалом, а цельных свободных участков мало.

Больнее всего бьют гигантские выделения посреди пульса мелкой работы. Программа два часа парсит документы, наделала миллион структур по 200 байт, потом просит 300 МБ под итоговый отчёт - и получает отказ, потому что сумма дыр даёт 500 МБ, а максимальная дыра - 40. Менеджер кучи не может сдвинуть живой объект, если в коде на него есть сырой указатель: адрес зафиксирован, и перенос превратит указатели в мины замедленного действия.

Отсюда деление менеджеров памяти на два лагеря. Перемещающие сборщики мусора (как в управляемых средах) периодически уплотняют кучу, потому что владеют всеми ссылками и могут их переписать. Неперемещающие аллокаторы для кода с указателями ограничены сегрегацией по размерам: мелкие блоки складываются в пулы фиксированных классов, крупные обслуживаются напрямую у ОС. Сегрегированные пулы сдерживают фрагментацию, но не отменяют её - остаётся внутренняя фрагментация (потерянные хвосты внутри выделенных блоков) и остаточная внешняя.

Полезно различать reserve и commit на уровне ОС. Резервирование отмечает диапазон виртуальных адресов как занятый, но физической памяти не тратит; коммит подкрепляет страницы реальной памятью или файлом подкачки. Правильная стратегия для больших структур - зарезервировать широкий кусок адресов заранее, пока пространство не изрезано, и коммитить по мере роста. Так делают динамические массивы серьёзных баз данных, и именно поэтому на 64-битной системе с её астрономическим запасом виртуальных адресов проблема резерва почти исчезла, а на 32-битной она оставалась главной причиной "странных" отказов.

"Свободно четыре гигабайта, а файл не влезает"

Сочетание счётчиков свободной памяти и ошибки ERROR_NOT_ENOUGH_MEMORY - классическая загадка для администратора. Ответ почти всегда сводится к непрерывности. Отображение файла в память требует непрерывного участка виртуального адресного пространства длиной в размер файла. Если нужно сопоставить файл на 4 ГБ, а максимальное свободное окно в адресах процесса - 1.2 ГБ, ошибка нехватки памяти логична, хотя суммарно и свободно, и физической памяти, и адресов больше нужного.

Windows, честно говоря, распространяет термин "нехватка памяти" на массу разных отказов: не хватило непрерывного диапазона адресов, не хватило коммита, не хватило невыгружаемого пула, исчерпался лимит дескрипторов. Поэтому диагностика начинается не с вопроса "сколько свободно", а с вопроса "какой именно ресурс отказал". VMMap показывает раскладку виртуального пространства отдельного процесса и сразу обнаруживает, воскресительным ли резервом забиты адреса или дырки размазаны мелочью. RAMMap смотрит на систему целиком: списки страниц, закреплённые области, расход пула драйверами и состав невыгружаемой памяти.

Особая статья расходов - драйверы. Оборудование без блока управления памятью хочет физически непрерывные буферы для прямого доступа, и драйвер просит их у ядра вольным заранее или в критический момент. Сетевые карты старых поколений, некоторые аудиоинтерфейсы, контроллеры дисков с 32-битной адресацией - все они привередливы. Если непрерывный диапазон нужного размера не находится, драйвер возвращает общую ошибку памяти, а устройство на ровном месте отваливается. Именно из-за драйверов фраза "свободно восемь гигабайт" не гарантирует даже запуска виртуальной машины с требованием цельного бэкинга.

Закреплённые страницы, которые удерживаются в физической памяти для операций ввода-вывода, усугубляют картину: они создают островки, которые нельзя трогать при любой переупаковке. Больше закреплённого ввода - больше гвоздей в полу, на которые спотыкается любая попытка убрать комнату.

Компактификация и сжатие памяти как запоздалые меры

Почему бы системе не сдвинуть страницы и собрать цельные участки, как дефрагментатор собирает файлы? Операционные системы именно так и делают, но с жёсткими ограничениями. Memory compaction в ядре Linux и аналогичные механизмы в Windows переносят только подвижные страницы: те, на которых нет внешних фиксированных ссылок. Страницы пользовательских процессов в целом подвижны, потому что виртуальный адрес не меняется при переносе физического носителя, а таблицы страниц обновляются одной записью. Однако страницы, закреплённые под прямой доступ оборудования, остаются на месте до конца операции, и каждая такая страница - потенциальный раскол цельного диапазона.

Компактификация стоит дорого: каждый перенос - это блокировки, копирование, инвалидация кэшей трансляции. Поэтому она запускается экономно, обычно как реакция на отказ крупного выделения, а не как фоновая гигиена. На старых системах и при тяжёлой нагрузке это создаёт характерные паузы, когда система внезапно задумывается, а потом возвращается с выполненным запросом - или с отказом.

Windows 10 добавила второй инструмент - сжатие памяти. Вместо немедленной выгрузки редко используемых страниц в файл подкачки система пакует их алгоритмом сжатия и складывает в специальную область. Счётчик "в сжатом виде" в диспетчере задач показывает именно этот запас. Техника экономит дисковые операции и уменьшает давление на выделение, но к фрагментации относится двусмысленно: с одной стороны освобождает физические страницы под новые нужды, с другой - само хранилище сжатых страниц занимает место и тоже живёт в физической памяти.

Обе техники работают с последствиями, а не с причинами. Причина - хронология: кто что выделил и в каком порядке вернул. И вот тут на сцену выходит испытанная народная практика.

Перезапуск как уборка и умный осмотр

Перезапуск процесса или всей машины лечит фрагментацию радикально, потому что стирает историю. Новое адресное пространство строится с нуля, крупные резервы делаются первыми, пока всё пусто, а физическая память собирается в длинные свежие полосы. Именно поэтому резервирование больших страниц при старте ОС надёжно, а попытка получить их через месяц аптайма - лотерея. Симптомы состояния, которое перезапуск лечит лучше всего, выглядят так:

  1. Процесс растёт днями, а отказы приходят при около половинной загрузке счётчиков памяти.
  2. Ошибка нехватки памяти сопровождает запрос больших структур: распаковку архива, построение индекса, отображение большого файла.
  3. После перезапуска та же нагрузка работает снова несколько дней без сбоев, и цикл повторяется.
  4. Драйверы или виртуализация отказываются стартовать, хотя свободной памяти много.
  5. VMMap показывает адресное пространство, расчерченное мелкими резервами, с маленьким самым длинным свободным интервалом.

Диагностика до перезагрузки стоит минут и экономит нервы. VMMap приписывает каждый виртуальный диапазон к категории - куча, стек, отображение файла, резерв, общая память - и сортирует окна по размеру. RAMMap дополняет картину со стороны системы: списки страниц по состояниям, закреплённые страницы, невыгружаемый пул, занятое драйверами. С этими двумя взглядами фраза "память есть, а памяти нет" превращается из парадокса в точное описание: есть объём, нет непрерывности реквизированного вида.

Профилактика следует из той же механики. Широкие резервы адресов делаются на старте, а не по мере надобности. Гигантские буферы выделяются одним запросом, а не отращиваются кусочками через реаллокацию. Службы с известным режимом деградации периодически перезапускаются по расписанию, а не по срабатыванию пожара. Драйверы обновляются, потому что современные версии чаще умеют собирать буферы из разрозненных страниц через списки сегментов и реже требуют непрерывности. И наконец 64-битные процессы снимают проблему адресного голода на порядки, оставляя только физическую фрагментацию, с которой уже борются подвижность страниц, сжатие и компактификация.

Фрагментация оперативной памяти не болезнь, а следствие экономики: память выдаётся порциями, забирается хаотично, а каждая попытка собрать большой заказ после долгой работы - это сбор урожая на поле, где все грядки уже заняты. Операционная система старательно переставляет грядки, и перезапуск расчищает поле под ноль. Понимание уровней - виртуальных адресов, страниц, кучи, закреплённых буферов - превращает загадочные отказы в читаемую книгу, где у каждой ошибки есть внятная механическая причина.