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)
{
}

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);

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));

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!

To prove this, here’s a tiny test script:

using System;
using UnityEngine;
 
public class TestScript : MonoBehaviour
{
	void Start()
	{
		TestFirst();
		TestSecond();
	}
 
	void TestFirst()
	{
		TestInstanceFunction();
		TestStaticFunction();
		TestLambda();
		TestAnonymousMethod();
	}
 
	void TestSecond()
	{
		TestInstanceFunction();
		TestStaticFunction();
		TestLambda();
		TestAnonymousMethod();
	}
 
	void TestInstanceFunction()
	{
		TakeDelegate(InstanceFunction);
	}
 
	void TestStaticFunction()
	{
		TakeDelegate(StaticFunction);
	}
 
	void TestLambda()
	{
		TakeDelegate(() => {});
	}
 
	void TestAnonymousMethod()
	{
		TakeDelegate(delegate(){});
	}
 
	void TakeDelegate(Action del)
	{
	}
 
	void InstanceFunction(){}
	static void StaticFunction(){}
}

If you run this and look at the Unity profiler in “deep” profiling mode you’ll see the first frame has this:

Delegate GC Alloc (After)

All four types of functions—instance functions, static functions, lambdas, and anonymous methods—allocate 104 bytes the first time you call TakeDelegateTakeDelegate, but the other types don’t. Why?

To find out, you have to decompile Library/ScriptAssemblies/Assembly-CSharp.dll and look at what else the compiler is generating on your behalf. Here’s what ILSpy will show you in C# mode:

using System;
using System.Runtime.CompilerServices;
using UnityEngine;
 
public class TestScript : MonoBehaviour
{
	[CompilerGenerated]
	private static Action <>f__mg$cache0;
 
	private void Start()
	{
		this.TestFirst();
		this.TestSecond();
	}
 
	private void TestFirst()
	{
		this.TestInstanceFunction();
		this.TestStaticFunction();
		this.TestLambda();
		this.TestAnonymousMethod();
	}
 
	private void TestSecond()
	{
		this.TestInstanceFunction();
		this.TestStaticFunction();
		this.TestLambda();
		this.TestAnonymousMethod();
	}
 
	private void TestInstanceFunction()
	{
		this.TakeDelegate(new Action(this.InstanceFunction));
	}
 
	private void TestStaticFunction()
	{
		if (TestScript.<>f__mg$cache0 == null)
		{
			TestScript.<>f__mg$cache0 = new Action(TestScript.StaticFunction);
		}
		this.TakeDelegate(TestScript.<>f__mg$cache0);
	}
 
	private void TestLambda()
	{
		this.TakeDelegate(delegate
		{
		});
	}
 
	private void TestAnonymousMethod()
	{
		this.TakeDelegate(delegate
		{
		});
	}
 
	private void TakeDelegate(Action del)
	{
	}
 
	private void InstanceFunction()
	{
	}
 
	private static void StaticFunction()
	{
	}
}

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:

.class public auto ansi beforefieldinit TestScript
	extends [UnityEngine]UnityEngine.MonoBehaviour
{
	// Fields
	.field private static class [System.Core]System.Action '<>f__mg$cache0'
	.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	.field private static class [System.Core]System.Action '<>f__am$cache0'
	.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	.field private static class [System.Core]System.Action '<>f__am$cache1'
	.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
 
	.method private hidebysig 
		instance void TestInstanceFunction () cil managed 
	{
		// Method begins at RVA 0x209d
		// Code size 20 (0x14)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldarg.0
		IL_0003: ldftn instance void TestScript::InstanceFunction()
		IL_0009: newobj instance void [System.Core]System.Action::.ctor(object, native int)
		IL_000e: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_0013: ret
	} // end of method TestScript::TestInstanceFunction
 
	.method private hidebysig 
		instance void TestStaticFunction () cil managed 
	{
		// Method begins at RVA 0x20b2
		// Code size 37 (0x25)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldsfld class [System.Core]System.Action TestScript::'<>f__mg$cache0'
		IL_0007: brtrue.s IL_001a
 
		IL_0009: ldnull
		IL_000a: ldftn void TestScript::StaticFunction()
		IL_0010: newobj instance void [System.Core]System.Action::.ctor(object, native int)
		IL_0015: stsfld class [System.Core]System.Action TestScript::'<>f__mg$cache0'
 
		IL_001a: ldsfld class [System.Core]System.Action TestScript::'<>f__mg$cache0'
		IL_001f: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_0024: ret
	} // end of method TestScript::TestStaticFunction
 
	.method private hidebysig 
		instance void TestLambda () cil managed 
	{
		// Method begins at RVA 0x20d8
		// Code size 37 (0x25)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache0'
		IL_0007: brtrue.s IL_001a
 
		IL_0009: ldnull
		IL_000a: ldftn void TestScript::'<TestLambda>m__0'()
		IL_0010: newobj instance void [System.Core]System.Action::.ctor(object, native int)
		IL_0015: stsfld class [System.Core]System.Action TestScript::'<>f__am$cache0'
 
		IL_001a: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache0'
		IL_001f: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_0024: ret
	} // end of method TestScript::TestLambda
 
	.method private hidebysig 
		instance void TestAnonymousMethod () cil managed 
	{
		// Method begins at RVA 0x20fe
		// Code size 37 (0x25)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache1'
		IL_0007: brtrue.s IL_001a
 
		IL_0009: ldnull
		IL_000a: ldftn void TestScript::'<TestAnonymousMethod>m__1'()
		IL_0010: newobj instance void [System.Core]System.Action::.ctor(object, native int)
		IL_0015: stsfld class [System.Core]System.Action TestScript::'<>f__am$cache1'
 
		IL_001a: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache1'
		IL_001f: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_0024: ret
	} // end of method TestScript::TestAnonymousMethod
}

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!

Here’s a modified version of the test script to test out that idea:

using System;
using UnityEngine;
 
public class TestScript : MonoBehaviour
{
	Action instanceFunctionDelegate;
	static Action staticFunctionDelegate;
	static Action lambdaDelegate;
	static Action anonymousMethodDelegate;
 
	void Start()
	{
		CreateDelegates();
		TestFirst();
		TestSecond();
	}
 
	void CreateDelegates()
	{
		CreateInstanceFunctionDelegate();
		CreateStaticFunctionDelegate();
		CreateLambdaDelegate();
		CreateAnonymousMethodDelegate();
	}
 
	void CreateInstanceFunctionDelegate()
	{
		instanceFunctionDelegate = InstanceFunction;
	}
 
	void CreateStaticFunctionDelegate()
	{
		staticFunctionDelegate = StaticFunction;
	}
 
	void CreateLambdaDelegate()
	{
		lambdaDelegate = () => {};
	}
 
	void CreateAnonymousMethodDelegate()
	{
		anonymousMethodDelegate = delegate(){};
	}
 
	void TestFirst()
	{
		TestInstanceFunction();
		TestStaticFunction();
		TestLambda();
		TestAnonymousMethod();
	}
 
	void TestSecond()
	{
		TestInstanceFunction();
		TestStaticFunction();
		TestLambda();
		TestAnonymousMethod();
	}
 
	void TestInstanceFunction()
	{
		TakeDelegate(instanceFunctionDelegate);
	}
 
	void TestStaticFunction()
	{
		TakeDelegate(staticFunctionDelegate);
	}
 
	void TestLambda()
	{
		TakeDelegate(lambdaDelegate);
	}
 
	void TestAnonymousMethod()
	{
		TakeDelegate(anonymousMethodDelegate);
	}
 
	void TakeDelegate(Action del)
	{
	}
 
	void InstanceFunction(){}
	static void StaticFunction(){}
}

And here’s how it looks in the profiler:

Delegate GC Alloc (Before)

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:

.class public auto ansi beforefieldinit TestScript
	extends [UnityEngine]UnityEngine.MonoBehaviour
{
	// Fields
	.field private class [System.Core]System.Action instanceFunctionDelegate
	.field private static class [System.Core]System.Action staticFunctionDelegate
	.field private static class [System.Core]System.Action lambdaDelegate
	.field private static class [System.Core]System.Action anonymousMethodDelegate
	.field private static class [System.Core]System.Action '<>f__mg$cache0'
	.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	.field private static class [System.Core]System.Action '<>f__am$cache0'
	.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	.field private static class [System.Core]System.Action '<>f__am$cache1'
	.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
 
	.method private hidebysig 
		instance void TestInstanceFunction () cil managed 
	{
		// Method begins at RVA 0x2142
		// Code size 14 (0xe)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldarg.0
		IL_0003: ldfld class [System.Core]System.Action TestScript::instanceFunctionDelegate
		IL_0008: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_000d: ret
	} // end of method TestScript::TestInstanceFunction
 
	.method private hidebysig 
		instance void TestStaticFunction () cil managed 
	{
		// Method begins at RVA 0x2151
		// Code size 13 (0xd)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldsfld class [System.Core]System.Action TestScript::staticFunctionDelegate
		IL_0007: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_000c: ret
	} // end of method TestScript::TestStaticFunction
 
	.method private hidebysig 
		instance void TestLambda () cil managed 
	{
		// Method begins at RVA 0x215f
		// Code size 13 (0xd)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldsfld class [System.Core]System.Action TestScript::lambdaDelegate
		IL_0007: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_000c: ret
	} // end of method TestScript::TestLambda
 
	.method private hidebysig 
		instance void TestAnonymousMethod () cil managed 
	{
		// Method begins at RVA 0x216d
		// Code size 13 (0xd)
		.maxstack 8
 
		IL_0000: nop
		IL_0001: ldarg.0
		IL_0002: ldsfld class [System.Core]System.Action TestScript::anonymousMethodDelegate
		IL_0007: call instance void TestScript::TakeDelegate(class [System.Core]System.Action)
		IL_000c: ret
	} // end of method TestScript::TestAnonymousMethod
}

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.