Безопасное управление памятью

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

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

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

Переполнение буфера

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

Эта проблема широко распространена в программах на C и C++ и зачастую связана с отсутствием проверок выхода за пределы массивов в этих языках. Мы встречались с подобной проблемой в главе «Безопасные типы данных» в примере с парой игральных костей.

Эта проблема не может возникнуть в Аде, поскольку в обычных условиях проверка индекса при обращении к массиву активирована. Эту проверку можно отключить, когда мы абсолютно уверены в поведении программы, но это может быть неблагоразумно, пока мы не доказали корректность программы формальным методом, например, используя инструментарий SPARK Examiner, который мы обсудим в главе 11.

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

Другой распространенной причиной разрушения данных является использование некорректных значений указателей. Указатели в C трактуются как адреса и для них разрешены арифметические операции. Как следствие, легко может возникнуть ситуация, когда значение указателя вычислено неверно. Запись по этому указателю разрушит какие‐то данные.

В главе «Безопасные указатели» мы видели, что строгая типизация указателей и правила контроля доступности в Аде защищают нас от подобных ошибок, гарантируя, что объявленный объект не исчезнет, пока на него ссылаются другие объекты.

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

Динамическое распределение памяти

Обычно языки программирования предоставляют три способа распределения памяти:

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

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

package Calandar_Data is

   type Month is (Jan, Feb, Mar, ..., Nov, Dec);

   Days_In_Month: array (Month) of Integer :=
      (Jan => 31, Feb => 28, Mar => 31, Apr => 30,
       May => 31, Jun => 30, Jul => 31, Aug => 31,
       Sep => 30, Oct => 31, Nov => 30, Dec =>31);
end;

Память, выделяемая под Days_In_Month, будет, естественно, выделена в фиксированной глобальной области.

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

Вернемся к функции Nfv_2000 из примера вычисления процентных ставок:

procedure Nfv_2000 (X: Float) is
   Factor: constant Float := 1.0 + X/100.0;
begin
   return 1000.0 * Factor**2 + 500.0 * Factor 2000.00;
end Nfv_2000;

Объект Factor обычно распределяется в стеке. Он создается при вызове функции и уничтожается при возврате. Все управление памятью происходит автоматически, благодаря механизму вызова/возврата подпрограмм. Отметим, что, хотя Factor является константой, он не является статическим объектом, потому что каждый вызов функции вычисляет для него свое значение. Так как две задачи могут вызвать эту функцию одновременно, Factor нельзя распределить статически. Аналогично, параметр X также распределяется в стеке.

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

function Rev (A: Vector) return Vector is
   Result: Vector(A'Range);
begin
   for K in A'Range loop
      Result (K) := A(A'First+A'Last-K);
   end loop;
   return Result;
end Rev;

где Vector объявлен, как неограниченный массив:

type Vector is array (Natural range <>) of Float;

Как объясняется в разделе «Массивы и ограничения» главы «Безопасные типы данных», эта запись означает, что Vector — это массив, но границы у разных объектов этого типа могут быть разные. Когда мы объявляем объект этого типа, мы должны предоставить границы. У нас может быть:

L: Integer := ...;    -- L может не быть статическим значением

My_Vector, Your_Vector: Vector (1 .. L);

...

Your_Vector := Rev (My_Vector);

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

Хотя такое поведение не требуется согласно стандарту, все промышленные компиляторы всегда используют стек для хранения локальных данных. Эффективной техникой в этом случае является использование двух стеков, один для хранения адресов возврата и локальных данных фиксированного размера, а другой для данных переменного размера. Это позволит обрабатывать данные фиксированного размера столь же эффективно, но сохранит требуемую гибкость в распределении памяти. Кроме того, в Аде часто применяется контроль за исчерпанием стека. В этом случает при попытке превысить отведенный размер стека будет возбуждаться исключение Storage_Error.

Данный пример красиво реализуется в Аде. Реализация на C контрастирует своей сложностью ввиду того, что в C нет соответствующей абстракции массивов. Мы можем передать массив, как аргумент, но только при помощи указателя на массив. Кроме того, в C нельзя вернуть массив, как объект. Хотя мы можем определить функцию, которая переставляет элементы прямо в массиве, и требовать от пользователя создавать копию перед ее вызовом. При этом нужно быть осторожным, чтобы не испортить данные при перестановке. Проще будет разрешить пользователю передавать как указатель на аргумент, так и указатель на результат. Следующее затруднение состоит в том, что в C мы не можем определить размер массива. Нам придется размер передавать явно. Мы получаем еще один шанс допустить ошибку, передав значение, не соответствующее длине массива. В итоге мы получим

void rev(float* a, float* result, int length)

{

   for (k=0;k<length;k++)
      result[k]=a[length-k-1];
}

...

float my_vector[100], your_vector[100];

...

rev(my_vector, your_vector, 100);

Хотя эта глава посвящена управлению памятью, наверное стоит остановиться, чтобы перечислить риски и затруднения в этом коде на C.

  • Массивы в C всегда индексируются, начиная с 0. Если прикладная область использует другую нумерацию, например с 1, может возникнуть путаница. В Аде нижняя граница присутствует всегда в явном виде.
  • Длина массива должна передаваться отдельно, что создает риск получить неверную длину, либо перепутать длину и верхнюю границу массива. В Аде атрибуты массива неотделимы от массива.
  • Адрес результата необходимо передавать отдельно. Появляется возможность перепутать два массива.
  • Цикл для итерации по массиву нужно записывать явно, в то время как в Аде можно воспользоваться атрибутом 'Range.

Но мы отклонились от темы. Ключевой момент в том, что, если бы мы объявили локальный массив в C++, чей размер не задан статически:

void f(int n, ...)
{
   float a[]=new float[n];

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

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

Пулы памяти

Давайте рассмотрим динамическое распределение памяти. Для этого в Аде используются пулы памяти. Если мы создаем объект динамически, как например в процедуре Push из главы «Безопасное создание объектов»:

procedure Push(S: in out Stack; X: in Float) is
begin
   S := new Cell'(S, X);
end Push;

то память под новый Cell распределяется из пула памяти. Всегда существует стандартный пул памяти, но мы можем объявить и управлять своими собственными пулами памяти.

Первым языком программирования, избавившим программиста от необходимости управлением памятью, был LISP, благодаря использованию механизма сборки мусора. Этот механизм используется и в других языках, в том числе в Java и Python. Наличие сборщика мусора значительно упрощает программирование, но имеет свои проблемы. Например, сборщик мусора может приостанавливать исполнение программы непредсказуемым образом, что может привести к проблемам в системах реального времени. Программируя системы реального времени, необходимо тщательно контролировать распределение памяти, а также гарантировать время отклика программы, что может быть затруднительно при использовании сборщика мусора. Одной из причин, по которой в первый стандарт языка Ада 83 не включали средства ООП, было то, что автор языка, Жан Ишбиа, занимавшийся в свое время реализацией ООП языка Simula, был уверен в том, что ООП необходимо иметь сборщик мусора, а это неприемлемо для систем реального времени. Как впоследствии было продемонстрировано в C++ и Ада 95, язык может поддерживать ООП без сборщика мусора, если он предоставляет программисту развитые механизмы управления памятью.

Ада позволяет программисту выбрать один из следующих механизмов управления памятью:

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

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

type Cell;

type Cell_Ptr is access all Cell;

type Cell is record
   Next: Cell_Ptr;
   Value: Float;
end record;

Обратите внимание, как здесь использовано неполное объявление типа, чтобы разорвать циклическую зависимость между типами. Теперь напишем:

procedure Free is new Unchecked_Deallocation (Cell, Cell_Ptr);

Чтобы удалить память, занимаемую объектом Cell, нужно вызвать процедуру Free и передать ей ссылку на удаляемый объект. Например, процедура Pop должна выглядеть так:

procedure Pop (S: in out Stack; X: out Float) is
   Old_S : Stack := S;
begin
   X := X.Value;
   S := S.Next;
   Free (Old_S);
end Pop;

Здесь мы используем Stack из примера с limited private, а не контролируемый тип.

Может показаться, что мы рискуем появлением висящих ссылок, поскольку могут быть другие ссылки, указывающие на удаленный объект. Но, в этом примере, с точки зрения пользователя, тип Stack лимитированный, следовательно ,пользователь не может сделать копию. Кроме того, пользователь не видит типов Cell и Cell_Ptr, поэтому не сможет вызвать Free. Это нам гарантирует корректность Pop. И, наконец, при настройке Unchecked_Deallocation используется тип Cell_Ptr, что позволяет проверить тип аргумента при вызове Free.

Нам необходимо изменить и процедуру Clear. Простейший вариант такой:

procedure Clear (S: in out Stack) is
   Junk: Float;
begin
   while S /= null loop
      Pop (S, Junk);
   end loop;
end Clear;

Хотя эта техника позволяет гарантировать, что память очищается при вызове Pop и Clear, существует риск, что пользователь объявит стек и выйдет из его области видимости пока стек не пуст. Например

procedure Do_Something is
   A_Stack: Stack;
begin
   ... -- Используем стек
   ... -- Пуст ли стек при выходе?
end Do_Something;

Если стек не был пуст при выходе, то память будет потеряна. Мы не можем обременять пользователя заботой о таких деталях, поэтому мы должны сделать тип контролируемым, как было продемонстрировано в конце главы «Безопасное создание объектов». Мы переопределим процедуру Finalize так:

overriding procedure Finalize (S: in out Stack) is
begin
   Clear (S);
end Finalize;

Использовав индикатор overriding, мы заставляем компилятор проверить, что мы не ошиблись в написании Finalize или в формальных параметрах.

В Аде также есть возможность объявить свои пулы памяти. Это просто, но потребует слишком много места для описания всех подробностей здесь. Основная идея в том, что есть тип Root_Storage_Pool (это лимитированный контролируемый тип) и мы объявляем свой тип, наследуя от него

type My_Pool_Type (Size: Storage_Count) is
  new Root_Storage_Pool with private;

overriding procedure Allocate(...);

overriding procedure Deallocate(...);

-- также переопределим Initialize и Finalize

Процедура Allocate автоматически вызывается, когда создается новый объект при помощи new, а Deallocate — при вызове настройки Unchecked_Deallocation, такой как Free. Так мы реализуем необходимые действия по управлению памятью. Поскольку тип контролируемый, процедуры Initialize и Finalize автоматически вызываются при объявлении пула и его уничтожении.

Чтобы создать пул мы объявляем объект этого типа, как обычно. Наконец, необходимо привязать конкретный ссылочный тип к объекту‐пулу.

Cell_Ptr_Pool: My_Pool_Type (1000); -- Размер пула — 1000

for Cell_Ptr'Storage_Pool use Cell_Ptr_Pool;

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

Пулы памяти были усовершенствованы в стандарте Ада 2012, где появились вложенные пулы. Мы не будем останавливаться здесь на этом, отметим лишь, что вложенные пулы — это части пула, уничтожаемые по отдельности.

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

with Unchecked_Deallocation;

Этот спецификатор контекста легко заметить при контроле качества программы.

Ограничения

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

pragma Restrictions(No_Dependence => Unchecked_Deallocation);

мы убедимся, что программа вообще не использует Unchecked_Deallocation — компилятор проверит это.

Существует около пятидесяти таких ограничений, которые контролируют различные аспекты программы. Многие из них узкоспециализированные и относятся к многозадачным программам. Другие касаются распределения памяти, например:

pragma Restrictions(No_Allocators);

pragma Restrictions(No_Implicit_Heap_Allocations);

Первый полностью запрещает использование конструкции new, как например new Cell'(...), а значит запрещает и динамическое распределение памяти вообще. Иногда, некоторые реализации используют динамическое распределение для хранения временных объектов. Это редкие случаи и второй вариант запрещает их появление.