English
preview
Обобщенный пул объектов в MQL5: Снижение накладных расходов на выделение памяти в высокочастотных индикаторах

Обобщенный пул объектов в MQL5: Снижение накладных расходов на выделение памяти в высокочастотных индикаторах

MetaTrader 5 — Торговые системы |
30 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Введение

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

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

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

Практическое замечание: для индикатора, использующего один небольшой объект на каждый тик, самое простое решение — вовсе не пул, а объявление этого объекта глобальной переменной или полем класса и вызов Reset() при каждом тике. Пул — подходящий инструмент, когда одновременно должны существовать многие короткоживущие объекты одного типа, их количество предсказуемо, а профилирование подтвердило, что выделение памяти вносит значительный вклад во время выполнения.
Шаблон "пул объектов" позволяет снизить накладные расходы на выделение памяти для этого класса нагрузок за счет переноса всех операций с памятью на этап инициализации индикатора. Блок объектов фиксированного размера выделяется один раз в функции OnInit(). В дальнейшем индикатор получает объекты из этого заранее созданного пула и возвращает их в него, полностью обходя аллокатор кучи при обычном ходе выполнения. Таким образом, операции с кучей полностью выносятся за пределы OnCalculate(), что устраняет как накладные расходы, так и джиттер времени выполнения, вызванные этими операциями.

Сравнение состояния памяти и сложности поиска (после 500 циклов)

Рисунок 1: Сравнение состояния памяти и сложности поиска (после 500 циклов).



Архитектура пула объектов

Реализация пула состоит из трёх компонентов. CSignalEvent — это класс полезных данных, экземпляры которого используются повторно и представляют результат вычисления одного сигнала. CObjectPool<T> — это обобщенный механизм пула, управляющий жизненным циклом объектов любого типа, допускающего использование в пуле. Пользовательский индикатор PoolBenchmark.mq5 тестирует оба компонента в условиях имитации поступления тиков и регистрирует измеримые различия во временных затратах между стратегиями выделения памяти с использованием пула и без него.

Пул работает по модели списка свободных элементов. При создании пул выделяет в куче память ровно для m_capacity экземпляров типа T и сохраняет указатели на них в плоском массиве. Второй массив целых чисел той же длины содержит индексы всех доступных в данный момент объектов. Целочисленная переменная m_free_count хранит число действительных записей в этом массиве индексов.

При получении объекта значение m_free_count уменьшается на единицу, считывается индекс слота, хранящийся в m_free_indices[m_free_count], и возвращается указатель, хранящийся в m_objects[slot_index]. Обращений к аллокатору кучи не происходит. При возврате объекта в пул с помощью GetPoolIndex() считывается индекс слота, хранящийся в самом объекте, проверяется указатель, вызывается Reset() для сброса полей полезных данных, сбрасывается флаг занятости и индекс слота записывается обратно в стек свободных индексов. И Acquire(), и Release() имеют сложность O(1). Перебор для проверки принадлежности не требуется.

Внутренняя структура памяти CObjectPool, иллюстрирующая взаимосвязь между m_free_indices[] и m_objects[].

Рисунок 2: Схема внутренней организации памяти CObjectPool, иллюстрирующая взаимосвязь между m_free_indices[] и m_objects[]. Курсор m_free_count разделяет стек индексов на зоны занятых и доступных слотов. При получении объекта считывается индекс целевого слота и извлекается соответствующий указатель за время O(1). При возврате объекта в пул считывается индекс слота, хранящийся внутри самого объекта, сбрасываются полезные данные, а индекс слота возвращается в стек свободных индексов за время O(1).



Почему для списка свободных элементов используется массив индексов, а не стек указателей

В более простой реализации в массиве свободных элементов хранились бы сами доступные указатели, а не их индексы. Реализация на основе индексов предпочтительна по двум причинам. Во-первых, это сохраняет возможность проверить, действительно ли возвращаемый объект принадлежит данному экземпляру пула: проверка идентичности m_objects[slot] == obj подтверждает это за время O(1). Во-вторых, при таком подходе массив свободных элементов остается обычным массивом int[], а не T*[], что позволяет избежать ограничений, связанных с типом указателя, для параметра типа обобщенного пула и упрощает операции ArrayResize, необходимые при инициализации.

Вместимость пула и политика обработки переполнения

Пул имеет фиксированную вместимость, задаваемую при его создании. Когда пул исчерпан, функция Acquire() возвращает NULL. Вызывающая сторона должна обработать этот случай. Резервное выделение памяти в куче с помощью new T() не предусмотрено.

Обоснование решения: резервное выделение памяти через new T() при переполнении вновь вводит выделение памяти в куче в горячий путь именно в тех условиях, в которых пул должен был его устранить, — при пиковой нагрузке. Пул, который в самый ответственный момент обращается к аллокатору, не обеспечивает ни предсказуемости пула фиксированной вместимости, ни гибкости подхода, основанного исключительно на выделении памяти в куче. Правильный подход к исчерпанию пула — задать достаточную вместимость пула в OnInit(), а не незаметно обходить пул во время выполнения. Если требуемая вместимость действительно непредсказуема, определите с помощью профилирования максимальное число одновременно существующих объектов, а затем увеличьте вместимость пула в OnInit(), предусмотрев запас.

Блок-схемы принятия решений для функций Acquire() и Release().

Рисунок 3: Блок-схемы принятия решений для функций Acquire() и Release(). Метод Acquire() возвращает указатель на объект за время O(1), если есть свободные слоты, либо NULL с предупреждением в журнале при исчерпании пула. Метод Release() проверяет указатель, предотвращая повторный возврат с помощью IsInUse(), подтверждает принадлежность объекта пулу с помощью IsPooled(), затем определяет слот за время O(1) с помощью GetPoolIndex(), после чего сбрасывает полезные данные и возвращает слот в стек свободных индексов.



Класс полезных данных CSignalEvent

CSignalEvent представляет результат вычисления одного сигнала и содержит направление, опорную цену, значение силы сигнала и временную метку. Он полностью реализует контракт пула, требуемый классом CObjectPool<T>.

Метод Reset() очищает только поля полезных данных, относящиеся к состоянию сигнала: m_direction, m_price, m_strength и m_timestamp. Метаданные принадлежности пулу, а именно m_is_pooled, m_pool_index и m_in_use, находятся исключительно под управлением CObjectPool<T>; метод Reset() никогда их не изменяет. Это разделение сделано намеренно. Класс полезных данных не должен зависеть от аллокатора, управляющего памятью его экземпляров. Включение решений, касающихся жизненного цикла, в метод Reset() приведет к привязке бизнес-логики к управлению памятью, что затруднит анализ и независимое повторное использование данного класса. Пул задает и поддерживает все данные о принадлежности объектов пулу с момента создания и на протяжении всего цикла выдачи и возврата объектов.

Таблица свойств CSignalEvent

СвойствоТипПо умолчаниюНазначение
m_directionint0Направление сигнала: 1 — на покупку, -1 — на продажу, 0 — нейтральное
m_pricedouble0.0Опорная цена на момент формирования сигнала
m_strengthdouble0.0Нормализованное значение силы сигнала (от 0.0 до 1.0)
m_timestampdatetime0Время сервера на момент вычисления сигнала
m_is_pooledboolfalseФлаг принадлежности пулу; устанавливается только классом CObjectPool<T>
m_pool_indexint-1Индекс слота внутри пула; обеспечивает вызов Release() со сложностью O(1)
m_in_useboolfalseФлаг занятости; предотвращает повреждение данных при повторном возврате

Методы контракта CSignalEvent

Конструктор инициализирует поля полезных данных с помощью метода Reset() и задает метаданным пула безопасные значения по умолчанию (m_is_pooled = false, m_pool_index = -1, m_in_use = false). CObjectPool<T> сразу же перезаписывает их правильными значениями во время создания самого пула.

Метод Reset() обнуляет все поля полезных данных и ничего больше:

//+------------------------------------------------------------------+
//| Reset business-state payload fields to defaults for pool reuse.  |
//| Does NOT touch m_is_pooled, m_pool_index, or m_in_use.           |
//| Pool ownership metadata is the pool's responsibility, not the    |
//| object's payload logic.                                          |
//+------------------------------------------------------------------+
void CSignalEvent::Reset(void)
  {
   m_direction = 0;
   m_price     = 0.0;
   m_strength  = 0.0;
   m_timestamp = 0;
   //--- m_is_pooled, m_pool_index, and m_in_use are intentionally NOT reset here.
   //--- Pool lifecycle metadata is managed exclusively by CObjectPool<T>.
  }

Методы SetPooled() и IsPooled() позволяют задать и проверить признак того, что объект был заранее создан пулом. Методы SetPoolIndex() и GetPoolIndex() позволяют задать и получить индекс слота, который метод Release() использует для возврата объекта за время O(1). Методы SetInUse() и IsInUse() обеспечивают защиту от повторного возврата: Release() проверяет IsInUse() перед приемом объекта и отклоняет его с предупреждением, если флаг уже имеет значение false.



Механизм работы шаблона класса CObjectPool

MQL5 поддерживает шаблоны классов с тем же синтаксисом template<typename T>, что и в C++. Параметр шаблона T в классе CObjectPool<T> должен быть типом класса, реализующим полный контракт пула: Reset(), SetPooled(bool), IsPooled(), SetPoolIndex(int), GetPoolIndex(), SetInUse(bool) и IsInUse(). Если тип T не реализует эти методы, компилятор выдаст ошибку в месте инстанцирования, а не в месте определения шаблона. Это ожидаемое поведение шаблонов классов MQL5.

Таблица интерфейса методов CObjectPool

МетодТип возвращаемого значенияДоступОперации с памятью
CObjectPool(int capacity)—общедоступныйВыделяет память в куче для всех экземпляров T; записывает индексы слотов; помечает все экземпляры как незанятые
~CObjectPool()—
общедоступный
Удаляет все экземпляры T, хранящиеся в m_objects[]; освобождает память обоих массивов
Acquire()T*общедоступныйO(1): уменьшает значение m_free_count, устанавливает флаг занятости, возвращает указатель
Release(T* obj)voidобщедоступныйO(1): считывает индекс слота из obj.GetPoolIndex(); проверяет, не является ли возврат повторным; вызывает obj.Reset(); записывает индекс в стек свободных индексов
FreeCount()intобщедоступныйВозвращает текущее значение m_free_count — операция с памятью не выполняется
Capacity()intобщедоступныйВозвращает m_capacity — операция с памятью не выполняется
Utilization()doubleобщедоступныйВозвращает (m_capacity − m_free_count) / m_capacity × 100.0
IsExhausted()boolобщедоступныйВозвращает значение m_free_count == 0

Конструктор

Конструктор выполняет все операции выделения памяти в куче за весь срок существования пула. Размер каждого из двух массивов устанавливается равным m_capacity одним вызовом ArrayResize. В цикле создаются все объекты типа T. В каждом объекте с помощью SetPoolIndex() сохраняется индекс его слота, объект помечается как незанятый и принадлежащий пулу, а его индекс записывается в стек свободных индексов. После завершения конструктора при обычном получении объектов из пула и возврате их в пул обращений к аллокатору кучи больше не происходит.

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
template<typename T>
CObjectPool::CObjectPool(int capacity)
   : m_capacity(capacity),
     m_free_count(capacity)
  {
   ArrayResize(m_objects,      m_capacity);
   ArrayResize(m_free_indices, m_capacity);

   //--- Instantiate all objects and record their slot indices
   for(int i = 0; i < m_capacity; i++)
     {
      m_objects[i] = new T();
      m_objects[i].SetPoolIndex(i);   // Store slot index inside the object (O(1) release)
      m_objects[i].SetInUse(false);   // Mark as free at construction
      m_objects[i].SetPooled(true);   // All pre-allocated objects belong to this pool
      m_free_indices[i] = i;
     }
  }

Acquire()

Метод Acquire() выполняется за время O(1). Если в пуле есть свободные слоты, значение m_free_count уменьшается на единицу, из m_free_indices[m_free_count] считывается индекс слота, у объекта устанавливается флаг занятости и возвращается указатель m_objects[slot]. Обращений к аллокатору кучи не происходит. Когда пул исчерпан, возвращается значение NULL и выводится предупреждение. Резервное выделение памяти в куче не предусмотрено.

//+------------------------------------------------------------------+
//| Acquire an instance pointer from the pool                        |
//| Returns NULL when pool is exhausted; caller must check.          |
//| No heap allocation occurs on this path after construction.       |
//+------------------------------------------------------------------+
template<typename T>
T *CObjectPool::Acquire(void)
  {
   //--- O(1) pool path: return next available object from free stack
   if(m_free_count > 0)
     {
      m_free_count--;
      T *obj = m_objects[m_free_indices[m_free_count]];
      obj.SetInUse(true);
      return(obj);
     }

   //--- Pool exhausted: caller must handle null
   //--- Do NOT fall back to new T() here: doing so reintroduces heap
   //--- allocation into the hot path, defeating the entire purpose of
   //--- the pool. Size the pool correctly in OnInit() instead.
   Print("[CObjectPool] WARNING: Pool exhausted. NULL returned. Increase pool capacity.");
   return(NULL);
  }

Release()

Метод Release() обрабатывает четыре случая: указатель NULL игнорируется без вывода сообщений; повторный возврат (IsInUse() возвращает false) отклоняется с выводом предупреждения; указатель на объект, не принадлежащий этому пулу (IsPooled() возвращает false), отклоняется с выводом предупреждения; у допустимого объекта, принадлежащего пулу, сбрасываются полезные данные и флаг занятости, а индекс его слота возвращается в стек свободных индексов за время O(1) с использованием сохраненного индекса.

//+------------------------------------------------------------------+
//| Release an active object pointer back to the pool               |
//| O(1): uses the slot index stored inside the object itself.       |
//+------------------------------------------------------------------+
template<typename T>
void CObjectPool::Release(T *obj)
  {
   if(obj == NULL)
      return;

   //--- Guard against double-release
   if(!obj.IsInUse())
     {
      Print("[CObjectPool] WARNING: Double-release detected. Object was already free.");
      return;
     }

   //--- Only accept objects that belong to this pool instance
   if(!obj.IsPooled())
     {
      Print("[CObjectPool] WARNING: Released pointer does not belong to this pool. Ignored.");
      return;
     }

   //--- Validate the stored slot index is in range
   int slot = obj.GetPoolIndex();
   if(slot < 0 || slot >= m_capacity || m_objects[slot] != obj)
     {
      Print("[CObjectPool] WARNING: Invalid slot index on released object. Ignored.");
      return;
     }

   //--- Reset business-state payload (ownership metadata is NOT touched by Reset)
   obj.Reset();
   obj.SetInUse(false);

   //--- Return slot to free stack in O(1)
   m_free_indices[m_free_count] = slot;
   m_free_count++;
  }



Когда использовать пул объектов, а когда — более простые альтернативы

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

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

//--- Best option when only one object is needed per tick:
CSignalEvent g_event;   // global or class member - allocated once in OnInit()

//+------------------------------------------------------------------+
//| Initialization                                                   |
//+------------------------------------------------------------------+
int OnCalculate(...)
  {
   g_event.Reset();
   g_event.SetDirection(...);
   g_event.SetPrice(...);
//--- ... use g_event ...
   return(rates_total);
  }

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

Практический порядок выбора подходов — от самого простого и эффективного до самого сложного:

  1. Локальные переменные без объекта — когда данные настолько просты, что объект не привносит никакой архитектурной ценности.
  2. Один постоянно существующий объект, повторно используемый на каждом тике, — подходит для подавляющего большинства схем работы индикаторов, использующих один объект на тик.
  3. Массив объектов с заранее выделенной памятью — когда одновременно должно существовать фиксированное небольшое число объектов.
  4. Пул объектов — когда большое, но ограниченное число однотипных короткоживущих объектов с высокой частотой получают из пула и возвращают в него.
  5. new/delete при каждом тике — избегайте этого в функциях OnCalculate() и OnTick().



Организация теста производительности

При каждом вызове функции OnCalculate(), соответствующем поступлению нового тика в режиме реального времени, индикатор выполняет два варианта вычислений. В первом варианте с помощью оператора new создаётся объект CSignalEvent, заполняются его поля, их значения считываются в локальные переменные, после чего объект немедленно удаляется. Во втором варианте объект CSignalEvent получают из пула, заполняют точно так же, считывают те же поля и возвращают объект в пул.

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

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

При первом полном проходе пересчета (prev_calculated == 0) индикатор возвращает rates_total - 1, не выполняя ни один из двух вариантов теста производительности. Значения для исторических баров остаются равными EMPTY_VALUE. После поступления тика в реальном времени на текущем баре начинают отображаться результаты измерений, которые обновляются с каждым последующим тиком. При закрытом рынке или редком поступлении тиков по торговому инструменту подокно будет оставаться пустым до получения тика в реальном времени — это корректное и предусмотренное поведение.

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


Схема теста производительности с двумя вариантами выполнения в OnCalculate().

Рисунок 4: Схема теста производительности с двумя вариантами выполнения в OnCalculate(). При первом проходе буферы заполняются значением EMPTY_VALUE, а тест производительности не запускается. На каждом тике в реальном времени оба варианта выполняют одинаковую работу с полезными данными объекта CSignalEvent: вариант без пула использует new и delete, а вариант с пулом — Acquire() и Release(). Перед записью в индикаторные буферы время выполнения в микросекундах, измеренное с помощью GetMicrosecondCount(), проверяется на соответствие диапазону допустимых значений.


Примечания по реализации индикаторных буферов

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

PLOT_EMPTY_VALUE должен быть равен EMPTY_VALUE, а не 0.0: если задать 0.0 в качестве маркера отсутствия данных, механизм построения графиков будет считать каждый слот буфера с нулевым значением — то есть всю инициализированную историю — не содержащим данных и не будет его отображать. Поскольку измеренное время обработки тиков при быстром выполнении также вполне может округляться до значений, близких к нулю, это приводило к тому, что подокно оставалось постоянно пустым. Правильный маркер отсутствия данных — EMPTY_VALUE (DBL_MAX): это значение никогда не может быть результатом реального измерения в микросекундах. Соответственно, буферы инициализируются значением EMPTY_VALUE с помощью ArrayInitialize(..., EMPTY_VALUE).

Функцию ArraySetAsSeries нельзя вызывать для буферов типа INDICATOR_DATA: терминал управляет индикаторными буферами, привязанными с помощью SetIndexBuffer(..., INDICATOR_DATA), в прямом порядке: индекс 0 соответствует самому старому бару, а индекс rates_total-1 — текущему бару. Вызов ArraySetAsSeries(true) для этих буферов обращает порядок индексации для вызывающего кода, но не меняет внутренний порядок индексации в терминале, из-за чего данные записываются не в те бары и график остается пустым. Для устранения этой проблемы необходимо удалить все вызовы ArraySetAsSeries для индикаторных буферов и обращаться к текущему бару по индексу rates_total - 1.

Для разреженных тиковых данных, поступающих в реальном времени, предпочтительнее использовать DRAW_SECTION, а не DRAW_LINE: DRAW_LINE соединяет каждую отображаемую точку со следующей прямым отрезком, в том числе перекрывая разрыв между последним историческим слотом со значением EMPTY_VALUE и первым измерением в реальном времени. На таймфреймах с редкими тиками, таких как H1, это приводит к появлению артефакта в виде почти вертикального скачка, который искажает данные. Режим DRAW_SECTION проводит линии только между последовательными непустыми барами, а в остальных местах оставляет разрывы, точно показывая, для каких именно баров были получены измерения в реальном времени.



Ожидаемая производительность и ограничения

Разница во времени выполнения двух вариантов обработки одного тика зависит от частоты поступления тиков и нагрузки на аллокатор. Для торгового инструмента с высокой частотой поступления тиков, например ETHUSD на таймфрейме M1, где тики поступают несколько раз в минуту, наблюдаемое время составляло примерно 25 мкс без пула объектов и 12 мкс с пулом объектов, что давало соотношение около 2,1. Линия времени выполнения без пула объектов также показывает выраженные колебания от тика к тику, зигзагообразно меняясь между измерениями, тогда как линия времени выполнения с пулом объектов проходит ниже и остается более ровной. Эти колебания и представляют собой джиттер времени выполнения, который устраняет пул и который зачастую важнее в реальной торговле, чем сама по себе разница в средних накладных расходах.

Для торгового инструмента с более низкой частотой поступления тиков, например EURUSD на таймфрейме H1, где тики поступают с интервалом в несколько минут, а нагрузка на кучу минимальна, наблюдаемое время составляло примерно 4–13 мкс без пула объектов и 2–7 мкс с пулом объектов. Это соотношение остается стабильным на уровне примерно 1,9–2,1, однако абсолютные значения меньше, поскольку аллокатор работает с "холодной", практически нефрагментированной кучей. Это прямо подтверждает вывод о том, что для торговых инструментов с редкими тиками зачастую достаточно одного повторно используемого объекта, а пул дает лишь незначительное дополнительное преимущество по сравнению с этой более простой альтернативой.

Наиболее наглядный диагностический показатель — линия Overhead Ratio ("Отношение накладных расходов") в подокне, которая для каждого тика показывает отношение времени выполнения без пула объектов ко времени выполнения с пулом объектов. Рост этого отношения в течение сеанса указывает на увеличение накладных расходов аллокатора, которые устраняются в варианте обработки с пулом. Постоянное соотношение в течение сеанса указывает на то, что аллокатор достаточно эффективно справляется с объектами данного размера при данной частоте тиков, поэтому приемлем любой из подходов.

Архитектурные ограничения

Потокобезопасность: пул не является потокобезопасным. В MQL5 каждый экземпляр индикатора или советника выполняется в одном потоке, что устраняет необходимость в мьютексе. Если в будущих обновлениях терминала будет реализовано параллельное выполнение, методам Acquire() и Release() потребуются атомарные операции над m_free_count.

Фиксированный размер: размер пула не изменяется динамически. Задайте размер пула в OnInit() исходя из максимального числа одновременно существующих объектов, определенного при профилировании. Предусмотрите небольшой запас. Если при эксплуатации пул часто исчерпывается (следите за предупреждением о NULL в журнале), увеличьте его вместимость и заново подключите индикатор.

Расходы памяти: пул хранит m_capacity экземпляров типа T на протяжении всего сеанса работы индикатора. Общий объем занимаемой памяти равен вместимости пула × sizeof(CSignalEvent). Для 64 объектов этот объем пренебрежимо мал, но при очень больших пулах или типах T очень большого размера следует учитывать фиксированный объем занимаемой памяти



Заключение

Многократное выделение памяти в куче в высокочастотных циклах индикатора приводит к накладным расходам и джиттеру времени выполнения в OnCalculate(), где стабильность времени выполнения важнее самой по себе средней скорости. Шаблон "пул объектов" решает эту проблему, ограничивая все операции с кучей этапом инициализации и используя операции с массивом индексов, выполняемые за время O(1), для последующего управления жизненным циклом объектов.

Реализация основана на двух ключевых архитектурных решениях. Во-первых, список свободных элементов на основе индексов вместо стека указателей позволяет сохранить проверку принадлежности пулу, при этом тип внутреннего массива остается независимым от параметра шаблона. Во-вторых, хранение индекса слота внутри каждого объекта с помощью SetPoolIndex() устраняет необходимость в переборе для проверки принадлежности при возврате объекта в пул, благодаря чему и Acquire(), и Release() выполняются за постоянное время независимо от вместимости пула.

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

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

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


Программы, использованные в статье:

#НазваниеТипОписание
1SignalEvent.mqhВключаемый файлКласс полезных данных CSignalEvent с поддержкой работы в пуле: содержит направление, цену, силу сигнала, временную метку и поля контракта пула (m_is_pooled, m_pool_index, m_in_use); метаданные строго отделены от полезных данных.
2ObjectPool.mqhВключаемый файлОбобщенный шаблон CObjectPool<T>: операции Acquire и Release со сложностью O(1) благодаря сохраненному индексу слота, защита от повторного возврата, фиксированная вместимость без резервного выделения памяти в куче при исчерпании пула.
3PoolBenchmark.mq5Пользовательский индикаторТест производительности, сравнивающий два варианта работы с объектами CSignalEvent — с выделением памяти в куче и с получением объектов из пула — путем измерения времени выполнения на каждом тике с помощью GetMicrosecondCount(). Использует DRAW_SECTION для корректного отображения разрывов, EMPTY_VALUE в качестве маркера отсутствия данных на графике и индексацию баров rates_total-1 без ArraySetAsSeries.
4Generic_Object_Pool_in_MQL5.zipАрхив в формате ZIPZIP-архив, содержащий все приложенные файлы с сохранением их путей относительно корневой папки терминала.

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22947

Прикрепленные файлы |
SignalEvent.mqh (8.68 KB)
ObjectPool.mqh (7.5 KB)
PoolBenchmark.mq5 (9.89 KB)
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
Создание объектно-ориентированного расчетного модуля сессионного VWAP на MQL5 Создание объектно-ориентированного расчетного модуля сессионного VWAP на MQL5
В этой статье показано, как реализовать сессионный VWAP на MQL5 в виде пригодного для повторного использования подключаемого класса, который строго сбрасывает накопленные значения в полночь по времени брокера. Расчетный модуль вычисляет VWAP и взвешенные по объему полосы отклонения только по закрытым барам и задает временную привязку накопления с помощью MqlDateTime, чтобы избежать искажений из-за отсутствующих свечей. Вспомогательный индикатор отображает базовую линию и полосы, а советник считывает сигналы один раз на бар, что обеспечивает согласованное выполнение, экономное использование ресурсов процессора и надежное тестирование.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Создание инструмента воспроизведения баров на MQL5 Создание инструмента воспроизведения баров на MQL5
В этой статье рассказывается, как создать на MQL5 для MetaTrader 5 интерактивный инструмент воспроизведения баров, который отображает исторические свечи по одной, не раскрывая будущих данных. Вы реализуете пользовательскую отрисовку свечей с помощью DRAW_COLOR_CANDLES, событийно-ориентированный механизм воспроизведения с обработчиками OnChartEvent и OnTimer, панель управления с кнопкой «Воспроизведение/Пауза», перетаскиваемый якорь воспроизведения и имитационную торговлю с кнопками «Buy» и «Sell» и линиями стоп-лосса (SL) и тейк-профита (TP). При этом активный бар будет оставаться в видимой области графика, чтобы вы могли отрабатывать навыки дискреционного анализа и исполнения сделок.