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 aConcurrentBag
instead of aList<T>
so we don't have to take care oflock
s 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.
- 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.
- It adds more complexity. It is another layer in your application logic.
- 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.
- And last but not least: We keep objects alive. At least as long as our
ObjectPool
is alive.
Conclusion
ObjectPool
s 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
- ObjectPool in ASP.NET Core
- This example can be found here
- Majority of the examples can be found here