Events & tokens
Threading, Performance, And API
Review metrics, threading caveats, common pitfalls, performance notes, defaults, and exact API shapes.
- Metrics
- Threading
- Pitfalls
- API reference
Use this page after the lifecycle model is working and you need diagnostics, threading rules, pitfalls, or method signatures.
Metrics
The AzEvent family implements IAzEventMetricsProvider.
AzEventMetrics metrics = CombatEvents.HealthChanged.GetMetrics();
Debug.Log(metrics.ToString());
Debug.Log($"Total subscribers: {metrics.TotalSubscribers}");
Debug.Log($"Active subscribers: {metrics.ActiveSubscribers}");
Debug.Log($"Paused subscribers: {metrics.PausedSubscribers}");
Debug.Log($"Capacity: {metrics.Capacity}");
Debug.Log($"Fragmentation: {metrics.Fragmentation:P1}");
Debug.Log($"Invocations: {metrics.TotalInvocations}");
Debug.Log($"Listeners called: {metrics.TotalListenersCalled}");
Debug.Log($"Exceptions: {metrics.TotalExceptions}");Useful metrics:
TotalSubscribersNumber of live subscriptions, including paused subscriptions.
ActiveSubscribersNumber of subscriptions eligible to run.
PausedSubscribersNumber of live subscriptions currently paused.
CapacityInternal slot capacity.
AllocatedSlotsInternal used slot range.
FreeSlotsSlots available for reuse.
MemoryUsageKBApproximate storage use.
FragmentationApproximate unused internal capacity ratio.
TotalInvocationsNumber of invoke calls recorded.
TotalListenersCalledNumber of handler calls recorded.
TotalExceptionsNumber of exceptions seen by safe invocation.
SnapshotRebuildCountNumber of times the dispatch snapshot was rebuilt.
TrimOperationCountNumber of compactions.
LastTrimTimeLast UTC trim time, if any.
Reset all metrics:
CombatEvents.HealthChanged.ResetMetrics();Reset only runtime counters while keeping structural state:
CombatEvents.HealthChanged.ResetRuntimeMetrics();Threading
Treat the AzEvent family as main-thread event infrastructure.
Do not invoke Unity APIs from background threads, and do not raise AzEvent directly from background worker code. If a background thread needs to cause an event, queue work back to the Unity main thread, then invoke the event there.
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using Azkar.Eda;
using UnityEngine;
public sealed class MainThreadEventPump : MonoBehaviour
{
private static readonly ConcurrentQueue<Action> Queue = new();
public static void Post(Action action)
{
if (action != null)
{
Queue.Enqueue(action);
}
}
private void Update()
{
while (Queue.TryDequeue(out Action action))
{
action();
}
}
}
public sealed class BackgroundExample : MonoBehaviour
{
private void Start()
{
Task.Run(() =>
{
int result = 42;
MainThreadEventPump.Post(() =>
{
GameEvents.ScoreChanged.Invoke(result);
});
});
}
}AzEventTokenBag uses an internal lock for bag operations such as add, remove, and dispose-all, but that does not make arbitrary event invocation patterns thread-safe. Keep AzEvent subscription and invocation lifecycle on the main thread unless you have designed and tested a specific synchronization layer.
If you need off-thread subscription support, review the AzPriorityEvent family separately; it has different threading behavior and queued operations.
Common Pitfalls
Forgetting to Dispose Tokens
If you subscribe and never dispose, the event continues holding the handler.
Bad:
private void OnEnable()
{
CombatEvents.HealthChanged.Subscribe(OnHealthChanged);
}Good:
private readonly AzEventTokenBag _subscriptions = new();
private void OnEnable()
{
CombatEvents.HealthChanged.Subscribe(OnHealthChanged).AddTo(_subscriptions);
}
private void OnDisable()
{
_subscriptions.DisposeAll();
}Disposing the Bag Too Early
Bad for components that can be re-enabled:
private void OnDisable()
{
_subscriptions.Dispose();
}Good:
private void OnDisable()
{
_subscriptions.DisposeAll();
}Use Dispose() when the bag itself is done forever, such as in OnDestroy.
Re-subscribing Every OnEnable Without Disposing
Bad:
private void OnEnable()
{
CombatEvents.HealthChanged.Subscribe(OnHealthChanged);
}
private void OnDisable()
{
// Nothing.
}Each enable adds another handler. The same method may run multiple times per event.
Good:
private void OnEnable()
{
CombatEvents.HealthChanged.Subscribe(OnHealthChanged).AddTo(_subscriptions);
}
private void OnDisable()
{
_subscriptions.DisposeAll();
}Using Lambdas You Cannot Remove
This is hard to remove by handler later:
CombatEvents.HealthChanged.Subscribe(value => Debug.Log(value));If you use a lambda, store the token:
private AzEventToken _token;
private void OnEnable()
{
_token = CombatEvents.HealthChanged.Subscribe(value => Debug.Log(value));
}
private void OnDisable()
{
_token.Dispose();
}For most component handlers, prefer method groups:
CombatEvents.HealthChanged.Subscribe(OnHealthChanged).AddTo(_subscriptions);Calling TrimExcess While Components Still Hold Tokens
TrimExcess can invalidate tokens when it compacts storage. This is correct and intentional, but it surprises people if they use it as routine cleanup.
Use it during controlled cleanup windows, not while normal gameplay objects still expect their tokens to work.
Expecting New Subscriptions to Run Immediately During Invoke
If a handler subscribes another handler during dispatch, the new handler does not join the current dispatch.
This is by design. It keeps invocation order and mutation behavior predictable.
Confusing Pause with Dispose
Pause:
- keeps the subscription
- keeps order
- can resume later
- useful for temporary inactivity
Dispose:
- removes the subscription
- cannot be resumed
- useful for final cleanup
Performance Notes
AzEvent is designed for a zero-GC hot invocation path after warmup. The common performance-friendly pattern is:
- Create events up front.
- Call
EnsureCapacityif you know the expected subscriber count. - Subscribe during setup or lifecycle transitions.
- Invoke normally with
Invokein trusted hot paths. - Use
InvokeSafefor diagnostics or isolation. - Dispose or pause tokens instead of relying on handler removal by delegate.
- Avoid per-frame subscribe/dispose churn when pause/resume would model the lifecycle better.
AzEventRef<T> can reduce copies for large structs, but do not use it automatically for every payload. For small payloads, AzEvent<T> is simpler and usually the right choice.
AzEventTokenBag stores AzEventToken by value, so adding normal AzEvent tokens does not box them.
API Quick Reference
AzEvent
public AzEventToken Subscribe(Action handler);
public AzEventToken SubscribeUnique(Action handler);
public bool Remove(Action handler);
public void Invoke();
public AzInvokeResult InvokeSafe();
public int Count { get; }
public int Capacity { get; }
public bool HasSubscribers { get; }
public void Clear();
public void EnsureCapacity(int capacity);
public void TrimExcess(int minCapacity = 0);
public AzEventMetrics GetMetrics();
public void ResetMetrics();
public void ResetRuntimeMetrics();AzEvent<T>
public AzEventToken Subscribe(Action<T> handler);
public AzEventToken SubscribeUnique(Action<T> handler);
public bool Remove(Action<T> handler);
public void Invoke(T value);
public AzInvokeResult InvokeSafe(T value);
public int Count { get; }
public int Capacity { get; }
public bool HasSubscribers { get; }
public void Clear();
public void EnsureCapacity(int capacity);
public void TrimExcess(int minCapacity = 0);
public AzEventMetrics GetMetrics();
public void ResetMetrics();
public void ResetRuntimeMetrics();AzEventRef<T>
public AzEventToken Subscribe(AzRefHandler<T> handler);
public AzEventToken SubscribeUnique(AzRefHandler<T> handler);
public bool Remove(AzRefHandler<T> handler);
public void Invoke(in T value);
public AzInvokeResult InvokeSafe(in T value);
public int Count { get; }
public int Capacity { get; }
public bool HasSubscribers { get; }
public void Clear();
public void EnsureCapacity(int capacity);
public void TrimExcess(int minCapacity = 0);
public AzEventMetrics GetMetrics();
public void ResetMetrics();
public void ResetRuntimeMetrics();AzEventToken
public static readonly AzEventToken Invalid;
public bool IsValid { get; }
public bool IsDisposed { get; }
public bool IsPaused { get; }
public string DiagnosticId { get; }
public void Dispose();
public void Pause();
public void Resume();
public override string ToString();AzEventTokenBag
public AzEventTokenBag(int initialCapacity = 8);
public int Count { get; }
public bool IsDisposed { get; }
public void Add(AzEventToken token);
public void EnsureCapacity(int min, bool roundUpPow2 = true);
public void EnsureHeadroom(int additional, bool roundUpPow2 = true);
public void ShrinkToFit();
public int PauseAll();
public int ResumeAll();
public void DisposeAll();
public int PruneInvalid();
public bool HasValidTokens();
public void Clear();
public bool RemoveToken(AzEventToken token, bool dispose = true);
public bool TryRemoveToken(AzEventToken token, bool dispose = true);
public int RemoveWhere(Func<AzEventToken, bool> predicate, bool dispose = true);
public void ForEach(Action<AzEventToken> action);
public IEnumerable<AzEventToken> Tokens();
public void Dispose();Token Bag Extensions
public static AzEventToken AddTo(this AzEventToken token, AzEventTokenBag bag);
public static bool RemoveFrom(this AzEventToken token, AzEventTokenBag bag, bool dispose = true);Recommended Defaults for New Users
Use these defaults until you have a reason to do something more specialized:
- Put shared events on a static event bus.
- Use
AzEvent<T>for most payloads. - Use
AzEventRef<T>only for large struct payloads. - Subscribe in
OnEnable. - Add every token to an
AzEventTokenBag. - Call
DisposeAll()inOnDisable. - Call
Dispose()on the bag inOnDestroyonly when you used pause/resume or persistent subscriptions. - Use
PauseAll()/ResumeAll()for pooled objects that enroll once and toggle active state many times. - Use
SubscribeUniquewhen duplicate subscription is a realistic bug. - Use
Invokein normal hot paths. - Use
InvokeSafewhile debugging or when one throwing subscriber should not stop the rest. - Keep
AzEventinvocation on the Unity main thread.
Complete Example
using Azkar.Eda;
using UnityEngine;
public static class PlayerEvents
{
public static readonly AzEvent Spawned = new();
public static readonly AzEvent<int> HealthChanged = new();
public static readonly AzEventRef<PlayerHitReport> HitReported = new();
}
public readonly struct PlayerHitReport
{
public readonly int Damage;
public readonly Vector3 Point;
public readonly Vector3 Normal;
public PlayerHitReport(int damage, Vector3 point, Vector3 normal)
{
Damage = damage;
Point = point;
Normal = normal;
}
}
public sealed class Player : MonoBehaviour
{
private int _health = 100;
private void Start()
{
PlayerEvents.Spawned.Invoke();
PlayerEvents.HealthChanged.Invoke(_health);
}
public void TakeDamage(int damage, Vector3 point, Vector3 normal)
{
_health -= damage;
var report = new PlayerHitReport(damage, point, normal);
PlayerEvents.HitReported.Invoke(in report);
PlayerEvents.HealthChanged.Invoke(_health);
}
}
public sealed class PlayerHud : MonoBehaviour
{
private readonly AzEventTokenBag _subscriptions = new();
private AzEventToken _hitToken;
private void OnEnable()
{
PlayerEvents.Spawned.Subscribe(OnPlayerSpawned).AddTo(_subscriptions);
PlayerEvents.HealthChanged.Subscribe(OnHealthChanged).AddTo(_subscriptions);
_hitToken = PlayerEvents.HitReported.Subscribe(OnHitReported).AddTo(_subscriptions);
}
private void OnDisable()
{
_subscriptions.DisposeAll();
_hitToken = AzEventToken.Invalid;
}
public void SetHitEffectsMuted(bool muted)
{
if (muted)
{
_hitToken.Pause();
}
else
{
_hitToken.Resume();
}
}
private void OnPlayerSpawned()
{
Debug.Log("Player spawned.");
}
private void OnHealthChanged(int health)
{
Debug.Log($"Health: {health}");
}
private void OnHitReported(in PlayerHitReport report)
{
Debug.Log($"Hit for {report.Damage} at {report.Point}");
}
}This example shows the normal ecosystem:
PlayerEventsowns the event bus.Playerinvokes events.PlayerHudsubscribes to events.PlayerHudstores tokens in a bag.OnDisabledisposes subscriptions cleanly.- One token is also kept individually so a single listener can be paused/resumed.
