Алгоритмы динамического управления памятью

При динамическом выделении памяти запросы на выделение памяти формируются во время исполнения задачи. Динамическое выделение, таким образом, противопоставляется статическому, когда запросы формируют­ся на этапе компиляции программы. В конечном итоге, и те, и другие запросы нередко обрабатываются одним и тем же алгоритмом выделения памяти в ядре ОС. Но во многих случаях статическое выделение можно реализовать намного более простыми способами, чем динамическое. Главная сложность здесь в том, что при статическом выделении кажется неестественной — и поэтому редко требуется — возможность отказаться от ранее выделенной памяти. При динамическом же распределении час­то требуется предоставить возможность отказываться от запрошенных блоков так, чтобы освобожденная память могла использоваться для удов­летворения последующих запросов. Таким образом, динамический рас­пределитель вместо простой границы между занятой и свободной памя­тью (которой достаточно в простых случаях статического распределения) вынужден хранить список возможно несвязных областей свободной па­мяти, называемый пулом или кучей.

Многие последовательности запросов памяти и отказов от нее могут привести к тому, что вся доступная память будет разбита на блоки ма­ленького размера, и попытка выделения большого блока завершится не­удачей, даже если сумма длин доступных маленьких блоков намного больше требуемой. Это явление называется фрагментацией памяти. Иногда используют более точный термин — внешняя фрагмен­тация (что такое внутренняя фрагментация будет рассказано далее). Кроме того, большое количество блоков требует длительного поиска. Существует также много мелких трудностей разного рода. К счастью, человечество занимается проблемой распределения памяти уже давно, и найдено много хороших или приемлемых решений.

В зависимости от решаемой задачи используются различные стратегии по­иска свободных блоков памяти. Например, программа может выделять бло­ки одинакового размера или нескольких фиксированных размеров. Это сильно облегчает решение задач дефрагментации и поиска свободных участ­ков ОЗУ.

Возможны ситуации, когда блоки освобождаются в порядке, обратном тому, в котором они выделялись. Это позволяет свести выделение памяти к стеко­вой структуре, т. е. фактически, вернуться к простому запоминанию грани­цы между занятой и свободной памятью.

Возможны также ситуации, когда некоторые из занятых блоков можно пе­реместить по памяти — тогда есть возможность проводить дефрагментацию памяти, перемещение занятых блоков памяти с целью объединить свобод­ные участки. Например, функцию realloco в ранних реализациях системы UNIX можно было использовать именно для этой цели.

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

Впрочем, случайные запросы — далеко не худший вариант. Даже не зная Деталей стратегии управления кучей, довольно легко построить програм­му, которая "испортит жизнь" многим распространенным алгоритмам.

Приведенный пример построен на том предположении, что система выделяет нам блоки памяти, размер которых соответствует запрошенному с точности до байта. Если же минимальная единица выделения равна 32 байтам, ника­кой внешней фрагментации наш пример не вызовет: на каждый запрос бу­дет выделяться один блок. Но при этом мы столкнемся с обратной пробле­мой, которая называется внутренней фрагментацией: если система умеет выделять только блоки, кратные 32 байтам, а нам реально нужно 15 или 47 байт, то 17 байт на блок окажутся, потеряны.

Чем больше размер единицы выделения, тем меньше нам грозит фрагмента­ция внешняя, и тем большие потери обеспечивает фрагментация внутрен­няя. Величина потерь зависит от среднего размера запрашиваемого блока. Грубая оценка свидетельствует о том, что в каждом блоке в среднем теряется половина единицы выделения, т. е. отношение занятой памяти к потерян-

1 Ns

ной будет------, где N— количество выделенных блоков, s — размер еди-

2 N1

ницы выделения, а 7 — средний размер блока. Упростив эту формулу, мы получим выражение для величины потерь: ^J , т. е. потери линейно растут с увеличением размера единицы выделения.

Если средний размер блока сравним с единицей выделения, наша формула теряет точность, но все равно дает хорошую оценку порядка величины потерь. Так, если s = 7, наша формула дает 50% потерь, что вполне согласуется со здравым смыслом: если запрашиваемый блок чуть короче минимально возможного, теряется только это "чуть"; зато если он чуть длиннее, то для него отводится два минимальных блока, один из которых теряется почти весь. Точная величина потерь определяется распределением запрашиваемых блоков по длине, но мы предпочитаем оставить вывод точной формулы лю­бопытному читателю.

Варианты алгоритмов распределения памяти исследовались еще в 50-е годы. I Итоги многолетнего изучения этой проблемы приведены в [Кнут 2000] и многих других учебниках.

Обычно все свободные блоки памяти объединяются в двунаправленный свя­занный список. Список должен быть двунаправленным для того, чтобы из него в любой момент можно было извлечь любой блок. Впрочем, если все действия по извлечению блока производятся после поиска, то можно слегка , усложнить процедуру поиска и всегда сохранять указатель на предыдущий блок. Это решает проблему извлечения и можно ограничиться однонаправ­ленным списком. Беда только в том, что многие алгоритмы при объедине­нии свободных блоков извлекают их из списка в соответствии с адресом, поэтому для таких алгоритмов двунаправленный список остро необходим. Поиск в списке может вестись тремя способами: до нахождения первого подходящего (first fit) блока, до блока, размер которого ближе всего к заданному - наиболее подходящего (best fit), и, наконец, до нахождения самого большого блока, наименее подходящего (worst fit).

Использование стратегии worst fit имеет смысл разве что в сочетании с сортировкой списка по убыванию размера. Это может ускорить выделение па­мяти (всегда берется первый блок, а если он недостаточно велик, мы с чистой совестью можем сообщить, что свободной памяти нет), но создает проблемы при освобождении блоков: время вставки в отсортированный список пропорционально О (л), где п — размер списка.

Помещать блоки в отсортированный массив еще хуже — время вставки ста­новится О (л + log(fl)) и появляется ограничение на количество блоков. Ис­пользование хэш-таблиц или двоичных деревьев требует накладных расходов и усложнений программы, которые себя в итоге не оправдывают. На прак­тике стратегия worst fit используется при размещении пространства в фай­ловых системах, например в HPFS, но ни одного примера ее использования для распределения оперативной памяти автору неизвестно. Чаще всего применяют несортированный список. Для нахождения наиболее подходящего мы обязаны просматривать весь список, в то время как первый подходящий может оказаться в любом месте, и среднее время поиска будет меньше. Насколько меньше — зависит от отношения количества подходя­щих блоков к общему количеству. (Читатели, знакомые с теорией вероятно­сти, могут самостоятельно вычислить эту зависимость.) В общем случае best fit увеличивает фрагментацию памяти. Действительно, если мы нашли блок с размером больше заданного, мы должны отделить "хвост" и пометить его как новый свободный блок. Понятно, что в случае best fit средний размер этого "хвоста" будет маленьким, и мы в итоге полу­чим большое количество мелких блоков, которые невозможно объединить, так как пространство между ними занято.

В тех ситуациях, когда мы размещаем блоки нескольких фиксированных размеров, этот недостаток роли не играет и стратегия best fit может оказать­ся оправданной. Однако библиотеки распределения памяти рассчитывают на общий случай, и в них обычно используются алгоритмы first fit.

При использовании first fit с линейным двунаправленным списком воз­никает специфическая проблема. Если каждый раз просматривать спи­сок с одного и того же места, то большие блоки, расположенные ближе к началу, будут чаще удаляться. Соответственно, мелкие блоки будут скапливаться в начале списка, что увеличит среднее время поиска. Простой способ борьбы с этим явлением состоит в том, чтобы просматривать список то в одном направлении, то в другом. Более ради­кальный и еще более простой метод заключается в следующем: список делается кольцевым, и каждый поиск начинается с того места, где мы остановились в прошлый раз. В это же место добавляются освободив­шиеся блоки. В результате список очень эффективно перемешивается и никакой "антисортировки" не возникает.

Разработчик программы динамического распределения памяти обязан ре­шить еще одну важную проблему, а именно — объединение свободных бло­ков. Действительно, обидно, если мы имеем сто свободных блоков по одному килобайту и не можем сделать из них один блок в 100 килобайт. Но если все эти блоки расположены в памяти один за другим, а мы не можем их при этом объединить — это просто унизительно.

Кроме того, если мы умеем объединять блоки, и видим, что объединение блок ограничен сверху значением brkievel, то мы можем, вместо помещения этого блока в список, просто уменьшить значение brkievel и, таким образом, вернуть ненужную память системе.

Представим себе для начала, что все, что мы знаем о блоке, — это его начальный адрес и размер. Легко понять, что это очень плохая ситуация. Действительно, для объединения блока с соседями мы должны найти их в списке свободных, или же убедиться, что там их нет. Для этого мы должны просмотреть весь список. Как одну из идей мозгового штурма можно выдвинуть предложение сортировать список свободных блоков по адресу.

Гораздо проще запоминать в дескрипторе блока указатели на дескрипторы соседних блоков. Немного развив эту идею, мы приходим к методу, который называется алгоритмом парных меток и состоит в том, мы добавляем к каждому блоку по два слова памяти. Именно слова не байта. Дело в том, что требуется добавить достаточно места, что хранить в нем размер блока в байтах или словах. Обычно такое число занимает столько же места, сколько и адрес, а размер слова обычно равен размеру адреса. На х86 в реальном режиме это не так, но это вообще довольно странный процессор.

Итак, мы добавляем к блоку два слова — одно перед ним, другое после него. В оба слова мы записываем размер блока. Получается своеобразный дескриптор, который окружает блок. При этом мы устанавливаем, что значение длин будут положительными, если блок свободен, и отрицательными, е блок занят. Можно сказать и наоборот, важно только потом соблюдать соглашение.

Представим, что мы освобождаем блок с адресом addr. Считаем, addr имеет тип word *, и при добавлении к нему целых чисел результирующий адрес будет отсчитываться в словах, как в языке С. Для то чтобы проверить, свободен ли сосед перед ним, мы должны посмотреть слово с адресом addr - 2. Если оно отрицательно, то сосед занят, и мы должны оставить его в покое. Если же оно положительно, то мы можем легко определить адрес начала этого блока как addr - addr [-2].

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

Похожим образом присоединяется и сосед, стоящий после него. Единствен­ное отличие состоит в том, что этого соседа все-таки нужно извлекать из списка свободных блоков.

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

Итак, наилучшим из известных универсальных алгоритмов динамического распределения памяти является алгоритм парных меток с объединением свободных блоков в двунаправленный кольцевой список и поиском по принципу first fit. Этот алгоритм обеспечивает приемлемую производитель­ность почти для всех стратегий распределения памяти, используемых в при­кладных программах.

Алгоритм парных меток был предложен Дональдом Кнутом в начале 60-х. В третьем издании классической книги [Кнут, 2000], этот алгоритм приво­дится под названием "освобождения с дескрипторами границ". В современ­ных системах используются и более сложные структуры дескрипторов, но всегда ставится задача обеспечить поиск соседей блока по адресному про­странству за фиксированное время. В этом смысле, практически все современные подпрограммы динамического выделения памяти (в частности, реализации стандартной библиотеки языка С) используют аналоги алгоритма парных меток. Другие известные подходы либо просто хуже, чем этот, либо проявляют свои преимущества только в специальных случаях.

реализация malloc в библиотеке GNU LibC (реализация стандартной библиотеки языка С в рамках freeware проекта GNU Not Unix) (пример 4.3) использует смешанную стратегию: блоки размером более 4096 байт выделяются стратегией first fit из двусвязного кольцевого списка с использованием циклического просмотра, а освобождаются при помощи метода, который в указанном ранее смысле похож на алгоритм парных меток. Все выделяемые таким образом блоки будут иметь размер, кратный 4096 байтам. Блоки меньшего размера объединяются в очереди с размерами, пропорцио­нальными степеням двойки, как в описанном далее алгоритме близнецов. Элементы этих очередей называются фрагментами. В отличие от алгоритма близнецов, мы не объединяем при освобождении парные фраг­менты. Вместо этого, мы разбиваем 4-килобайтовый блок на фрагменты одинакового размера. Если, например, наша программа сделает запросы на 514 и 296 байт памяти, ей будут переданы фрагменты в 1024 и 512 байт со­ответственно. Под эти фрагменты будут выделены полные блоки в 4 кило­байта, и внутри них будет выделено по одному фрагменту. При последую­щих запросах на фрагменты такого же размера будут использоваться свободные фрагменты этих блоков. Пока хотя бы один фрагмент блока за­нят, весь блок считается занятым. Когда же освобождается последний фраг­мент, блок возвращается в пул.

Описатели блоков хранятся не вместе с самими блоками, а в отдельном ди­намическом массиве _heapinfо. Описатель заводится не на непрерывную последовательность свободных байтов, а на каждые 4096 байт памяти (в примере 4.3 именно это значение принимает константа blocksize). Бла­годаря этому мы можем вычислить индекс описателя в _heapinfо, просто разделив на 4096 смещение освобождаемого блока от начала пула. Для нефрагментированных блоков описатель хранит состояние (занят-свободен) и размер непрерывного участка, к которому принадлежит блок. Благодаря этому, как и в алгоритме парных меток, мы легко можем найти соседей освобождаемого участка памяти и объединить их в большой непре­рывный участок.

Для фрагментированных блоков описатель хранит размер фрагмента, счет­чик занятых фрагментов и список свободных. Кроме того, все свободные Фрагменты одного размера объединены в общий список — заголовки этих списков собраны в массив _fraghead.

Используемая структура данных занимает больше места, чем применяемая в Классическом алгоритме парных меток, но сокращает объем списка свобод­ах блоков и поэтому имеет более высокую производительность. Средний о6ъем блока, выделяемого современными программами для ОС общего назначения, измеряется многими килобайтами, поэтому в большинстве случаев повышение накладных расходов памяти оказывается терпимо.

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

Проще всего решить эту задачу, если нам требуются блоки нескольких фик­сированных размеров. Мы объединяем блоки каждого размера в свой список. Если в списке блоков требуемого размера ничего нет, мы смотрим в список блоков большего размера. Если там что-то есть, мы разре­заем этот блок на части, одну отдаем запрашивающей программе, а вторую... Правда, если размеры требуемых блоков не кратны друг другу, что мы будем делать с остатком?

Для решения этой проблемы нам необходимо ввести какое-либо ограниче­ние на размеры выделяемых блоков. Например, можно потребовать, чтобы эти размеры равнялись числам Фибоначчи (последовательность целых чисел, в которой Fi+1 = Ft + Fi-1). В этом случае, если нам нужно Ft байт, а в наличии есть только блок размера Fi+1, мы легко можем получить два блока - один требуемого размера, а другой — Fi-1 который тоже не пропадет. Да, любое ограничение на размер приведет к внутренней фрагментации, но так ли велика эта плата за гарантированное время поиска блока?

На практике, числа Фибоначчи не используются. Одной из причин, по-видимому, является относительная сложность вычисления такого Ft , которое меньше требуемого размера блока. Другая причина — сложность объединения свободных блоков со смежными адресами в блок большего размера. Зато широкое применение нашел алгоритм, который ограничивает последовательные размеры блоков более простой зависимостью — степенями числа 2: 512 байт, 1 Кбайт, 2 Кбайт и т. д. Такая стратегия называется алгоритмом близнецов.

Одно из преимуществ этого метода состоит в простоте объединения блоков при их освобождении. Адрес блока-близнеца получается простым инвертированием соответствующего бита в адресе нашего блока. Нужно только проверить, свободен ли этот близнец. Если он свободен, то мы объединяем братьев в блок вдвое большего размера, и т. д. Даже в наихудшем случае время поиска не превышает О (log(Smax)-log(Smjn)), где Sтах и Smin обозначают, соответственно, максимальный и минимальный размеры используемых блоков. Это делает алгоритм близнецов трудно заменимым для ситуаций, в которых необходимо гарантированное время реакции — например, для задач реального времени. Часто этот алгоритм или его варианты использу­ется для выделения памяти внутри ядра ОС.

Существуют и более сложные варианты применения описанного выше под­хода. Например, пул свободной памяти Novell Netware состоит из 4 очере­дей с шагом 16 байт (для блоков размерами 16, 32, 48, 64 байта), 3 очередей с шагом 64 байта (для блоков размерами 128, 192, 256 байт) и пятнадцати очередей с шагом 256 байт (от 512 байт до 4 Кбайт). При запросах большего размера выделяется целиком страница. Любопытно, что возможности рабо­ты в режиме реального времени, присущие этой изощренной стратегии, в Netware практически не используются.

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

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