ObjectPool - Rent and return some instances

Just imagine a car pool: There is a dealer which bought the car and lent's it to you. After a while you will return this car where you got it from. Much like that works an ObjectPool in C#. You can rent an expensive object from the pool and when you are done with it, you just return it. Sounds beautiful, doesn't it?

ObjectPools are exactly used for that. If you create often times new objects which are quite expensive it is worth trying to keep them in a pool. So everytime you might want to get a new object you just ask the pool and in best case he has already an object, otherwise it will create one.

If we take the car analogy. Without a car pool: you will always buy a new car when you need one and sell it when you are done. With a car pool you just go to the dealer which offers you that service and get the car from their and after you are done with your tour you just bring it back. So the dealer takes care of managing all those cars on his premise.

Simple ObjectPool implementation

Let's look at a very simple implementation. We basically use two main concepts:

  • A ConcurrentBag to manage all the objects in the pool. We are using a ConcurrentBag instead of a List<T> so we don't have to take care of locks and other multithreading stuff. We want it nice, simple and maintainable.
  • We also need a generator function. The ObjectPool is generic, so he doesn't have the slightest idea how to create new object. We have to tell him that.
public class ObjectPool<T>
{
    private readonly ConcurrentBag<T> _objects;
    private readonly Func<T> _objectGenerator;

    /// <summary>
    /// Initializes the ObjectPool.
    /// </summary>
    /// <param name="objectGenerator">We need a generator function to create an object if our pool is empty.</param>
    public ObjectPool(Func<T> objectGenerator)
    {
        _objectGenerator = objectGenerator ?? throw new ArgumentNullException(nameof(objectGenerator));
        _objects = new ConcurrentBag<T>();
    }
    
    public T Rent() => _objects.TryTake(out T item) ? item : _objectGenerator();

    public void Return(T item) => _objects.Add(item);
}

The two main functions we need is Rent and Return. When a consumer calls Rent he basically asks for an object out of the pool. If it doesn't exist then unfortunately we have to create it but if it is exists then we are lucky and just return one of the parked objects. (If we take the car analogy again, if the customer asks for a car and there is none then we just buy a new one and hand it over to the customer but if we already have a car we just give him that).

Return just gives back the object to the pool (the customer brings back the car).

?? Info: Please be aware that there is already a very good ObjectPool coming with the framework itself. The implementation shown above is for educational purposes even though it is perfectly working.

ObjectPool in action

Imagine we have the following "heavy" class:

public class MyHeavyClass
{
    private int[] someArray;

    public MyHeavyClass()
    {
        Console.WriteLine("ctor was called");
        someArray = new int [1000];
        Array.Fill(someArray, 10);
    }
}

Then we can utilize our ObjectPool like this:

// Tell the ObjectPool how to create one of our instances
var objectPool = new ObjectPool<MyHeavyClass>(() => new MyHeavyClass());

// Our pool is empty so create an instance
var instanceA = objectPool.Rent();
var instanceB = objectPool.Rent();

// Return one of our instances to the pool
objectPool.Return(instanceA);

// The pool has one instance so we don't need to create the object
var instanceC = objectPool.Rent();

The output would be:

ctor was called
ctor was called

The constructor from our HeavyClass was only called twice even though we askes three times for the object. The reason is that we returned one of the instances back to the pool. So one instance was available at the last call to Rent.

The upsides

The advantage of that is to reduce the amount of objects created. Especially in a tight loop it can make a lot of sense to reuse objects. GC pressure is smaller and your code doesn't have to take care of creating instances.

Let's see it in a benchmark:

[MemoryDiagnoser]
public class PoolBenchmark
{
    private const int LoopCount = 10;

    [Benchmark(Baseline = true)]
    public int CreateNewObjects()
    {
        var sum = 0;
        for (var i = 0; i < LoopCount; i++)
        {
            var obj = new MyHeavyClass();
            sum += obj.Length;
        }
        
        return sum;
    }

    [Benchmark]
    public int ObjectPool()
    {
        var  pool = new ObjectPool<MyHeavyClass>(() => new MyHeavyClass());
        var sum = 0;
        for (var i = 0; i < LoopCount; i++)
        {
            var obj = pool.Rent();
            sum += obj.Length;
            pool.Return(obj);
        }
        
        return sum;
    }
}

public class MyHeavyClass
{
    private readonly double[] _someArray;

    public MyHeavyClass()
    {
        _someArray = new double[5000];
        Array.Fill(_someArray, 10);
    }

    public int Length => _someArray.Length;
}

Results:

|           Method |      Mean |     Error |    StdDev | Ratio |   Gen 0 |   Gen 1 |  Gen 2 | Allocated |
|----------------- |----------:|----------:|----------:|------:|--------:|--------:|-------:|----------:|
| CreateNewObjects | 25.823 us | 0.3793 us | 0.3548 us |  1.00 | 95.2148 | 11.8713 |      - |    391 KB |
|       ObjectPool |  8.050 us | 0.1594 us | 0.1835 us |  0.31 |  6.4392 |  3.2196 | 0.6409 |     40 KB |

Our ObjectPool is 3x faster and takes 10x less space due to the fact that the for loop is run 10 times. Keep in mind this example favors the ObjectPool to show you what your could gain from it.

The downsides

Great. Why not using it everywhere then? Well as always it comes with trade-offs.

  1. Just imagine you introduced a scratch into the car which you rented from a company. The company will most likely not repair it. That said the next customer getting that car will also have the scratch in the car. So the state of the car stays the same. And that holds true for our ObjectPool as well. If we have stateful objects and returning them to the pool. The next person asking for an object from the pool will receive that very stateful object.
  2. It adds more complexity. It is another layer in your application logic.
  3. Going back to the cars: When you rent a car, you have to bring it back. And it also works here the same way. Once an object is given out by the pool it should be returned, otherwise we will create new objects over and over again defeating the initial purpose. As a developer you are normally not forced to return your object when you are done. The GC will take care of it, but with the pool you have this responsibility.
  4. And last but not least: We keep objects alive. At least as long as our ObjectPool is alive.

Conclusion

ObjectPools can be a great way to reduce GC pressure and keep your application swift! But also keep in mind that it comes with a price tag.

Resources

Delete a record without prior loading in Entity Framework

Sometimes you have an Id of an object and want to delete the underlying thing from the database. But it doesn't make sense to load the whole object from the database to memory first. So how can we achieve this quickly?

Generator-Function in C# - What does yield do?

Since the introduction of C# 2.0 we have the yield keyword with in combination with the IEnumerable<T> type works as a generator function. We can return elements one by one.

But how does that thing work internally? And what does it have to do with async / await?

Wrap Event based functions into awaitable Tasks - Meet TaskCompletionSource

You might have code where an object offers you an event to notify you when a specific operation is done. But event's can be tricky to use, especially when you want to have a continuous flow in your application.

That is where TaskCompletionSource comes into play. We can "transform" an event based function into something which is await-able from the outside world via the await keyword.

An error has occurred. This application may no longer respond until reloaded. Reload x