The Problems with Object Pools
It’s extremely common to see somebody ask a question about avoiding the garbage collector only to be answered with “just use a pool” as if that immediately and totally solved the problem. While pools will often keep the garbage collector at bay, they’ll also introduce a whole slew of new problems that you’ve got to deal with instead. Today’s article goes through several of these problems so you’ll be aware of the tradeoffs involved and hopefully avoid some pitfalls.
Object Pools Recap
We want to avoid frame spikes/hitches in our games caused by the slow, all-at-once, memory-fragmenting, main thread-blocking garbage collection that Unity provides. So we often turn to an object pool which is designed to hold objects in a list and never release the references for the GC to collect. I’ve provided a simple pool before and there are many more if you search online. Here’s the one I made as an example of your typical pool:
public interface IPoolableObject<TInitArgs> where TInitArgs : struct { void Init(TInitArgs initArgs); void Release(); } public class ObjectPool<TObject, TInitArgs> where TObject : class, IPoolableObject<TInitArgs>, new() where TInitArgs : struct { private readonly Stack<TObject> unused = new Stack<TObject>(); public TObject Get(TInitArgs initArgs) { var obj = unused.Count == 0 ? new TObject() : unused.Pop(); obj.Init(initArgs); return obj; } public void Release(TObject obj) { obj.Release(); unused.Push(obj); } }
It’s really simple! Instead of using new
, call ObjectPool.Get
and it’ll reuse an object from the pool if one’s available. When you’re done with it, simply call ObjectPool.Release
and it’ll go back on the Stack
. So what could possibly go wrong?
Requires explicit free
For starters, when you’re done with a pooled object you must remember to put it back in the pool. That sounds easy until you realize that if you forget you’ll get basically no feedback alerting you of that. There’s no compiler error or warning, no debug logs will be printed, and the game won’t crash. Just by overwriting a variable you’ll release the object it references and garbage collection will eventually occur.
The only way you’ll find that this is happening is if you profile the game and watch for garbage collection, probably after noticing GC-caused frame hitches. Then you get to backtrack over all the frames since the last garbage collection and look at the “GC alloc” column to find out what was allocated and guess which of those objects weren’t returned to the pool. The whole process is horribly time-consuming and tedious. There’s basically no tools to support you in the process and all it takes to bring on this mess is to omit one simple line of code.
Contents not released when returned to pool
But let’s say you never forget to put objects back in the pool. What else could go wrong? Well, you have those Init
and Release
functions on your IPooledObject
type and that’s where you’re supposed to reset the object’s state so it’s fresh when it’s reused. Unfortunately, it’s really easy to forget to properly reset an object’s state. You can easily add a field and forget to reset it in Release
. You might have complex objects that need complex resetting. For example, you might have a IPooledObject
field that you need to put back in its own pool.
If you forget to do any of this then the object’s state will “leak” between uses of it. That’s also really hard to track down! You might wonder why—intermittently—your enemies start off with a buff on them or with 30% of their health missing. You’ll have to—somehow—find out that it’s because the enemy’s state wasn’t properly reset when released. Again, there’s no tooling to give you warnings or errors that you’ve simply omitted a line of code or two. You’ll have to slog through a debugging session trying to find out why your enemies are in a weird state… sometimes.
Multiple references to pooled object
Another nasty issue crops up when you have multiple references to a pooled object. With garbage collection, the object will be cleaned up automatically after the last reference is released. With pooling you need to manually keep track of how many references you have to an object. That means every time you pass a pooled object to a function or store it on a field of a class you need to remember to increment a reference count somewhere. Every time those functions return or those classes overwrite their reference field you’ve got to remember to decrement that number. And don’t forget about closures and coroutines, which secretly create classes with fields for your local variables because you’ll need to count those too.
If you forget even one increment or one decrement then your reference count will be inaccurate. If it doesn’t drop to zero the object will never be put back into the pool. If it drops to zero before all the references are released then the remaining references will continue to use an object that has (hopefully) been cleaned up or even reused by another area of the code. Again, there’s no tooling to warn you about this and you’ll need to track down why—sometimes—damaging one enemy damages another one and all sorts of other tricky issues like that.
Hard to pool collections
Collections like arrays, List
, and Dictionary
are essential building blocks for most Unity games. They’re also hard to pool! Imagine you wrap an int[]
in a PooledIntArray
class. How likely is it that another chunk of code will want to get an array of that exact length? Not very. If you use a List
you can change the length, but there are still issues. First, as the list grows it’ll internally release its reference to its array and copy the elements to a new, larger array. You can’t interject into that process to insert your own pool of arrays. You could make a list that starts with a really big capacity and hope you never exceed it, but then your memory usage will always be at the “high water mark” of what you need. The same problems apply to Dictionary
and the other collection types. How do you work around this? Pools don’t provide a solution here.
Adds overhead to pooled types
The pool itself contains some kind of list of objects, like the Stack
in the above example. That’s memory that you wouldn’t have otherwise used and even more if the list needs to resize due to an excess of objects being released. The pool class and it’s list are also GC allocated and tracked objects, but hopefully you never release them. More advanced pools will need even more memory to store data indicating which objects are in the pool or out of it. And there’s a bunch of virtual function calls involved, like Get
and Release
. This is all CPU and memory overhead on top of what you’d normally use without object pools.
Not thread-safe by default
Using the new
operator and setting a reference to null
are completely thread-safe operations. When pools get involved the waters become murky. You could create one pool per thread, but that’s inefficient for short-lived threads and because objects aren’t reused across threads. Or you could create a single pool for all threads and add lock
statements in your Get
and Release
functions. That’s even more overhead on top of the above.
There are also reentrancy problems. When the pool calls Init
or Release
, those functions may call back into the pool to Get
or Release
. For more advanced pools than the simple one above, that could cause major problems with the state of the pool itself and possibly affect far-reaching areas of the game. The lock
statement won’t help here since it only protects against multiple threads running the same code. So you’ll need even more overhead, such as a volatile bool
flag that you set and check everywhere. It’s a lot of overhead and complexity that’s difficult to get right and expensive even if you do.
No default constructor
Pools like the above need to be able to create objects when the pool is empty, so they add a where T : new()
constraint so there’s guaranteed to be a default constructor. The trouble is that many classes don’t have a default constructor. Consider a Person
class that needs a first name, last name, and age in order to construct into a valid state. Instead, it’ll need to have a default constructor that leaves those strings null
(or empty) and that age at something invalid like zero or -1. The Init
function will be called right away, but only the pool is set up to do that right every time. It’s easy to write code that just calls new Person
and skips the pool and the Init
function. Once again there are no warnings that a vital function has been skipped and only later on will there be a crash due to a null
string or some bizarre arithmetic due to a -1 age.
Awkward to use
Finally, pools are simply awkward to use. You need a reference to the pool in order to get or release objects. To get that reference you need to pass it to each function that creates an object, like you would with a factory. That bloats up parameter lists. It also seems strange that a caller of a function would know how the function is going to create or release an object.
To tackle that daisy chain of parameter passing the pool, you’ll probably be very tempted to make a global pool variable of some sort. You might call it a “singleton” or a “service” or a “manager”, but it’ll be globally accessible from every function in the game. That may be acceptable in this case, but it forces the “one pool for all threads” approach that will require locks unless your whole game is single-threaded.
You’ve also got to implement IPoolableObject
in order to pool the objects of some class. Since you don’t have control over the many classes already defined by Unity, .NET, and other libraries, you can’t pool them. So if you want to pool a StringBuilder
then you’ve got to wrap it in a PoolableStringBuilder
type of class. Whenever you create one of those you’ll be allocating two objects: the wrapper itself and the wrapped object. That’s more overhead, more pressure on the GC, and more awkward code.
Conclusion
Object pools are not a panacea. They don’t easily and totally solve the problem of garbage collection in Unity games. Depending on your perspective, the cure might even be worse than the disease. They can be a useful tool in fighting frame spikes/hitches. If you decide to use them, don’t do so lightly. Keep the above downsides in mind when considering the tradeoffs.
#1 by Todd Ogrin on April 17th, 2017 ·
As for terrible trade-offs, here’s another good one: You know that GitHub project or Asset Store product that does exactly what you need for a critical part of your project? Well, it doesn’t pool. You’re faced with the unappealing choice of accepting it and its garbage-creating behaviors as-is, or forking and installing after-market pooling functionality yourself.
I’d add a few other observations of my own.
1) Obviously, reusing objects necessarily means that borrowed items are returned, but do all borrowed items really need to be returned? Some will always be out in the wild, but anything that’s just dereferenced completely will be GCed. This is what we’re trying to avoid, obviously, but it’s really a better vs. perfect proposition. Can you afford any slack?
2) Regarding the global singleton/service/manager… I’ve chosen this approach myself, at least for development purposes. Later, I may introduce a global class with static pool instances to eliminate looking up pools by type, but it’s nice just to borrow from a pool without worrying if it’s been instantiated yet. It also helps when working across libraries. e.g. when Project A imports Projects B & C, and they all need a pool of Things, which of them declares Pool? Project D, which just declares pools? Or do A, B, and C each have their own Pool?
3) I think of garbage collection as a side-effect of a poorly utilized pool. So, it’s worth investing in a few simple pool metrics to uncover problems well before you have to look at GC stats. For each pool, I track number of objects created, borrowed, returned, and outstanding. These usually reveal problems like “returns > borrows” meaning I’m either newing stuff and sending it into the pool or I’m returning instances more than once. Another good one is “borrows > returns” meaning I may be using the pool just to new() stuff that is later GCed, and I’m missing some returns.
Overall, after about 3 weeks of work, my pooling efforts are edging away from “total disaster” towards “not-entirely-intolerable.” I have a feeling that’s where the needle will stop moving.
#2 by jackson on April 17th, 2017 ·
All excellent points!
> Libraries that create garbage
A third option is to fork the library, fix the garbage creation, then submit a pull request. That will get you out of maintaining your own fork, but you’ll still have to do the initial work. Of course it may become awkward due to the placement of the pool code, as you point out in #2.
> Never returned objects
There are definitely some objects that, once allocated, are always used and therefore never returned. For example, you might have some network code that’s keeping a persistent connection open to a chat server. In that case, it doesn’t really matter if you get the object out of the pool or create one directly with
new
. The only advantage to the pool is that you might get to borrow an object, but you’ll have to live with other pooling risks as described in the article.> Pool and project architecture
This is one case where a global system of some sort may be justified. After all,
new
is globally accessible and you’re trying to replicate that system with the pool. There are, of course, advantages and disadvantages to those globals over “dependency injection” so the decision isn’t straightforward either way. As for project organization, it’s the classic problem of where to put a “used everywhere” chunk of code. Do you just throw it in some “utils” library that everything uses? Do you copy/paste the code into all your projects to avoid a complex system of dependencies that Unity doesn’t deal well with? This too probably depends on the project in question.> Stat tracking
Yes! Exposing stats seems like a great way to avoid a lot more complicated debugging via tools like the profiler. I can easily imagine simple report generators that print out these pool stats from a menu item in the editor or a GUI console or “cheat gesture” on device. For a super sophisticated approach that includes some amazing tooling, check out this CppCon video by Electronic Arts.
Hang in there with the pool code. I get the feeling that the Unity world could use several pooling proposals that eventually borrow enough ideas from each other that we have something “not-entirely-intolerable”. :)
#3 by Stephen Hodgson on April 17th, 2017 ·
Something else to consider as well is allocations create from enabling and disabling gameobjects from your pool.
#4 by jackson on April 17th, 2017 ·
Yes, this is very important and something I didn’t explicitly mention in the article. If your code for
Init
orRelease
call other code that creates garbage then you’ve got to account for that somehow. It may be unavoidable in the case of some Unity APIs, but in other cases you may be able to get around it. For example, if your pooledPlayer
class has aList<Buff>
then you could callClear
on it duringRelease
instead of releasing the reference to it. Likewise, if it had aWeapon
field that was also pooled it could be put back into its pool duringRelease
and gotten back from that same pool duringInit
rather than releasing the reference for GC and usingnew
to create a new one.#5 by Developer on March 17th, 2020 ·
Premature optimization is the root of all evil.
Do you REALLY have garbage collection problems? I really doubt so.
#6 by jackson on March 17th, 2020 ·
Every Unity game I’ve ever worked on has had GC issues. It’s a very common problem in Unity game development even outside of just my projects. This is definitely not premature optimization, but rather solving very real problems that are seriously affecting the game’s quality.
#7 by AlsoDeveloper on June 1st, 2021 ·
I’m concerned that you think you DON’T have GC problems in a Unity game.
#8 by None on January 3rd, 2021 ·
You can release lists before the main GC using destructors and asking explicitly for the GC to ignore your Finalize method (and thus, not collecting it in the main GC).
This is the same as C free (you can control the GC, forcing a collect (which will not be faster but, at least, the main GC will not have so much work when it collects – you do a lot of collecting instead of one big collecting)).
You can even implement a pool inside a list (mitigating even more the crappy Unity GC), because now you have real control of creation and (real) destruction of objects.
And, besides that, .net allows you to work with pointers, so you can alloc and free managed resources freely with C# (but, honestly, in this case, seems better to use a real C/C++ engine).