Автоматическое выделение памяти

Классы CVirtualBufBase и CVirtualBuf<>

Автор: Алексей Ширшов
The RSDN Group

Источник: RSDN Magazine #3
Опубликовано: 08.04.2003
Версия текста: 1.0
Предисловие
SEH
Фильтр исключений
Пример
Виртуальное адресное пространство
Класс CVirtualBufBase
Класс CVurtualBuf<>
Пример использования
Заключение
Список литературы

Исходный код (2 Кб)

Предисловие

Эти классы родились по многочисленным просьбам трудящихся программистов сообщества RSDN. Они не являются аналогами CAutoBufBase и CAutoBuf<> соответственно, и предназначены совершенно для других задач. Они могут серьезно упростить код, который должен работать с большими массивами данных, затрачивая минимум ресурсов (памяти). Другими словами, память будет выделяться тогда, когда нужно, и в том объеме, в котором нужно.

Использование классов не освобождает программиста от обязанности писать код в защищенном блоке, однако все остальное берет на себя класс. Еще одно замечание – вы не сможете создавать локальные экземпляры классов (в стеке), имеющие деструкторы, и будете обязаны сами вызывать функцию освобождения ресурсов для класса. Дело в том, что реализация компилятора С++ от фирмы Microsoft не позволяет создавать локальные экземпляры классов с деструктором в функциях, использующих SEH – Structured Exception Handling.

SEH

В этом разделе коротко рассматривается структурная обработка исключений. Более подробную информацию можно найти в [1].

Итак, структурная обработка исключений – предоставляемый ОС механизм, позволяющий обрабатывать программные и аппаратные исключения коду, который может их обработать (в том числе и в пользовательском режиме). Код, исключения которого нужно перехватить, помещается в защищенный блок. Начало блока отмечает оператор __try, после которого вы обязаны указать область видимости (фигурные скобки). Обработчик исключений может быть двух типов – обработчик завершения или фильтр исключения. Первый начинается с оператора __finally, а второй – с __except. Смешивать их нельзя, но можно вкладывать друг в друга. Обработчик завершения нужен для того, чтобы выполнить завершающий код при любом течении событий. Вне зависимости от того, произошло исключение или нет, он будет вызван и выполнен. Фильтр исключений, напротив, выполнится только при возникновении исключения и не вызывается при нормальном выполнении

защищенного блока. Небольшой пример:

      __try
{
//что-то делаем
}
__finally
{
//этот код будет выполнен в любом случае
}
...
__try
{
//что-то делаем
}
__except(/*фильтр исключения*/)
{
//обработчик исключения
}

Фильтр исключений

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

Константа Описание
EXCEPTION_EXECUTE_HANDLER Обработчик исключения будет выполнен, после чего управление передастся оператору, следующему за блоком __except.
EXCEPTION_CONTINUE_SEARCH Переход к предыдущему блоку try и вызов его фильтра исключений.
EXCEPTION_CONTINUE_EXECUTION Переход на инструкцию, вызвавшую исключение.

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

Функция GetExceptionInformation возвращает указатель на структуру, которая содержит еще два указателя: на машинно-зависимую и машинно-независимую информацию об исключении.

Машинно-независимая информация представлена структурой

      typedef
      struct _EXCEPTION_RECORD { 
  DWORD ExceptionCode; 
  DWORD ExceptionFlags; 
  struct _EXCEPTION_RECORD *ExceptionRecord; 
  PVOID ExceptionAddress; 
  DWORD NumberParameters; 
  ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;

Рассмотрим назначение каждого параметра:

Член структуры EXCEPTION_RECORD Описание
ExceptionCode Содержит код исключения. Например, EXCEPTION_ACCESS_VIOLATION или EXCEPTION_INT_DIVIDE_BY_ZERO.
ExceptionFlags Флаги исключения
*ExceptionRecord Указатель на структуру EXCEPTION_RECORD предыдущего необработанного исключения
ExceptionAddress Адрес инструкции, вызвавшей исключение
NumberParameters Количество дополнительных параметров исключения
ExceptionInformation Массив дополнительных параметров, максимальное количество которых не может быть больше EXCEPTION_MAXIMUM_PARAMETERS.

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

Параметр Описание
ExceptionInformation[0] - флаг чтения/записи Если флаг равен нулю, поток пытался прочитать данные, которые были не доступны. Если единице – была попытка записи.
ExceptionInformation[1] - aдрес недоступного участка памяти Виртуальный адрес, при обращении к которому произошло исключение.

Пример

Теперь самое время рассмотреть маленький примерчик:

      int d = 0;

DWORD MyFilter(PEXCEPTION_RECORD er)
{
  if (er->ExceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO){
    d = 12;
    return EXCEPTION_CONTINUE_EXECUTION;
  }
  return EXCEPTION_EXECUTE_HANDLER;
}

int main()
{
  __try{
    int k = 12/d;
    int* j = 0;
    *j = 12;
  }
  __except(MyFilter((GetExceptionInformation())->ExceptionRecord)){
    OutputDebugString(_T("Why are you dereferencing a NULL pointer?\n")); 
  }
}

В этом примере будет два исключения: первое – деление на ноль. Оно будет успешно исправлено. Второе – разыменовывание нулевого указателя, для него в окно Debug отладчика будет выведена соответствующая строка.

Виртуальное адресное пространство

Рассмотрим также кратко структуру адресного пространства процесса и функции работы с виртуальной памятью. Более подробную информацию можно найти в [1]. Семейство операционных систем Windows 9x здесь не рассматривается.

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

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

Зарезервированная память мало чем отличается от свободной. Единственное, для чего она нужна – исключить возможность резервирования или выделения ее кем-то еще. То есть, резервируя память, мы как бы заявляем свои права на участок. До тех пор, пока мы ее не освободим, никто не сможет выделить память, которая пересекается с ней или является ее частью. Обращение к зарезервированной памяти аналогично обращению к свободной.

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

Минимальной единицей, с которой работает менеджер виртуальной памяти, является страница. Менеджер выделяет, подкачивает, резервирует и освобождает память только по страницам. Размер страницы зависит от аппаратуры (процессора). На процессорах Intel он составляет 4Кб.

Память резервируется и выделяется одной функцией.

LPVOID VirtualAlloc(
  LPVOID lpAddres,         // регион для резервирования или выделения
  SIZE_T dwSize,           // размер региона
  DWORD flAllocationType,  // тип выделения
  DWORD flProtect          // тип защиты
);

Для резервирования первым параметром обычно указывается ноль, в этом случае система сама подыскивает нужный адрес.

ПРИМЕЧАНИЕ

Этот адрес всегда будет кратен 64-килобайтной границе.

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

ПРИМЕЧАНИЕ

Если вы попытаетесь выделить память, которая не была первоначально зарезервирована, ничего плохого не произойдет. :) Система зарезервирует регион автоматически.

Освобождать или возвращать (decommit) память можно с помощью функции

BOOL VirtualFree(
  LPVOID lpAddress,   // адрес региона
  SIZE_T dwSize,      // размер
  DWORD dwFreeType    // тип освобождения
);

Наибольший интерес представляет третий параметр.

Значение Описание
MEM_RELEASE Освобождает зарезервированный регион. Если ему была передана память, она также освобождается. Параметр dwSize должен быть равен нулю.
MEM_DECOMMIT Возвращает ранее переданную память, которая снова становится зарезервированной.

Теперь можно перейти к рассмотрению классов.

Класс CVirtualBufBase

Класс предназначен для автоматического выделения необходимого количества «сырой», не типизированной памяти. Работа с ним ведется как с обыкновенным массивом, однако часть его может и не находиться в памяти. Память резервируется в конструкторе, при этом по умолчанию сразу передается только одна страница. Если при резервировании памяти в функции Init произойдет ошибка и память зарезервирована не будет, члены m_lReserv и m_pBase будут равны нулю. К чему это приведет? К тому, что функция Alloc вызовется для передачи одной странички для не зарезервированного региона. Если повторное выделение и резервирование памяти завершится неудачно члены m_lReserv и m_pBase останутся равными нулю.

      //Явный конструктор
      explicit CVirtualBufBase(ULONG_PTR lReserv, bool bytes = false)
  {
    //Резервируем регион
    Init(lReserv,PAGE_READWRITE,bytes);
    //Выделяем одну страничку
    Alloc(1);
  }
...
  void Init(ULONG_PTR lReserv,DWORD dwProt,bool bytes = false)
  {
    SYSTEM_INFO si = {0};
    //Определяем размер страницы
    GetSystemInfo(&si);
    m_dwPageSz = si.dwPageSize;
    m_pBase = 0;
    //Флаги защиты
    m_dwProtect = dwProt;
    //Кол-во зарезервированных страницif (bytes){
      m_lReserv = lReserv / m_dwPageSz;
      if (lReserv % m_dwPageSz)
        m_lReserv++;
    }
    else
      m_lReserv = lReserv;
    m_lIncPage = 1;    
    //Резервирование
    m_pBase = VirtualAlloc(NULL,lReserv*m_dwPageSz,MEM_RESERVE,m_dwProtect);
    if (!m_pBase) m_lReserv = 0;
  }

Процедура выделения памяти тривиальна:

      //Функция выделения памяти. На входе - кол-во страниц
  LPVOID Alloc(ULONG_PTR lPages)
  {
    if (lPages > m_lReserv){
      if (m_lReserv != 0)
        lPages = m_lReserv;
      else
        m_lReserv = lPages;//Если зарезервированно 0,//то функцией VirtualAlloc мы сразу зарезервируем и//передадим lPages страниц. Ситуация может возникнуть после//CleanUp
    }
    m_lAllocated = lPages;
    m_pBase = VirtualAlloc(m_pBase,lPages*m_dwPageSz,MEM_COMMIT,m_dwProtect);
    if (!m_pBase) m_lReserv = 0;
    return m_pBase;
  }

Самая сложная и важная процедура класса - фильтр исключений. Вот ее код.

      //Фильтр обработки исключений
  DWORD Filter(PEXCEPTION_RECORD er)
  {
    //Наше исключение EXCEPTION_ACCESS_VIOLATIONif (er->ExceptionCode == EXCEPTION_ACCESS_VIOLATION){
      
      //Смысл последующего блока - исключить возможность//выделения помяти при попытке записать в буфер,//который изначально предполагалось использовать//только для чтенияif (er->ExceptionInformation[0] == 1 &&  //попытка записи
        (m_dwProtect & PAGE_READONLY) != 0)
        return EXCEPTION_EXECUTE_HANDLER;
      
      //Здесь производится проверка, что адрес, вызвавший//исключение, принадлежит нашему буферу
      ULONG_PTR pBadMem = (ULONG_PTR)er->ExceptionInformation[1];
      if ((ULONG_PTR)m_pBase < pBadMem && 
        (ULONG_PTR)m_pBase+m_lReserv*m_dwPageSz > pBadMem){
        
        ULONG_PTR pBase = pBadMem - pBadMem % m_dwPageSz;
        ULONG_PTR lIncPage = m_lIncPage;
        
        while(pBase + lIncPage*m_dwPageSz > (ULONG_PTR)m_pBase + m_lReserv*m_dwPageSz)
          lIncPage--;

        MEMORY_BASIC_INFORMATION mbi;
        VirtualQuery((LPVOID)(pBase+1*m_dwPageSz),&mbi,sizeof(mbi));
        if (mbi.State == MEM_COMMIT){
          if (pBase == (ULONG_PTR)m_pBase)
            lIncPage = 1;
          else
            pBase -= (lIncPage-1) * m_dwPageSz;
        }
        //Увеличиваем на заданное кол-во страниц
        m_lAllocated += lIncPage;
#ifdef _DEBUG
        TCHAR buf[100];
        
        wsprintf(buf,_T("%d page(s) at addr %X\n"),lIncPage,pBase);
        OutputDebugString(_T("Caught: Allocate "));
        OutputDebugString(buf);
#endif//Выделяем еще память
        VirtualAlloc((LPVOID)pBase,lIncPage*m_dwPageSz,MEM_COMMIT,m_dwProtect);

        //Выполнение передается на вызвавшую ошибку//инструкциюreturn EXCEPTION_CONTINUE_EXECUTION;
      }
    }

    //Выполнение передается в блок обработки исключенияreturn EXCEPTION_EXECUTE_HANDLER;
  }

Суть ее в том, что при нарушении доступа к «нашему» региону мы выделяем заданное количество страниц и продолжаем работу. Если произошло исключение другого типа или по адресу, не принадлежащему нашему региону, мы передаем управление в блок обработки исключения, возвращая EXCEPTION_EXECUTE_HANDLER.

Давайте рассмотрим алгоритм более подробно. Перво-наперво проверяется код возникшего исключения. Если он не соответствует коду нарушения доступа к памяти, фильтр завершает работу, возвращая EXCEPTION_EXECUTE_HANDLER. Следующее условие проверяет ситуацию, когда пользователь попытался записать что-то в память с атрибутом READ_ONLY. Напомню, что атрибуты защиты устанавливаются в конструкторах класса. По умолчанию значение устанавливается в PAGE_READWRITE. Далее идет проверка на то, что адрес памяти, при обращении к которой возникло исключение, лежит в диапазоне нашего зарезервированного региона. Если условие не выполняется, фильтр опять возвращает EXCEPTION_EXECUTE_HANDLER. Следует отметить, что если при начальном резервировании региона в конструкторе произошла ошибка, члены m_pBase, содержащие базовый адрес региона, и m_lReserv будут равны нулю. При этом соответствующее условие также выполнено не будет и фильтр закончит обработку.

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

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

Этот алгоритм очень далек от совершенства, однако он на минимальном уровне позволяет оптимизировать доступ к памяти.

Класс CVurtualBuf<>

Это очень маленький класс-обертка для CVirtualBufBase. Вот весь его код:

      template<class T>
class CVirtualBuf : public CVirtualBufBase
{
public:
  //Явный конструкторexplicit CVirtualBuf(ULONG_PTR lReserv) : CVirtualBufBase(lReserv*sizeof(T),true){}
  
          //Функция выделения
            T* Alloc(ULONG_PTR cNumOfElems)
  {
    cNumOfElems *= sizeof(T);
    ULONG_PTR lPg = cNumOfElems / m_dwPageSz;
    if (cNumOfElems % m_dwPageSz) lPg++;
    returnreinterpret_cast<T*>(CVirtualBufBase::Alloc(lPg));
  }

  //Высвобождаем (decommit), но не удаляем памятьvoid DeAlloc(ULONG_PTR cNumOfElems = 0)
  {
    if (m_pBase != 0){
      cNumOfElems *= sizeof(T);
      ULONG_PTR lPg = cNumOfElems / m_dwPageSz;
      if (cNumOfElems % m_dwPageSz) lPg++;
      VirtualFree(m_pBase,(cNumOfElems?lPg:m_lReserv)*m_dwPageSz,MEM_DECOMMIT);
      m_lAllocated -= (cNumOfElems?lPg:m_lReserv);
    }
  }

  //Функция получения базового адреса
  T* GetBase() const
  {
    returnreinterpret_cast<T*>(CVirtualBufBase::GetBase());
  }
  
  //Размер выделенного блока в элементах T
  ULONG_PTR GetAllocatedSize() const
  {
    return CVirtualBufBase::GetAllocatedSize()/sizeof(T);
  }

  //Размер зарезервированного блока в элементах T
  ULONG_PTR GetReservedSize() const
  {
    return CVirtualBufBase::GetReservedSize()/sizeof(T);
  }

  bool IsAllocated(T* pMem)
  {
    return CVirtualBufBase::IsAllocated(pMem);
  }

  //Оператор доступа
  T& operator[](int idx)
  {
    return GetBase()[idx];
  }
};

Замечу, что размер выделенной и зарезервированной памяти возвращается в единицах параметра шаблона, а не в байтах.

Пример использования

Для упрощения кода фильтра исключений можно использовать следующий макрос:

      //Специальный макрос для __except
      #define VIRTUAL_BUF_FILTER(vb) vb.Filter((GetExceptionInformation())->ExceptionRecord)

Его назначение вы поймете из следующего примера.

      int main(int argc, char* argv[])
{
  //Резервируем четыре страницы, выделяем одну
  CVirtualBuf<int> pVirtBuf(4000);
  for(ULONG_PTR i = 0;i<pVirtBuf.GetReservedSize();i++)
  {
    __try{
      pVirtBuf[i] = 0xAA;
    }
    __except(VIRTUAL_BUF_FILTER(pVirtBuf))  //Используем макрос
    {
      OutputDebugString("Some other exception!\n");
    }
  }
  //Отчистка
  pVirtBuf.CleanUp();
  return 0;
}

CVirtualBufBase и CVirtualBuf<> не имеют деструкторов, по выше описанным причинам, поэтому освобождать ресурсы вам придется самим. Конечно, можно обойти эту проблему, однако это уже совсем другая история.

Заключение

Когда же стоит применять описанные в статье классы? Это зависит от задачи, которую вы решаете. Если вы работаете с заранее не известными объемами данных, размер которых достаточно велик для того, чтобы все их размещать в куче (Heap) – классы CVirtualBufBase и CVirtualBuf<> будут идеальным решением. Память будет выделяться только по требованию и полностью автоматически. Если вам понадобится освободить уже выделенную память, например, если вы заполнили первые 1000 элементов массива, но активно работаете только со второй тысячей, можно воспользоваться функцией DeCommit.

Если же объем обрабатываемых данных невелик, и их размер (хотя бы приблизительно) известен, лучше воспользоваться функциями выделения памяти в куче, такими как HeapAlloc, HeapReAlloc, HeapFree и проч.

Список литературы

  1. 1 Джеффри Рихтер, Windows для профессионалов


Эта статья опубликована в журнале RSDN Magazine #3. Информацию о журнале можно найти здесь