Порождающие шаблоны: Пул объектов (Object pool)

Название шаблона

Пул объектов (Object pool).

Тип

Порождающий шаблон проектирования (Creational).

Описание

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

Шаблон применяется для повышения производительности, если:

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

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

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

Плохой практикой является сокрытие Пула за другими порождающими шаблонами. Разработчик, использующий такой “гибрид”, не ожидает требования возврата объектов от, например, Фабричного метода. А без возврата объектов сам Пул становится бесполезным. В таком случае, правильным решением будет отделить реализацию классов, создающих объекты.

Особенности использования

  1. Пул ничего не знает о реализации хранимых объектов. Поэтому возвращенный объект считается находящимся в неопределенном состоянии. Для дальнейшего использования его необходимо перевести в начальное состояние (сбросить). Наличие объектов в неопределенном состоянии превращает Пул в “объектную клоаку” (object cesspool).
  2. Повторное использование может стать причиной утечки конфиденциальной информации. Поэтому необходимо обязательно очищать поля с секретными данными при сбросе, а сами данные – затирать или уничтожать.
  3. Возможна ситуация, когда в Пуле не останется свободных объектов. В этом случае реакция на запрос может быть следующая:
    • увеличение размера пула;
    • отказ в выдачи объекта;
    • постановка в очередь и ожидание освобождения объекта.

Схожие шаблоны и их отличия

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

Реализация шаблона в общем виде

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

Примеры реализации

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

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

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

/// <summary>The poolable object interface</summary>
public interface IPoolable
{
    /// <summary>Resets the object's state.</summary>
    void ResetState();
}

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

/// <summary>The pool object creator interface.</summary>
/// <typeparam name="T">Type of the objects to create.</typeparam>
public interface IPoolObjectCreator<T>
{
    /// <summary>Creates new object for a pool.</summary>
    /// <returns>The object.</returns>
    T Create();
}

Сразу реализуем данный интерфейс в виде generic класса для создания с экземпляров с помощью конструктора без параметров:

public class DefaultObjectCreator<T> : IPoolObjectCreator<T> where T : class, new()
{
    T IPoolObjectCreator<T>.Create()
    {
        return new T();
    }
}

Перейдем к созданию непосредственно Пула. В качестве контейнера объектов используем класс ConcurrentBag, реализованный в .NET4. Его важными особенностями являются потокобезопасность, а так же быстрые операции добавления и удаления объектов. Для ранних версий .NET можно использовать, например, класс ArrayList. Но в этом случае необходимо самостоятельно позаботиться о потокобезопасности.

Поскольку размер пула не фиксированный, то его метод GetObject() всегда возвращает объект. Это может быть объект взятый из контейнера или, если он пустой, созданный.

Метод ReturnObject() помещает объект обратно в контейнер. При этом осуществляется сброс его состояния. Кроме того, переменной, содержавшей ссылку на него, присваивается значение null. Таком образом, объект может находиться или в контейнере или вне него. К сожалению средствами Пула не возможно гарантировать возврат объекта и отсутствие дополнительных ссылок на него. Все это оставляем “на совести” клиентского кода. В данной реализации, если объект не будет возвращён, то через какое-то время он будет просто уничтожен сборщиком мусора.

Свойство Count показывает сколько объектов в данный момент находится в пуле.

Полный код класса, реализующего шаблон “Пул объектов”, приведен ниже:

public class ObjectPool<T> where T : class, IPoolable
{
    /// <summary>Object container. ConcurrentBag is tread-safe class.</summary>
    private readonly ConcurrentBag<T> _container = new ConcurrentBag<T>();

    /// <summary>Object creator interface.</summary>
    private readonly IPoolObjectCreator<T> _objectCreator;

    /// <summary>Total instances.</summary>
    public int Count { get { return this._container.Count; } }

    /// <summary>
    /// Initializes a new instance of the <see cref="T:ObjectPool"/> class.
    /// </summary>
    /// <param name="creator">Interface of the object creator. It can't be null.</param>
    public ObjectPool(IPoolObjectCreator<T> creator)
    {
        if (creator == null) {
            throw new ArgumentNullException("creator can't be null");
        }

        this._objectCreator = creator;
    }

    /// <summary>Gets an object from the pool.</summary>
    /// <returns>An object.</returns>
    public T GetObject()
    {
        T obj;
        if (this._container.TryTake(out obj)) {
            return obj;
        }

        return this._objectCreator.Create();
    }

    /// <summary>Returns the specified object to the pool.</summary>
    /// <param name="obj">The object to return.</param>
    public void ReturnObject (ref T obj) 
    {
        obj.ResetState();
        this._container.Add(obj);
        obj = null;
    }
}

Проведем небольшой тест. Для этого напишем демонстрационный класс и класс, для его создания.

public class TestObject : IPoolable
{
    public int Index { get; set; }

    public TestObject()
    {
        Console.WriteLine("TestObject constructor.");
        this.Index = -1; 
    }

    void IPoolable.ResetState() 
    {
        this.Index = -1;
    }
}

Как видно из исходного кода, каждый вызов конструктора будет выводить уведомление на консоль.

Для примера, сравним два варианта цикла:

public static void Test()
{
    var pool = new ObjectPool<TestObject>(new DefaultObjectCreator<TestObject>());


    Console.WriteLine("1) Using 'new' keyword ...");
    for (int i = 0; i < 10; i++) {
        TestObject obj = new TestObject();
        // Do something with the test object
        obj.Index = i;
    }

    Console.WriteLine("2) Using Object pool ...");
    for (int i = 0; i < 10; i++) {
        TestObject obj = pool.GetObject();
        // Do something with the test object
        obj.Index = i;
        pool.ReturnObject(ref obj);
    }
}

Если запустить данный код, то будет выведено 10 уведомлений о срабатывании конструктора для цикла с ключевым словом new и всего лишь одно для варианта с Пулом. Разница будет больше, если увеличить число повторений тела цикла до 100 и более. Явный выигрыш по размеру используемой памяти.

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

Обратите внимание, что код получает объект из Пула таким, каким бы он был после вызова конструктора. Это обеспечивается сбросом состояния.

Особенности реализации в .NET и C#

Для начала вспомним некоторые факты о работе с памятью в .NET:

  1. Сборка мусора в .NET происходит по поколениям. И чем старшее поколение, тем реже в нем происходит сборка мусора.
  2. Объекты, за исключением больших, создаются в нулевом поколении. Они переходят в следующее, если выживают после очередной сборки мусора.
  3. Чем больше будет интенсивность создания объектов, тем чаще будут происходить сборки мусора.

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

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

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