C# is full of syntactic sugars... lambdas, linq, foreach, properties, actions, events, async, await. These features make the language fun and fast to use, but, carry with it the hidden cost of garbage.
Garbage consists of transient objects located on the heap memory which have no references into user code. Garbage can induce application slowdowns as garbage collection needs to run to release these expired references.
Do Use Structs
// 0 garbage public struct EntityHealthData { public int Health; }
Structs (value types) are a must for any high end software. By living on the stack memory they are easier to access for the CPU, especially when used in lists or arrays.
Don't Pass Structs As Objects
void MyMethod(object value); void Start() { bool myStruct = true; //24 bytes of garbage MyMethod(myStruct); }
This is a common problem found in framework code that aims to be type agnostic. When we call MyMethod We wrap our struct into a object for consumption by MyMethod causing a heap allocation of about 24 bytes. This process is called boxing.
Don't Cast Structs As Interfaces
public Bullet : INetData { public int Damage; } //24 bytes of garbage void Send(INetData data);
Interfaces are heaped object, so our value type is once again boxed into an object.
Do Use Generic Covariance
public Bullet : INetData { public int Damage; } //0 bytes of garbage void Send<T>(T data) where T : INetData;
In the above example, calling send produces no garbage.
Generic methods are the correct way to make a type agnostic method which consumes value types. When compiled, a version of this method is generated for each type, so no boxing is required.
Don't use excessive events
public event Action OnRaiseChange; public void AddListener() { //24 bytes of garbage OnRaiseChange += Handle; } public void Handle() { // Do Something! }
When we use the += operator, we add our Handle delegate to the event list. Internally this calls Delegate.Combine which requires casting the delegate into an Action and combining it with the previous Action delegate. This process generates 208 bytes of garbage per call.
Don't Iterate Through IEnumerables
public IEnumerable<T> Players; public void Start() { foreach(var player in Players) { //24 bytes of garbage } }
When we access an IEnumerable a Enumerator is created. Enumerators are structs, but because it is accessed through the IEnumerable interface, we cause our struct to get boxed.
Additionally, I recommend always casting a IEnumerable to a concrete type. IEnumerables are expressions and is thus have lazy evaluation. In other words whenever we access the Enumerable the data source is reevaluate and we could get desynced data.
Do Preallocate Arrays and Lists
var BadPlayers = new List<TPlayer>(); var GoodPlayers = new List<TPlayer>(16); var BestPlayers = new TPlayer[16]; BadPlayers.Add(myPlayer); // 78 bytes of garbage GoodPlayers.Add(myPlayer); // 0 bytes of garbage
Each time a list needs to resize its internal array it generates 78 bytes of garbage. Please initialize them with a size, or better yet use an array.
Don't Use System.Equals
public bool BetterEqual(int a, int b) { //24 bytes of garbage return System.Equals(a, b); }
Many of the older .Net 2.0 System methods box structs as objects, generating garbage.
This can be especially frustrating when using Enums which idiomatically are value types, but when used with the system helper classes are immediately boxed.
As a workaround use the generic EqualityComparer<T> class.
Don't abuse anonymous methods
//24 bytes of garbage await networkService.Post(request, response => { //handle here });
When we declare an anonymous method using the fat arrow operator, we are instantiating a heaped object to keep state and instantiating it.
A better solution is to write a proper method for handling our response.
Don't Spam Tasks
foreach(var actionData in actionQueue) { await networkService.Post(actionData); }
As a Task enthusiast this one is hard for me. When I compare my options of how to handle asynchronous transactions Tasks are great. They are easy to read, and consolidate code.
Internally they are a Synchronization Context with a callback. The Synchronization Context is the killer here. When writing my networking library I originally used tasks and discovered I wasted 30% of my cpu throughput on context switching. This was compared against just using a dedicated thread and a circular queue.
For those not familiar, context switching is the system returning scope back to the calling thread. So if you start a task on thread A, it runs on thread B, it should return on thread A.
As a workaround use the ConfigureAwait extension method. That said, for games especially, having an concurrent or circular queue that runs synchronously is a best practice.
foreach(var actionData in actionQueue) { await networkService.Post(actionData).ConfigureAwait(false); }
Conclusion
Some of these suggestions can be considered Micro Optimizations, so please use your best judgment when implementing. These are best saved for Framework and Networking code. For general user code, please favor readability and maintainability.