How TEngine's Zero-GC Event System Works Internally: Architecture and Implementation
TEngine’s zero-GC event system eliminates runtime allocations by using compile-time generated integer event IDs, generic delegate storage in EventDispatcher, and direct invocation chains that avoid boxing and temporary array creation.
The alex-rachel/tengine repository provides a high-performance event bus specifically engineered for Unity's garbage-collected runtime. Unlike traditional C# event systems that allocate memory through string hashing, boxing, and params object[] arrays, TEngine's implementation maintains zero garbage collection pressure even when dispatching millions of events per frame during intensive gameplay scenarios.
Compile-Time Event ID Generation
The foundation of the zero-GC architecture rests on replacing string-based event identifiers with integer constants generated at compile time.
In EventInterfaceAttribute.cs, a simple marker attribute identifies which interfaces should participate in code generation:
// EventInterfaceAttribute.cs
[AttributeUsage(AttributeTargets.Interface)]
public class EventInterfaceAttribute : Attribute { }
The EventInterfaceGenerator.cs source generator then scans for these attributes and emits a static class containing const int fields for each method in the interface. This occurs during compilation, eliminating reflection and string hashing at runtime. For an interface IGameEvents with methods OnHealthChanged(float) and OnPlayerDied(), the generator produces:
public static class GameEvents
{
public const int OnHealthChanged = 10001;
public const int OnPlayerDied = 10002;
}
Because these are compile-time constants, the JIT compiler can inline the values, and the EventDispatcher performs dictionary lookups using raw int keys rather than computing string hashes.
EventDispatcher and Generic Delegate Storage
The central hub resides in EventDispatcher.cs, which maintains a single dictionary mapping event IDs to their handler containers:
// EventDispatcher.cs
private readonly Dictionary<int, EventDelegateData> _eventTable = new();
Unlike traditional event systems that use object or EventArgs parameters (which force boxing), EventDispatcher provides generic Send<T> overloads that forward arguments with exact type matching. When you call GameEvent.Send<float>(GameEvents.OnHealthChanged, 100f), the dispatcher retrieves the EventDelegateData instance and invokes the delegate chain without allocating temporary arrays or boxing the float value.
The dispatcher stores delegates as concrete types (Action, Action<T>, Action<T1, T2>) rather than wrapping them in Delegate objects that require allocation, ensuring that adding a listener performs zero heap allocations after the initial dictionary entry creation.
EventDelegateData Container
Each event type maintains its handlers in an EventDelegateData instance, defined in EventDelegateData.cs. This container caches the delegate invocation list to prevent per-send allocations:
// EventDelegateData.cs
private readonly List<Delegate> _delegates = new();
public void Add(Delegate del) => _delegates.Add(del);
public void Remove(Delegate del) => _delegates.Remove(del);
When EventDispatcher.Send<T>() triggers a dispatch, it calls into EventDelegateData.Callback(T arg), which iterates the internal List<Delegate> and invokes each handler directly. Because the list is reused and never reallocated during the iteration, and because the generic method signature matches the stored delegate exactly, the entire operation completes without generating garbage.
Automatic Listener Cleanup with GameEventMgr
Memory leaks from dangling event listeners traditionally require weak references (which allocate) or manual cleanup (which is error-prone). GameEventMgr.cs solves this by recording registration metadata without extra allocations:
// GameEventMgr.cs
private readonly List<(int eventId, Delegate handler)> _tracked = new();
public void AddEvent<T>(int eventId, Action<T> handler)
{
_tracked.Add((eventId, handler));
GameEvent.AddEventListener(eventId, handler);
}
When a component is destroyed or GameEventMgr.Clear() is invoked, the manager iterates its tracked list and calls GameEvent.RemoveEventListener for each pair. This O(N) cleanup operation removes delegates from EventDispatcher._eventTable without allocating temporary collections or triggering GC sweeps.
The Zero-GC Execution Flow
Understanding how these components cooperate clarifies why the system produces zero garbage:
-
Definition: A developer annotates
IGameEventswith[EventInterface]. The generator creates constant integers—no runtime reflection occurs. -
Registration: Calling
GameEvent.AddEventListener(GameEvents.OnHealthChanged, handler)stores theAction<float>directly in_eventTable[10001]. The first registration creates anEventDelegateDatainstance; subsequent registrations reuse this container. -
Dispatch:
GameEvent.Send<float>(GameEvents.OnHealthChanged, 95.5f)resolves toEventDispatcher.Send<float>, which retrieves theEventDelegateDataand invokesCallback(95.5f). The float passes directly without boxing, and the delegate invocation uses the cached method pointers. -
Cleanup:
GameEventMgr.Clear()iterates its internal list and unregisters each handler, returning theEventDispatcherto its previous state without allocating removal buffers.
Practical Implementation Example
The following pattern demonstrates proper usage across all core files:
// 1. Define events (triggers EventInterfaceGenerator.cs)
[EventInterface]
public interface IGameEvents
{
void OnHealthChanged(float hp);
void OnPlayerDied();
void OnItemPicked(int id, int count);
}
// Generated output: static class GameEvents with const int fields
// 2. Component usage with automatic cleanup
public class PlayerController : MonoBehaviour
{
private readonly GameEventMgr _eventMgr = new();
void Awake()
{
// Zero-allocation registration
_eventMgr.AddEvent(GameEvents.OnHealthChanged, HandleHealth);
_eventMgr.AddEvent(GameEvents.OnPlayerDied, HandleDeath);
}
void HandleHealth(float hp) { /* Update UI */ }
void HandleDeath() { /* Play animation */ }
void OnDestroy()
{
// Zero-allocation cleanup
_eventMgr.Clear();
}
}
// 3. Dispatch from anywhere in the codebase
public class DamageSystem
{
public void ApplyDamage(float amount)
{
float newHealth = CalculateHealth(amount);
// Zero-allocation, zero-boxing dispatch
GameEvent.Send<float>(GameEvents.OnHealthChanged, newHealth);
}
}
Why It Avoids Garbage Collection
TEngine's architecture eliminates the four primary sources of GC pressure in traditional event systems:
- No string hashing: Event IDs are
const intvalues, not strings, preventing hash code calculations and string comparisons. - No boxing: Generic
Send<T>methods preserve value types (structs, floats, ints) without casting toobject. - No temporary arrays: The system never uses
params object[]for argument passing, avoiding array allocations per dispatch. - No closure allocations: Delegates are stored as concrete
Actiontypes rather than compiler-generated closure classes that capture variables.
By reusing the Dictionary<int, EventDelegateData> and internal List<Delegate> collections, the system amortizes all memory costs to the first listener registration, remaining allocation-free during steady-state operation.
Summary
- Compile-time generation:
EventInterfaceGenerator.cscreates integer constants from attributed interfaces, eliminating runtime string operations. - Type-safe storage:
EventDispatcher.csuses generic methods and concrete delegate types to prevent boxing and maintain exact signatures. - Allocation-free dispatch:
EventDelegateData.cscaches invocation lists, ensuringSendoperations create no temporary objects. - Automatic cleanup:
GameEventMgr.cstracks registrations in reusable lists, enabling O(N) removal without GC churn. - Static API:
GameEvent.csprovides the public façade while delegating to a singletonEventDispatcher, keeping engine code concise and performant.
Frequently Asked Questions
How does TEngine prevent boxing when sending value types?
The system uses generic Send<T> overloads in EventDispatcher.cs that accept exact type parameters. When you call GameEvent.Send<int>(GameEvents.OnScoreChanged, 100), the integer passes directly to the stored Action<int> delegate without boxing to object. This preserves value types on the stack throughout the entire invocation chain.
What happens if I don't use GameEventMgr for listener cleanup?
Without GameEventMgr, you must manually call GameEvent.RemoveEventListener before destroying components. Failure to do so leaves delegates in EventDispatcher._eventTable, causing memory leaks as the dispatcher holds references to destroyed objects. The GameEventMgr pattern eliminates this risk by automating cleanup through its tracked list.
Is the event system thread-safe for multi-threaded dispatching?
The current implementation in EventDispatcher.cs uses standard Dictionary<int, EventDelegateData> and List<Delegate> collections without explicit locking mechanisms. For Unity's single-threaded main loop architecture, this design maximizes performance, but dispatching from background threads requires external synchronization to prevent race conditions during delegate iteration.
How are event ID constants generated to avoid collisions?
EventInterfaceGenerator.cs employs a deterministic hashing algorithm combined with incremental counters to generate unique const int values for each method across all [EventInterface]-marked types. Because generation occurs at compile time, the build fails immediately if collisions are detected, ensuring runtime uniqueness without requiring centralized registration or string-based lookups.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →