Сообщений 17    Оценка 290        Оценить  
Система Orphus

QuickHeap

Автор: Чистяков Владислав
Оптим.ру

Источник: RSDN Magazine #1
Опубликовано: 26.11.2002
Исправлено: 13.03.2005
Версия текста: 1.0.1
Введение
Описание кода

Демонстрационный проект (VC7, C#)

Введение

QuickHeap получил свое название из-за того, что при его разработке основным мотивом было получение максимально быстрого варианта хипа.

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

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

В случае хипа ресурсом выступает блок памяти. В принципе, любой блок памяти уникально характеризуется его адресом. Но, с точки зрения использования этого блока, эта характеристика не имеет особого значения. Однако у блока памяти есть и другие характеристики – его содержимое и размер. При выделении памяти ее содержимое тоже не играет никакой роли, так как память должна быть обязательно проинициализирована перед использованием (к сожалению, в языках типа C++ за этим приходится следить самому программисту, что порой приводит к трудно уловимым ошибкам). А вот размер блока – атрибут, который нельзя игнорировать. Пул может содержать только блоки одного размера.

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

В случае с QuickHeap проблема обнаружения хипа с подходящим размером блока была решена введением дополнительного статического массива, хранящего ссылки на пулы с подходящим размером блока. Ячейка массива содержит NULL либо указатель на пул, состоящий из блоков, размер которых соответствует индексу ячейки. В начале работы программы все элементы этого массива инициализируются значением NULL, и если ячейка массива не указывает на уже существующий пул, создается соответствующий пул, а указатель на него помещается в свободную ячейку. Если же пул уже существует, то используется именно он (см. рисунок 1).

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

Память под все структуры QuickHeap (как-то сам объект QuickHeap, пулы ) выделяется в хипе приложения.


Рисунок 1. Устройство QuickHeap.

Внимательный читатель может заметить, что размер занимаемого в хипе блока может быть сколь угодно большим, а размер массива ограничен. Прискорбно…, но ситуация облегчается тем, что выделение больших объемов памяти происходит не часто. Его смело можно переложить на плечи стандартного хипа Windows или даже на подсистему виртуальной памяти. В данном случае обязанности перекладываются на хип ОС. При выделении памяти проверяется, не превышает ли размер выделяемого блока размера массива (в нашем случае 1024), и если этот размер превышен, производится занятие памяти в хипе ОС.

Но при этом возникает другая проблема – как при освобождении памяти определить, что блок был занят в хипе, а не в пуле? К сожалению, для этого приходится тратить лишние 8 байт памяти, но так как размер выделяемого блока довольно велик, на это смело можно закрыть глаза.

Сами пулы организованы очень просто. Память в них выделяется экстентами (непрерывными областями памяти, содержащими сразу несколько блоков). Первый экстент пула содержит несколько блоков (конкретное значение вычисляется путем деления константы ciQHInitPoolSize, в нашем случае имеющей значение 0x1000, или 4096 байт, на размер блока). Таким образом, на первый экстент пула тратится 4 килобайта памяти. Этот параметр можно регулировать в зависимости от конкретных нужд приложения. Его значение не так критично, так как при нехватке блоков в первом экстенте выделяется еще один экстент, размер которого вдвое превышает предыдущий. Таким образом, количество содержащихся в экстенте блоков удваивается с каждым следующим экстентом, и очень быстро пул набирает количество блоков, после которого добавление новых экстентов не нужно.

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

Указатель на хип заполнятся непосредственно при выделении блока и используется для определения хипа в функциях освобождения памяти, подобных оператору delete и функции free (из связки malloc/free). Потребителю возвращается указатель на тело блока, то есть адрес блока плюс размер шапки. Таким образом, в функциях освобождении памяти достаточно вычесть из переданного в них указателя размер заголовка. В результате получится указатель на заголовок, содержащий указатель на хип, в котором был выделен этот блок.


Рисунок 2. Устройство блока.

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

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


Рисунок 3. Список свободных блоков.

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

Описание кода

В QuickHeap блок памяти оформлен в виде вот такой структуры:

struct QHBlock
{
  QHBlock * m_pNext;
  void * m_pQuickHeapPool;
protected:
  inline void * __cdecl operator new(size_t n){ n; }
  inline void   __cdecl operator delete(void* p){ p; }
};

Чтобы запретить создание операторов new и delete в хипе, эти объекты убраны в protected-область. Кроме описанного в структуре заголовка, блок имеет тело, указатель на который и возвращается потребителю.

m_pNext – используется для создания связанного списка пустых блоков и инициализируется при освобождении блока.

m_pQuickHeapPool – указатель на пул, в котом был выделен блок. Инициализируется перед передачей блока потребителю.

Более подробно эти указатели будут описаны ниже.

struct QHExtent
{
  QHExtent * m_pNextExtent;
  QHBlock * m_pCurrent;

  QHBlock m_aryBlocks[0];
protected:
  inline void * __cdecl operator new(size_t n){ n; }
  inline void   __cdecl operator delete(void* p){ p; }
};

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

m_pNextExtent содержит ссылку на предыдущий экстент или NULL, если это первый экстент. Таким образом, каждый хип хранит связанный список занятых в нем экстентов. Этот список нужен для освобождения памяти в конце работы программы.

m_pCurrent – указатель на текущий бок. При инициализации нового экстента этому указателю присваивается указатель на последний блок экстента. При каждом выделении нового блока этот указатель уменьшается на размер блока, приближаясь к началу экстента (блоку m_aryBlocks[0]). Когда в экстенте больше не остается не выделенных блоков, создается новый экстент, а указатель на старый помещается в m_pNextExtent нового экстента.

m_aryBlocks[0] – это заглушка, символизирующая :) собой массив блоков. [0] означает, что если попытаться создать QHExtent штатными средствами C++, m_aryBlocks будет указывать на область памяти сразу за телом структуры. Так как физически ни одного элемента в массиве нет, обращение по этому смещению привело бы к непредсказуемым последствиям. m_aryBlocks является всего лишь виртуальным смещением. Память под этот элемент не отводится. m_aryBlocks нужен всего лишь для упрощения доступа к блокам экстента. Большинство компиляторов будут страшно недовольны таким объявлением, так что в реальном коде это определение заключено в код, подавляющий предупреждения. Память под экстент занимается вручную (без использования конструктора и оператора new). Это позволяет занять место под входящие в него блоки.

Как и в случае с QHBlock, в QHExtent закрыты операторы new и delete. Это позволяет уберечь программиста от нецелевого использования данного класса.

Настала пора перейти к, пожалуй, главному элементу QuickHeap – классу QuickHeapPool. Именно в нем производится львиная доля всех операций. Класс приведен целиком, а необходимая информация дана в виде комментариев.

class QuickHeapPool
{
public:
  /* Память для этого класса должна выделяться в хипе ОС, так как QuickHeap
   * не может выделять память сам для себя. Так как для использования 
   * QuickHeap обычно переопределяются глобальные операторы new и delete, в 
   * этом классе нужно вручную их переопределить и заставить выделять память
   * в хипе ОС. Функции QuickHeapPoolInternalXxx выделяют память в главном 
   * хипе процесса.
   */
  inline void * __cdecl operator new(size_t n)
  { return QuickHeapPoolInternalAlloc(n); }
  inline void   __cdecl operator delete(void* p)
  { QuickHeapPoolInternalFree(p); }

  // Alloc занимает новый бок памяти.
  void * Alloc()
  {
    QHBlock * p = AllocBlock();

    /* Каждый блок начинается с заголовка. Поэтому перед тем, как отдать блок
     * потребителю, необходимо сместить указатель на область, идущую
     * непосредственно за заголовком блока. Так как размер заголовка равен
     * размеру структуры QHBlock, можно просто увеличить указатель на 
     * блок (p) на единицу.
     */
    p++;
    return p;
  }

  // Конструктор.
  QuickHeapPool(size_t BlockSize,      // Размер блока в пуле.
                int iInitBlockCnt = 0) // Начальное количество блоков.
    : m_CurrExtent(NULL),m_pFreeBlocksList(NULL)
  {
    // Рассчитываем реальный размер блока. Именно он будет участвовать в
    // дальнейших расчетах.
    m_iIntrBlockSize = sizeof(QHBlock) + BlockSize;

    // Если задано начальное количество блоков, ...
    if(iInitBlockCnt > 0)
      AddExtent(iInitBlockCnt); // ...сразу же добавляем первый экстент
  }

  // Деструктор. Освобождает занятые экстенты.
  ~QuickHeapPool(void)
  {
    QHExtent * pExtent = m_CurrExtent;
    while(pExtent)
    {
      QHExtent * pNext = pExtent->m_pNextExtent;
      QuickHeapPoolInternalFree(pExtent);
      pExtent = pNext;
    }
  }

  // Занимает блок памяти.
  __forceinline QHBlock * AllocBlock()
  {
    // Если список освобожденных блоков не пуст...
    if(m_pFreeBlocksList)
    {
      // Вынимаем из него первый элемент и отдаем его потребителю.
      // Это позволяет существенно ускорить множественные
      // займы/освобождения памяти.
      register QHBlock * pBlock = m_pFreeBlocksList;
      m_pFreeBlocksList = m_pFreeBlocksList->m_pNext;
      return pBlock;
    }
    else
      return GetBlock(); // Иначе берем новый блок из текущего экстента.
  }

  // Освобождает блок ранее занятый через AllocBlock блок.
  __forceinline void FreeBlock(QHBlock * pBlock)
  {
    // Освобождение в QuickHeap сводится к добавлению блока в список
    // освобожденных блоков.
    pBlock->m_pNext = m_pFreeBlocksList;
    m_pFreeBlocksList = pBlock;
  }

protected:
  // AddExtent занимает новый экстент размером iBlockCnt блоков.
  // Память под экстент выделяется в хипе ОС. В принципе, для пулов с большим
  // размером блока память можно было бы выделять прямо в ОС 
  // (функциями VirtualAlloc/ VirtualFree).
  void AddExtent(size_t iBlockCnt)
  {
    ATLASSERT(!m_CurrExtent 
      || m_CurrExtent->m_pCurrent == m_CurrExtent->m_aryBlocks);

    // Занимаем память под экстент (заголовок и входящие в экстент блоки).
    QHExtent * pExtent = (QHExtent*)QuickHeapPoolInternalAlloc(
      sizeof(QHExtent) + (iBlockCnt + 1) * m_iIntrBlockSize);

    // Помещаем указатель на предыдущий экстент в переменную m_pNextExtent
    // нового экстента.
    pExtent->m_pNextExtent = m_CurrExtent;

    // Записываем в m_pCurrent указатель на самый последний блок.
    // Этот указатель рассчитывается как адрес первого блока плюс количество
    // блоков, умноженное на их размер. Приведение к BYTE* требуется из-за 
    // того, что смещение рассчитывается в байтах. Если этого не сделать, по 
    // правилам C++ смещение будет увеличиваться на размер структуры QHBlock.
    pExtent->m_pCurrent = (QHBlock*)((BYTE*)(pExtent->m_aryBlocks) 
      + iBlockCnt * m_iIntrBlockSize);

    // Делаем новый экстент текущим. Тем самым заставляем выделять 
    // блоки именно из него.
    m_CurrExtent = pExtent;

    // Запоминаем размер текущего экстента для того, чтобы в следующий раз 
    // удвоить этот размер.
    m_iCurrExtentSize = iBlockCnt;
  }

  // Выделяет блок новый блок (из экстента).
  __forceinline QHBlock * GetBlock()
  {
    // Проверяем, не исчерпан ли текущий экстент...
    if(m_CurrExtent->m_pCurrent == m_CurrExtent->m_aryBlocks)
      // Если исчерпан, добавляем новый экстент, количество элементов 
      // в котором вдвое больше, чем в предыдущем.
      AddExtent(m_iCurrExtentSize * 2);

    QHBlock * pRet = m_CurrExtent->m_pCurrent;
    // Инициализируем заголовок нового блока.
    // Указатель на пул будет нужен при освобождении блока, так как 
    // в процедурах освобождения передается только освобождаемый блок.
    pRet->m_pQuickHeapPool = this;

    // Уменьшаем указатель на текущий блок, сдвигая его тем самым назад к
    // m_aryBlocks. Так как размер блока определяется в байтах, необходимо 
    // привести m_pCurrent к указателю на байт (BYTE*). Чтобы получить
    // lvalue добавляется знак ссылки «&».
    (BYTE*&)m_CurrExtent->m_pCurrent -= m_iIntrBlockSize;
    return pRet;
  }

  // Данные пула.

  // Количество блоков в текущем экстенте. Нужен для расчета количества 
  // блоков в следующем экстенте.
  size_t m_iCurrExtentSize;
  // Внутренний размер блока. Включает в себя размер блока, получаемый
  // в конструкторе, и размер заголовка блока.
  size_t m_iIntrBlockSize;
  // Текущий экстент пула. Список ранее выделенных экстентов хранится 
  // в виде связанного списка в поле QHExtent::m_pNextExtent
  QHExtent * m_CurrExtent;
  // Список освобожденных блоков. Когда программист освобождает ранее
  // занятый блок, этот блок попросту помещается в начало связанного списка
  // свободных блоков. При следующем запросе блок не выделяется из текущего
  // экстента, а возвращается из этого списка, головой списка при этом
  // становится блок, указатель на который находится в QHBlock::m_pNext. 
  // Если свободных блоков нет, в m_pFreeBlocksList помещается NULL.
  QHBlock * m_pFreeBlocksList;
};

Класс QuickHeapPool можно использовать и отдельно, создавая пулы для отдельных классов. Правда, в нем есть некоторая избыточность. Поле m_pQuickHeapPool нужно исключительно для определения, в каком хипе был выделен блок. В случае пула для отдельного класса такой проблемы не возникает, так что от этого поля (и всех связанных с ним действий) можно отказаться. Единственное, что нужно учитывать при создании пулов для отдельных классов, это то, что пул будет неправильно работать в случае вызова деструктора для указателя, приведенного к базовому классу. Если необходимо именно такое поведение, придется воспользоваться виртуальным деструктором. Это само по себе приведет к некоторому замедлению работы. Эта проблема не QuickHeap, это реальность C++ (подобная проблема возникла бы и с обычным хипом).

Теперь подошло время для описания последнего класса, входящего в QuickHeap, который так и называется – QuickHeap.

// Начальный размер пула в байтах.
const ciQHInitPoolSize = 0x1000;

class QuickHeap
{
public:
  // Память под QuickHeap должна выделяться в хипе ОС.
  inline void * __cdecl operator new(size_t n)
  { return QuickHeapPoolInternalAlloc(n); }
  inline void __cdecl operator delete(void* p)
  { QuickHeapPoolInternalFree(p); }

  // Коструктор.
  QuickHeap()
  {
    // Инициализируем массив пулов значением NULL.
    const iSisze = sizeof(m_arypQHPool);
    ZeroMemory(m_arypQHPool, iSisze);
  }

  // Деструктор. Освобождает все пулы, занятые в процессе работы QuickHeap.
  ~QuickHeap()
  {
    for(int i = 0; i < sizeof(m_arypQHPool)/sizeof(m_arypQHPool[0]); i++)
      delete (m_arypQHPool[i]);
  }

  // Занимает блок памяти размером cb.
  __forceinline void * Alloc(size_t cb)
  {
    const iMaxHeapSize = (sizeof(m_arypQHPool) / sizeof(m_arypQHPool[0]));
    // Если размер блока в байтах превышает количество элементов в массиве 
    // пулов, память для него нужно выделять в хипе ОС.
    if(cb >= iMaxHeapSize)
    {
      // Выделяем блок размером cb плюс размер заголовка блока.
      QHBlock * pBlock = (QHBlock*)QuickHeapPoolInternalAlloc(
        sizeof(QuickHeapPool) + cb);

      // Инициализируем указатель на хип значением -1 (0xffffffff для Win32).
      // Впоследствии по этому значению можно будет определить, что память 
      // занята в хипе ОС, а не в пуле.
      pBlock->m_pQuickHeapPool = (QuickHeapPool*)-1;
      // Продвигаем указатель, чтобы он указывал на тело блока.
      ++pBlock;
      return pBlock;

    }
    else if(cb <= 0)
      return NULL; // Если запрашиваю 0 байт, возвращаем NULL.
    else
    {
      // Если блок достаточно мал, выделяем для него память в пуле 
      // подходящего размера.

      // Получаем указатель на пул с блоком соответствующего размера.
      QuickHeapPool * pCurrPool = m_arypQHPool[cb];

      // Если пул еще не создан, указатель будет содержать NULL.
      if(!pCurrPool) // В этом случае...
        // ...создаем новый пул и помещаем указатель на него в массив.
        pCurrPool = m_arypQHPool[cb] = 
          new QuickHeapPool(cb, ciQHInitPoolSize / (int)cb);

      // Выделяем блок из пула.
      return pCurrPool->Alloc();
    }
  }
  // Освобождает блок памяти ранее занятый функцией Alloc.
  __forceinline void Free(void * p)
  {
    // Получаем указатель на заголовок блока.
    QHBlock * pBlock = (QHBlock *)p;
    pBlock--; // Заголовок находится по отрицательному смещению.

    // Получаем хип, в котором был занят блок.
    QuickHeapPool* pQuickHeapPool = (QuickHeapPool*)pBlock->m_pQuickHeapPool;

    // Если блок был занят в системном пуле, даем ему слово...
    if((QuickHeapPool*)-1 == pQuickHeapPool)
      QuickHeapPoolInternalFree(pBlock);
    // Такая проверка не нужна, так как такая ситуация исключена.
    //else if(NULL == pQuickHeapPool)
    //  return;
    else
      // Освобождаем блок (при этом он помещается в список свободных блоков).
      pQuickHeapPool->FreeBlock(pBlock);
  }

  // Данные QuickHeap.

  // Массив пулов. Ячейка этого массива содержит NULL или указатель на пул
  // с размером блока, равным индексу этой ячейки. 
  QuickHeapPool * m_arypQHPool[1024];
};

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

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

В приведенном коде отсутствует код, отвечающий за потокобезопасность. В коде, прилагающемся в статье, такой код есть. Он основан на CComAutoCriticalSection (из ATL). Для упрощения использования этого класса был написан небольшой хелпер-класс, позволяющий сократить код, отвечающий за синхронизацию, до одной строки, и избавляющий от ручного разблокирования в случае выхода из области видимости по return или исключению.

Чтобы включить потокобезопасный режим работы, нужно определить в программе макрос QUICK_HEAP_SYNCHRONIZATION. Если этот макрос не определен, хип будет работать без синхронизации. Зачем это нужно? Дело в том что синхронизация значительно (до четырех раз!) замедляет работу QuickHeap. Во многих приложениях, код которых в принципе не может вызываться в многопоточном окружении, синхронизация не нужна. В некоторых синхронизация уже осуществляется вручную, а стало быть, скорее всего, реализована более эффективно. В обоих случаях можно использовать QuickHeap без встроенной синхронизации. Однако, во втором случае нужно быть очень осторожным, так как вероятность порчи памяти при этом очень высока. Оптимальной идеологией в этом случае может быть защита операторов new и delete у больших классов, создающих множество вспомогательных объектов, с помощью критических секций. Так же нужно защитить и места, где создаются или уничтожаются незащищенные объекты. Это позволит сократить количество блокировок и ускорить процесс инициализации этих классов. Но еще раз повторю, это очень скользкий путь.

Хотя и незначительно, но скоростные характеристики QuickHeap можно изменять с помощью константы ciQHInitPoolSize и изменения размера массива пулов (m_arypQHPool). Чем больше значения этих параметров, тем быстрее будет работать QuickHeap. Расплатой за скорость станет менее эффективное расходование памяти.

Для использования QuickHeap в конечном приложении нужно перебить реализации операторов new/delete (можно глобально, а можно и для отдельных классов) и функций занимающих/освобождающих память (таких как malloc/free).

Сделать это можно следующим образом:

// Объявляем глобальную переменную QuickHeap.
// Для ATL-проектов в VC6 эту переменную нужно объявлять как член класса 
__declspec(selectany) QuickHeap g_QuickHeap;
__forceinline void * operator new(size_t n)
{ 
  //printf("QuickHeap::new size = %d\n", (int)n); 
  return g_QuickHeap.Alloc(n); 
}
__forceinline void operator delete(void* p)
{ 
  //printf("QuickHeap::delete\n"); 
  return g_QuickHeap.Free(p); 
}
__forceinline void * operator new[] (size_t n)
{ 
  //printf("QuickHeap::new[] size = %d\n", (int)n); 
  return g_QuickHeap.Alloc(n); 
}
__forceinline void operator delete[] (void* p)
{ 
  //printf("QuickHeap::delete[]\n"); 
  return g_QuickHeap.Free(p); 
}

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