учебники, программирование, основы, введение в,

 

Управление памятью

Что происходит с объектами
ОО-программа создает объекты. Предыдущая лекция показала, как полезно полагаться на динамическое создание для получения гибких объектных структур, подстраивающихся автоматически к нуждам системы.
Создание объектов
Мы рассмотрели базовые операции размещения новых объектов. Простейший способ размещения записывается как
create x
и его эффект был определен триадой: создать новый объект; связать его со ссылкой x; и инициализировать его поля.
Вариант этой инструкции вызывает процедуру инициализации; можно также создать новый объект с помощью подпрограмм clone и deep_clone. Так как все эти формы размещения основаны на одной и той же базисной инструкции создания, можно без потери общности ограничиться рассмотрением create x .
Рассмотрим эффект, создаваемый инструкциями управления памятью.
Три режима управления объектами
Во-первых, будет полезным расширить рамки дискуссии. Форма управления объектами, используемая для ОО-вычислений, может поддерживаться одним из трех обычно встречаемых режимов: статическим, стековым и динамически распределяемым. Выбор режима определяет, как сущности присоединяются к объектам.


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

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

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

Второй режим размещения объектов - режим стека. Здесь сущность может быть в реальном времени последовательно присоединяться к нескольким объектам. Механизм выполнения размещает и удаляет эти объекты в порядке "последним пришел, первым ушел". Когда объект удаляется, относящаяся к нему сущность присоединяется вновь к объекту, с которым она была связана до появления нового элемента, если, конечно, такой объект существует.
Основанное на стеке управление объектами сделало популярным Algol 60 и с тех пор поддерживается (часто вместе с другими двумя режимами) в большинстве языков. Такой способ поддерживает рекурсию и динамические массивы, границы которых выясняются в процессе выполнения. В Pascal и C этот механизм не применяется к массивам, как это делается в Algol. Однако разработчикам хотелось бы чаще всего именно массивы распределять таким способом. Тем не менее, даже если этот механизм и может быть применен к массивам, размещение в стеке большинства сложных структур данных невозможно.
Для сложных структур данных нам нужен третий и последний режим: динамическая память, называемая также "кучей", из-за способа ее использования. Это память, в которой объекты создаются динамически по запросу. Сущности могут динамически присоединяться к разным объектам. Во время компиляции обычно нельзя предсказать, какие объекты будут созданы и присоединены к сущности. Кроме того, объекты могут содержать ссылки на другие объекты.
Динамическая память позволяет создавать сложные динамические структуры данных, необходимые когда, как обсуждалось в предыдущей лекции, ПО требуется вся мощь методов моделирования.
Использование динамического режима
Динамический режим, очевидно, наиболее общий, и он необходим для ОО-программирования. Его используют многие не ОО-языки. В частности:

  • Pascal использует статический режим для массивов, режим, основанный на стеке, для переменных, не являющихся массивами и указателями, динамический режим для указателей. В последнем случае создание объекта выполняется с помощью вызова специальной процедуры создания new.
  • Язык C похож на Pascal, но дополнительно вводит динамические массивы и статические переменные, не являющиеся массивами, Язык С динамически размещает переменные типа указатель и массивы, используя библиотечную функцию malloc.
  • PL/I поддерживает все модели.
  • Lisp системы традиционно были высоко динамичны и полагались большей частью на динамический режим распределения памяти. Одна из наиболее важных операций Lisp, используемая многократно для представления списков, - CONS, создает структуру из двух полей. В первом поле хранится значение элемента, а во втором - указатель на следующий элемент. Здесь CONS, скорее источник новых объектов, чем инструкция их создания.

Повторное использование памяти в трех режимах
Для объектов, созданных как в основанном на стеке режиме, так и в динамическом режиме, возникает вопрос, что делать с неиспользуемыми объектами? Возможно ли память, занятую таким объектом, повторно использовать в более поздних инструкциях создания новых объектов?
В статической модели проблемы не существует: для каждого объекта есть одна навсегда присоединенная сущность. Выполнение требует поддерживать связь с объектом все время, пока сущность активна. Поэтому повторное использование памяти невозможно в настоящей трактовке этого понятия. Однако при острой нехватке памяти похожая технология иногда используется. Если вы уверены, что объекты, присоединенные к двум сущностям, никогда не нужны одновременно, и эти сущности не должны сохранять свои значения между последовательными использованиями, то можно на одной и той же памяти размещать две или более сущности, будучи совершенно уверены в безопасности того, что вы делаете. Эта техника, известная как перекрытие (overlay), достаточно ужасная, все еще практикуется при работе вручную.


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

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


Таблица 9.1. Размещение и удаление объектов в языках с блочной структурой

Динамическое свойство (событие времени выполнения)

Статическое свойство (положение в тексте программы)

Техника реализации

Размещение объекта

Начало блока

Вталкивание объектов (один для каждой локальной сущности блока) в стек

Удаление объекта

Конец блока

Выталкивание объектов из стека

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


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

Основные причины отсоединения следующие. Предположим, x и y сущности ссылочного типа вначале присоединены к объектам O1 и O2. Рисунок иллюстрирует случаи D1 и D2.

  • (D1) Присваивание вида x := Void, или x := v где v типа void, отсоединяет x от O1.
  • (D2) Присваивание вида y := z, где z не присоединен к объекту O2, отсоединяет y от O2.
  • (D3) Завершение подпрограммы отсоединяет формальные аргументы от присоединенных к ним объектов.
  • (D4) Инструкция создания create x , присоединяет x к вновь созданному объекту и, следовательно, отсоединяет x, если он ранее был присоединен к объекту O1.

Случай D3 соответствует ранее данному правилу: инициализация формального аргумента a подпрограммы r во время вызова t.r(..., b, ...), где позиция b в вызове соответствует позиции a в объявлении r, в точности соответствует семантике присваивания a := b.
Недостижимые объекты
Значит ли отсоединение объектов, например O1 или O2 , что они становятся бесполезными и, следовательно, механизмы периода исполнения могут освободить занимаемое ими место в памяти? Это было бы слишком просто! Сущность, для которой объект был первоначально создан, могла уже потерять интерес к объекту, но из-за динамических псевдонимов другие ссылки могут быть все еще подсоединены к нему. Например,возможно отражает лишь частное видение связей между объектами. Рассматривая более широкий контекст, можно обнаружить, что O1 и O2 все еще достижимы для других объектов.
Но и эта картина все еще не дает полного видения структуры всех связей между объектами. Расширяя контекст, можно, например, выяснить, что O4 и O5 сами не нужны, так что в отсутствии других ссылок, O1 и O2 не нужны тоже.
Таким образом, ответ на вопрос: "Какие объекты можно удалить?" должен следовать из глобального анализа множества всех созданных объектов. Выделим три типа объектов:

  • (C1) Объекты, напрямую присоединенные к сущностям, известны (из правил языка программирования) как необходимые.
  • (C2) Зависимые от объектов категории C1. (Напомним, наряду с непосредственно зависимыми объектами, имеющими ссылки на объекты C1, зависимые объекты могут рекурсивно иметь ссылки на непосредственно зависимые объекты.) Здесь рассматривается прямая и косвенная зависимость.
  • C3 Объекты, не относящиеся к предыдущим двум категориям.

Объекты первой категории могут называться оригиналами (origins). Вместе с объектами категории С2 они составляют множество достижимых (reachable) объектов. Объекты категории С3 недостижимы (unreachable). Они ранее неформально назывались ненужными или бесполезными. В другой более мрачной терминологии используются термины "мертвые объекты" для категории С3 и "живые" для первых двух. (У программистов принята более прозаическая терминология, и процесс удаления мертвых объектов, изучаемый ниже, называется просто сборкой мусора.)


Для объектов наряду с термином "оригинал" используется термин "корень". Первый термин предпочтительнее, поскольку сама ОО-система имеет "корневой объект" и "корневой класс". Однако результат возможной двусмысленности не сильно вредит делу, потому что корневой объект, как будет видно далее, является одним из оригиналов.

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

Достижимые объекты в классическом подходе

Поскольку проблема недостижимости рассматривается в таких классических подходах, как Pascal, C и Ada, разумно начать с этих случаев. (Читатели, незнакомые ни с одним из этих подходов, могут пропустить этот раздел и перейти к следующему, рассматривающему ОО-программирование.)
Все подходы используют стековое размещение объектов и размещение в динамической памяти. Языки C и Ada поддерживают также статическую модель, но для упрощения ее можно проигнорировать, рассматривая статику как специальный случай размещения в стеке. Можно полагать, что статические объекты размещаются в начале выполнения и находятся в конце стека. В языке Pascal они объявляются в самом внешнем блоке.
Общим свойством этих подходов является и то, что сущности могут задаваться указателями. В ОО-подходе вместо указателей используются ссылки - более абстрактное понятие (эта тема обсуждалась в предыдущей лекции). Позвольте сделать вид, что указатели есть в действительности ссылки, игнорируя слабо типизируемую природу указателей в С.
При этих допущениях и упрощениях на следующем рисунке показаны оригиналы, размещенные в стеке или присоединенные к ссылке, размещенной в стеке, достижимые и недостижимые объекты.
Проблема недостижимости возникает только для объектов, размещенных в динамической памяти. Такие объекты всегда подсоединяются к сущностям ссылочного типа. Поэтому удобно игнорировать проблему повторного использования памяти для объектов, непосредственно размещенных в стеке. Она может быть решена просто при помощи освобождения стека при окончании блока. Начнем рассмотрение со ссылок, размещенных в стеке. Мы можем назвать их ссылками оригиналами (reference origins). Они изображены толстыми стрелками на рисунке и представляют:

  • (O1) Значение локальных сущностей или аргументов функции ссылочного типа (как две верхних начальных ссылки на рисунке).
  • (O2) Поля ссылочного типа объектов, расположенных в стеке (ниже лежащая ссылка на рисунке).

Рассмотрим пример объявления типа и процедуры, написанный на смеси Pascal и нотации, используемой в этой книге ( reference G - ссылка, которая может быть подсоединена к объекту типа G):
type
COMPOSITE =
record
m:INTEGER
r:reference COMPOSITE
end
...
procedure p is
local
n: INTEGER
c: COMPOSITE
s: reference COMPOSITE
do
...
end
При каждом вызове процедуры p три значения вталкиваются в стек:
Тремя новыми значениями являются: целое n, не влияющее на проблему управления объектами (оно исчезнет при завершении процедуры и не ссылается на другие объекты); ссылка s, являющаяся примером категории О1; и объект с типа COMPOSITE. Сам объект содержится в стеке и занятая объектом память может быть использована по завершении работы процедуры. Но он содержит ссылочное поле r, являющееся примером категории О2.
Итак, для определения достижимости объекта в классическом подходе, комбинирующем стековую и динамическую память, следует начать со ссылок в стеке (переменные ссылочного типа и ссылочные поля комбинированных объектов), и последовательно просмотреть все ссылочные поля присоединенных объектов, если они существуют.
Достижимые объекты в ОО-модели
ОО-структура данных, представленная в предыдущих лекциях, имеет некоторые отличия от рассмотренной выше структуры.
Работа любой системы начинается с создания объекта, называемого корневым объектом системы, или просто корнем (когда нет путаницы с корневым классом, задаваемым статически). Корень в этом случае является одним из оригиналов.
Другое множество оригиналов возникает из-за возможного присутствия локальных переменных в подпрограмме. Рассмотрим подпрограмму вида
some_routine is
local
rb1, rb2: BOOK3
eb: expanded BOOK3
do
. . .
create rb1
. . .Операции, возможно использующие rb1, rb2 и eb . . .
end
При любом вызове и выполнении подпрограммы some_routine, инструкции в теле подпрограммы могут ссылаться на rb1, rb2, eb и на присоединенные к ним объекты, если они есть. Это значит, что такие объекты должны быть частью множества достижимых объектов, но не обязательно зависимы от корня. Заметим, для eb всегда есть присоединенный объект, а rb1 и rb2 могут при некоторых запусках иметь значение void.
Локальные сущности ссылочного типа, такие как rb1 и rb2, подобны переменным подпрограммы, которые в предыдущей модели были размещены в стеке. Локальные сущности развернутого типа, как eb, подобны объектам, расположенным в стеке.
Когда завершается очередной вызов some_routine, исчезают сущности rb1, rb2 и eb текущей версии. В результате все присоединенные объекты перестают быть частью множества оригиналов. Это не значит, что они становятся недостижимыми, - они могут тем временем стать зависимыми от корня или других оригиналов.
Допустим, например, что а - это атрибут рассматриваемого класса и что полный текст подпрограммы имеет вид:
some_routine is
local
rb1, rb2: BOOK3
eb: expanded BOOK3
do
create rb1;create rb2
a := rb1
end
На следующем рисунке показаны объекты, создаваемые вызовом some_routine, и ссылки с присоединенными объектами.
Когда вызов some_routine завершается, объект О, представляющий цель вызова, все еще доступен (иначе не было бы этого вызова). Поле а этого объекта О в результате вызова присоединено к объекту B1 класса BOOK3, созданного первой инструкцией создания нашей подпрограммы. Поэтому объект B1 остается достижимым по завершении вызова. Напротив, объекты B2 и EB, которые были присоединены к rb2 и eb во время вызова, теперь становятся недостижимыми: в соответствии с текстом процедуры невозможно, чтобы какой-либо другой объект "запомнил" В2 или ЕВ.

Проблема управления памятью в ОО-модели

Подводя итог предшествующего анализа, определим оригиналы и соответственно достижимые объекты:
Определение: начальные, достижимые и недостижимые объекты
В каждый момент времени выполнения системы множество оригиналов включает:

  • Корневой объект системы.
  • Любой объект, присоединенный к локальной сущности, или формальному аргументу, выполняемой в данный момент подпрограммы (для функции включается локальная сущность Result).

Любые объекты, прямо или косвенно зависящие от оригиналов, достижимы. Любые другие объекты недостижимы. Память, занятую недостижимыми объектами можно восстановить, (например, выделить ее другим объектам) сохраняя корректность семантики выполнения программы.
Проблема управления памятью возникает из-за непредсказуемости операций, влияющих на множество достижимых объектов: создание и отсоединение.
Такое предсказание возможно в некоторых случаях для строго управляемых структур данных. Примером является библиотечный класс, задающий список, - LINKED_LIST, рассматриваемый позже, и связанный с ним класс LINKABLE, описывающий элементы этого списка. Экземпляр LINKABLE создается только с помощью специальных процедур класса LINKED_LIST и может становиться недостижимым только в результате выполнения процедуры remove, удаляющей элементы списка. Для подобных классов можно представить себе особенную процедуру управления памятью. (Такой подход будет изучен позднее в этой лекции.)
Приведенный пример, хотя и важен, но только для специальных случаев. В общем случае приходится отвечать на сложный вопрос - что делать с недостижимыми объектами?
Три ответа
С недостижимыми объектами можно поступать тремя способами:

  • Проигнорировать проблему и надеяться, что хватит памяти для размещения всех объектов: достижимых и недостижимых. Это можно назвать несерьезным (casual) подходом.
  • Предложить разработчикам включать в каждое приложение алгоритм, ищущий недостижимые объекты, и дать им механизм освобождения соответствующей памяти. Такой подход называется восстановлением вручную(manual reclamation) .
  • Включить в среду разработки (как часть исполняемой системы (runtime system)) механизм, автоматически определяющий и утилизирующий недостижимые объекты. Этот подход принято называть автоматической сборкой мусора (automatic garbage collection).

Остаток лекции посвящен этим подходам.
Несерьезный подход (тривиальный)
Первый подход заключается в игнорировании проблемы: предоставлять мертвые объекты их судьбе. Создаются объекты как обычно, но никто не волнуется о том, что может потом случиться с объектами.
Может ли быть оправдан несерьезный подход?
Несерьезный подход не создает проблем в системах, создающих небольшое число объектов, например, при проведении простых тестов и экспериментов.
Более интересен случай, когда система может создавать много объектов, гарантируя, что ни один или немногие из них станут недостижимыми. Этот случай аналогичен статической схеме размещения, в которой ни один объект не удаляется. Разница только в том, что создание происходит динамически во время выполнения программы. Несерьезный подход в этом случае оправдан, поскольку практически не возникает необходимость утилизации объектов.
Некоторые программы реального времени следуют этой схеме: по причине эффективности, создавая все необходимые объекты статично или во время инициализации, избегая непредсказуемых моделей динамического создания.
Этот метод применяется в "жестких" системах реального времени ("hard-real- time"), требующих гарантированное микросекундное время отклика на внешние события (например, системы обнаружения ракет). В таких системах время выполнения каждой операции должно быть полностью предсказуемо. Но тогда приходится отказываться не только от управления памятью, но и от динамического создания объектов, рекурсии, вызова процедур с локальными сущностями и так далее. Работа с такими системами подразумевают специализированную машину с одним исполняемым процессом, фактически без операционной системы в обычном понимании этого термина. В таких средах люди предпочитают писать на языках ассемблера, из-за страха дополнительных неожиданностей от сгенерированного компилятором кода. Все это сводит обсуждение к малой, хотя и стратегически важной области мира программ.
Надо ли заботиться о памяти?
Другой аргумент, который можно услышать в оправдание несерьезному подходу, - это постоянный рост объема доступной памяти компьютера и уменьшение цены памяти.
Используемая память может быть как виртуальной, так и реальной. В системах виртуальной памяти первичная и вторичная память делится на блоки, называемые страницами. Вновь требуемые страницы первичной памяти вытесняют во вторичную память редко используемые страницы первичной памяти. Если такая система используется для работы с ОО-системами, страницы, содержащие недостижимые объекты, будут вытесняться и освободят основную память для часто используемых объектов.
Если бы действительно в нашем распоряжении было почти безграничное количество почти свободной памяти, можно было бы удовлетвориться несерьезным подходом.
К несчастью, это не так.
Первая причина в том, что на практике виртуальная память не эквивалентна реальной. Если хранить большое количество объектов в виртуальной памяти, в которой меньшинство достижимых объектов рассыпано среди большинства недостижимых, то процесс выполнения будет постоянно вызывать перемещение страниц памяти, феномен, известный как пробуксовка (trashing), приводящий к драматическому увеличению времени выполнения. Действительно, системы виртуальной памяти усложняют эффективное разделение двух основных аспектов - пространства и времени. (См. "Эффективность",)
Но есть более важное ограничение применения несерьезного подхода. Даже большая память имеет границы. Удивительно, как быстро программисты к ним подходят. Как только мы выходим за пределы систем с небольшим числом недостижимых объектов, лицом к лицу сталкиваемся с проблемой восстановления памяти.
Байт здесь, байт там, и реальные покойники
Пора послушать печальную и поучительную историю Лондонской службы скорой помощи.
Лондонская служба скорой помощи, как говорят, самая большая в мире, обслуживает территорию около 1500 кв. км, c постоянным населением почти в семь миллионов человек и с еще большим количеством населения в дневное время. Каждый день эта служба обслуживает пять тысяч пациентов и получает от двух до трех тысяч звонков.
Как можно догадаться по мрачному заголовку, в этой работе используются компьютеры (точнее ПО). Вначале было несколько неудачных разработок, даже не введенных в действие, как несоответствующих требованиям, несмотря на то что на разработку этих систем затрачивались значительные финансовые средства. Наконец, в 1992 была введена в эксплуатацию новая система, разработанная за миллион фунтов. Скоро о ней снова заговорили. 28 и 29 октября на телевидении и в прессе сообщалось, что из-за неадекватной работы системы были потеряны двадцать жизней. Говорили, что в одном конкретном случае врачи скорой помощи по рации сообщили на базу, что прибыли на место назначения и спросили, почему владелец похоронного бюро прибыл раньше. Исполнительный директор службы ушел в отставку, была назначена следственная комиссия.
Служба скорой помощи после трагедии не сразу отказалась от компьютерной системы, а переключилась на гибридную модель - частично ручную, частично полагающуюся на систему. Согласно официальным отчетам:
Эта гибридная система действовала с переменным успехом с 27 октября 1992 г. до раннего утра 4 ноября. Однако после двух часов 4 ноября работа системы значительно замедлилась, вскоре после этого система вышла из строя совсем. Перезагрузка не смогла решить проблему. Управление и персонал вернулись к бумаге и телефонным звонкам.
Что привело систему к краху, так что ее не могли сохранить даже как дополнение к ручным операциям? Отчет определил несколько причин. Вот основная:
Следственная команда пришла к выводу, что крах системы был вызван незначительной ошибкой программного обеспечения. Программист XX ("XX" здесь и далее заменяет название компании, разрабатывающей программное обеспечение данной системы) тремя неделями раньше оставил в системе кусок программного кода, который, используя небольшой файл на сервере, записывал и не стирал информацию о выезде машины на вызов. Через три недели память переполнилась. Эта ошибка была вызвана беспечностью и отсутствием проверки качества программного кода. Такого рода неисправности вряд ли могут быть обнаружены обычным тестированием программистом или пользователем.
Читатель должен сам решить, какую программную ошибку стоит называть незначительной, особенно принимая во внимание последний комментарий о трудностях тестирования, который еще будет подробно обсуждаться ниже.
Для тех, кто все еще думает, что можно пользоваться несерьезным подходом, и для тех, кто относится к управлению памятью только как к проблеме реализации, двадцать жертв лондонской службы скорой помощи должны служить грустным напоминанием о серьезности рассматриваемой проблемы.
Восстановление памяти: проблемы
Если уйти от несерьезного подхода и его упрощающих допущений, то предстоит решить, как и когда восстанавливать память. Возникают две проблемы:

  • Обнаружение (detection). Как найти мертвые элементы?
  • Восстановление (reclamation). Как восстановить для повторного использования память, присоединенную к этим элементам?

Для каждой из этих задач можно искать решение на одном из двух возможных уровнях:

  • Реализации языка - компилятор и среда исполнения обеспечивают общую поддержку любому ПО, создаваемому на этом языке и в данной среде.
  • Приложения - приложение само решает возникающие проблемы.

В первом случае управление выделенной памятью происходит автоматически с помощью программно-аппаратных средств. Во втором случае каждый разработчик приложения должен позаботиться об этом сам.
Фактически, существует еще третий возможный уровень, нечто среднее между этими двумя, - фабрика компонентов. Функции управления памятью возлагаются на общецелевые повторно используемые классы библиотеки ОО-среды. Подобно уровню приложения, можно использовать только разрешенные конструкции языка программирования, не имея прямого доступа к аппаратуре и функциям операционной системы. Подобно уровню реализации языка, проблемы управления памятью решаются один раз и для всех приложений.
Даны две проблемы и три способа решения каждой, в итоге - девять возможных вариантов. Только четыре из них имеют практический смысл. Рассмотрим их.
Удаление объектов, управляемое программистом
Одно популярное решение - обнаружение мертвых элементов возложить на разработчика программы, а восстановление памяти решать на уровне реализации языка.
Это простейшее решение для реализаторов языка: все, что от них требуется, - это ввести в язык примитив, скажем, reclaim, такой что a.reclaim сообщает системе, что объект, присоединенный к a, не нужен, и соответствующие ячейки памяти можно освободить для новых объектов.
Это решение реализовано в не ОО-языках, таких как Pascal (dispose процедура), C (free), PL/I (FREE), Modula-2 и Ada. Оно есть в большинстве 'гибридных ОО" языков, в частности в C++.
Такое решение особенно приветствуется в мире С-программистов, любящих полностью контролировать происходящее. Обычная реакция таких программистов на тезис о том, что Objective-C может давать преимущества, благодаря автоматическому восстановлению памяти, следующая:
Я говорю, НЕТ! Оставлять недостижимые объекты - ПЛОХОЙ СТИЛЬ ПРОГРАММИРОВАНИЯ. Если вы создаете объект, вы должны отвечать за его уничтожение, если вы им не пользуетесь. Разве мама не учила вас убирать свои игрушки после игры? (Послано Яном Стефенсоном (Ian Stephenson), 11 мая1993.)
Для серьезной разработки программ эта позиция не позволительна. Хорошие разработчики должны разрешать кому-либо другому играть со своими "игрушками" по двум причинам: надежности и простоты разработки.
Проблема надежности
Допустим, разработчик управляет утилизацией объектов с помощью механизма reclaim. Возможность ошибочного вызова reclaim всегда существует; особенно при наличии сложных структур данных. В жизненном цикле ПО reclaim, бывшее когда-то правильным, может стать некорректным.
Такие ошибки приводят к проблеме висячих ссылок, - когда в одном из полей существующего объекта хранится ссылка на удаленный объект. Если система, после того как область памяти, занимаемая этим объектом, была утилизирована и использована для хранения другой информации, попытается использовать ссылку, то результатом будет крах программы или (еще хуже) ее ошибочное или неуправляемое поведение.
Этот тип ошибки известен, как источник появления самых частых и неприятных жучков в практике языка С и производных языков. Программисты боятся таких жучков из-за трудности обнаружения их источника. Если программист не заметил, что определенная ссылка еще присоединена к объекту и как результат - ошибочно выполняет reclaim, то это часто происходит из-за того, что ссылка находится в другой части программы. Если так, то должна быть большая физическая и концептуальная дистанция между ошибкой - вызовом reclaim и ее проявлением - крах или другое ненормальное поведение из-за попытки применения некорректной ссылки. Проявиться ошибка может значительно позже и, по-видимому, совсем в другой части программы. К тому же, ошибка может быть плохо воспроизводимой, поскольку распределение памяти операционной системой не всегда происходит одинаково и может зависеть от внешних по отношению к программе факторов.
Сказать, что причиной этих ошибок является "плохой стиль программирования", как в письме, упомянутом выше, это не сказать ничего. Человеку свойственно ошибаться; ошибки при программировании неизбежны. Даже в приложениях средней сложности, нет разработчиков, которым можно доверять, нельзя доверять самому себе в способности проследить за всеми объектами периода выполнения. Это работа не для человека, с ней может справиться только компьютер.
Многие из С или С++ программистов ночи проводят, пытаясь понять, что произошло с одной из их игрушек. Нередко, что проект задерживается из-за загадочных ошибок при работе с памятью.
Проблема простоты разработки
Даже если можно было бы избежать ошибочных вызовов reclaim, остается вопрос - сколь реально просить разработчиков управлять удалением объектов? Загвоздка в том, что даже при обнаружении объекта, подлежащего утилизации, обычно просто удалить его недостаточно, он может сам содержать ссылки на другие объекты и нужно решить, что с ними делать.
Рассмотрим структуру, показанную на, ту же, что использовалась в предыдущей лекции для описания динамической природы объектных структур. Допустим, выяснилось, что можно утилизировать самый верхний объект. Тогда в отсутствии каких-либо других ссылок можно удалить и другие два объекта, на один из которых он ссылается прямо, а на другой - косвенно. Не только можно, но и нужно: разве хорошо удалять только часть структуры? В терминологии Pascal это иногда называется рекурсивной проблемой удаления: если операции утилизации имеют смысл, они должны быть рекурсивно применены ко всей структуре данных, а не только к одному индивидуальному объекту. Но конечно, необходимо быть уверенным, что на объекты удаляемой структуры нет ссылок из внешних объектов. Это трудная и чреватая ошибками задача.
На этом рисунке все объекты одного типа PERSON1. Предположим, что сущность x присоединена к объекту О типа MY_TYPE , объявленным как класс:
class MY_TYPE feature
attr1: TYPE_1
attr2: TYPE_2
end
Каждый объект типа MY_TYPE, такой как О, содержит две ссылки, которые (кроме void) присоединены к объектам типа TYPE_1 и TYPE_2. Утилизация О может предполагать, что эти два объекта тоже должны быть утилизированы, также как и зависимые от них объекты. Выполнение рекурсивной утилизации, в этом случае, предполагает написание множества процедур утилизации, - по одной для каждого типа объектов, которые, в свою очередь, могут содержать ссылки на другие объекты. Результатом будет множество взаимно рекурсивных процедур большой сложности.
Все это ведет к катастрофе. Нередко, в языках, не поддерживающих автоматическую сборку мусора, в приложения включаются специально разработанные системы управления памятью. Такая ситуация неприемлема. Разработчик приложения должен иметь возможность сконцентрироваться на своей работе, а не стать счетоводом или сборщиком мусора.
Возрастающая сложность программы из-за ручного управления памятью приводит к падению качества. В частности, она затрудняет читаемость и такие свойства как простота обнаружения ошибок и легкость модификации. В результате, к сложности конструкции добавляется проблема надежности. Чем сложнее система, тем больше вероятность содержания ошибок. Дамоклов меч ошибочного вызова reclaim всегда висит над головой и, скорее всего, упадет в наихудшее время: когда система пройдет тестирование и начнет использоваться, создавая большие и замысловатые структуры объектов.
Вывод очевиден. Кроме жестко контролируемых ситуаций (рассмотренных в следующем разделе), ручное управление памятью не подходит для разработки серьезных систем, как минимум, по соображениям качества.
Подход на уровне компонентов
(Этот раздел описывает решение, полезное только для специального случая; его можно пропустить при первом чтении книги.)
Перед тем как перейти к амбициозным схемам, таким как автоматическая сборка мусора, стоит посмотреть на решение, которое может быть альтернативой предыдущему, исправляя некоторые его недостатки.
Это решение применимо только для ОО-программирования "снизу-вверх", где структуры данных создаются не для нужд конкретной программы, а строятся как повторно используемые классы.
Что предлагает ОО-подход по отношению к управлению памятью? Одна из новинок скорее организационная, чем техническая: в этом подходе большое внимание уделяется повторному использованию библиотек. Между разработчиками приложения и создателями системных средств - компилятора и среды разработки - стоит третья группа людей, отвечающих за написание повторно используемых компонентов, реализующих основные структуры данных. Членов третьей группы, которые, конечно могут иногда выступать и в двух других ипостасях, принято называть производителями компонентов (component manufacturers).
Производители компонентов имеют полный контроль над любым использованием данного класса и потому находятся в лучшем положении при поиске приемлемого решения проблемы управления памятью для всех экземпляров этого класса.
Если модель размещения и удаления объектов класса достаточно проста, разработчики компонентов могут найти эффективное решение, возможно, даже не требующее специальной подпрограммы reclaim. Они могут выразить все в терминах понятий высокого уровня. Это и называется подходом на уровне компонентов.
Управление памятью связного списка
Приведем пример подхода на уровне компонентов. Рассмотрим класс LINKED_LIST, описывающий список, состоящий из заголовка (header) и набора связанных ячеек, являющихся экземплярами класса LINKABLE. Модель размещения и удаления для связного списка проста. Объектами рассмотрения являются связанные ячейки. В этом примере производители компонентов (люди, отвечающие за классы LINKED_LIST и LINKABLE) знают точно, как создаются и как становятся "мертвыми" экземпляры класса LINKABLE - процедурами вставки и удаления. Поэтому они могут управлять соответствующей памятью особенным способом.
Допустим, класс LINKED_LIST имеет только две процедуры вставки: put_right и put_left, вставляющие новый элемент справа или слева от текущей позиции курсора. Каждой процедуре вставки необходимо создать ровно один новый LINKABLE объект. Типичная реализация приведена ниже:
put_right (v: ELEMENT_TYPE) is
- Вставка элемента со значением v правее позиции курсора.
require
...
local
new: LINKABLE
do
create new.make (v)
active.put_linkable_right (new)
... Инструкции по изменению других связей...
end
Инструкция создания create new.make (v) дает указание уровню реализации языка разместить в памяти новый объект.
Точно так же, как мы управляем тем, где создавать объекты, мы точно знаем, где они становятся недостижимыми, - в процедурах удаления. Пусть в нашем классе три такие процедуры: remove, remove_right, remove_left. Могут быть также и другие процедуры, такие как remove_all_occurrences (которая удаляет все экземпляры с определенным значением) и wipe_out (удаляет все элементы списка). Допустим, что если они присутствуют, то используют первые три процедуры удаления. Процедура remove, например, может иметь следующую форму:
remove is
- удаляет элемент текущей позиции курсора.
do
...
previous.put_linkable_right (next)
... Инструкции по изменению других связей...
active := next
end
Эти процедуры удаления представляют точный контекст обнаружения недостижимых объектов и, при желании, предоставят эти объекты для последующего использования. В отсутствие какой-либо автоматической схемы освобождения памяти разработчик компонентов может безопасно резервировать освобождающуюся память. Если предыдущее удаление создало недостижимые LINKABLE объекты и разместило их где-то для последующего использования, то можно их использовать, когда вставка требует создания новых элементов.
Предположим, экземпляры LINKABLE хранятся в структуре данных, называемой available. Она будет представлена ниже. Тогда можно заменить инструкции создания типа create new.make (v) в put_right и put_left на
new := fresh (v)
где fresh закрытая функция класса LINKED_LIST, возвращающая готовый к использованию экземпляр linkable. Функция fresh пытается получить память из available списка, и выполнит создание нового элемента только, когда этот список пуст.
Элементы будут попадать в available в процедурах удаления. Например, тело процедуры remove теперь должно быть:
do
recycle (active)
- остальное без изменений:
... Инструкции по обновлению связей: previous, next, first_element, active...
где recycle новая процедура LINKED_LIST играет роль, противоположную fresh, добавляя свой аргумент в available. Эта процедура будет закрытой, она нужна только для внутреннего использования.

 
На главную | Содержание | < Назад....Вперёд >
С вопросами и предложениями можно обращаться по nicivas@bk.ru. 2013 г.Яндекс.Метрика