Mastering C# and Unity3D

Delegates and Garbage Creation

Two facts are at odds in Unity programming. First, delegates like Action, Func, and EventHandler are extremely common with or without events. Second, the garbage collector is a huge source of CPU spikes and memory fragmentation in our games. Why are these facts at odds? Because code that uses delegates is almost always written in a way that creates garbage. It’s an extremely easy trap to fall into, but this article will show you how to get out of it!

Consider a very simple function:

void TakeDelegate(Action del){}

void TakeDelegate(Action del)
{
}

You’ve probably seen a thousand functions like this. Array.Find takes a delegate and so does List.RemoveAll. You’ve probably passed a thousand “callback” variables, each some kind of delegate. The same goes for every event keyword you’ve ever seen: they’re just thinly veiled delegates.

So delegates are everywhere, but who cares? Well, if you care about preventing CPU spikes and not running out of memory due to fragmentation then you should care. That’s because the simplest way of calling that TakeDelegate function involves creating 104 bytes of garbage that the garbage collector (GC) will someday collect. All in one frame. On the main thread. And fragment your memory.

Here’s the simple way that almost all code calls TakeDelegate:

void MyFunction(){}
TakeDelegate(MyFunction);

void MyFunction()
{
}
TakeDelegate(MyFunction);

Did you spot the garbage creation? It’s really hard to see because the garbage creation is inserted into our code by the compiler! The compiler rewrites our code to leave out a missing step:

TakeDelegate(new Action(MyFunction));

TakeDelegate(new Action(MyFunction));

And there you see the dreaded new keyword. Every one of these calls to TakeDelegate creates a new Action which is 104 bytes of garbage. What if you call it every frame in your 30 FPS game? That’s about 3 KB of garbage per second for just that one function call!

For the static function, ILSpy generated a static Action variable and adds a null check every time you try to call TakeDelegate. The effect is that only one delegate is ever created for the static function and that explains why the second time you call TakeDelegate there’s no garbage created.

For the lambda and anonymous method you just see delegate{} which isn’t very helpful. You have to switch to IL mode to see the actual IL instead of a C# representation of it. Here’s a snippet of the most interesting parts:

At the top you can see that the compiler generated not one, but three static Action fields. In TestInstanceFunction you can see the new/newobj for Action. The other three types are all implemented the same way and they all do a null check and reuse their static, cached field.

At this point you’ve seen that passing an instance function for a delegate parameter always creates garbage and passing any other type of function makes the compiler insert an if every time. Both of these are undesirable. You really want to avoid the garbage creation and kind of want to avoid the slow branching of if.

Thankfully, you can take manual control and make your own cached Action fields. Then you can set them up when it’s convenient for you and avoid the if check every time you use them. Even better, you can cache an Action for instance functions and avoid the garbage creation!

You can see that the garbage is created when setting up the cached Action fields but never when actually using them later. The first goal is accomplished: passing instance methods no longer creates garbage. But what about the if check? To confirm that it’s gone, check out the IL now:

Now all four of these functions is implemented the same. They simply pass their static field to TakeDelegate without any null check. Unfortunately, the compiler leaves in its own cache fields so there’s duplication now. So you have a tradeoff: do you want duplicate fields or duplicate null checks?

With this strategy you can get control over the garbage that’s created when you use delegates. It’s really easy to cache the delegates as fields and the GC win can be quite large. So keep this in mind next time you’re passing a callback!

Let me know in the comments how you deal with garbage creation and delegates.