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:

TotalSubscribers

Number of live subscriptions, including paused subscriptions.

ActiveSubscribers

Number of subscriptions eligible to run.

PausedSubscribers

Number of live subscriptions currently paused.

Capacity

Internal slot capacity.

AllocatedSlots

Internal used slot range.

FreeSlots

Slots available for reuse.

MemoryUsageKB

Approximate storage use.

Fragmentation

Approximate unused internal capacity ratio.

TotalInvocations

Number of invoke calls recorded.

TotalListenersCalled

Number of handler calls recorded.

TotalExceptions

Number of exceptions seen by safe invocation.

SnapshotRebuildCount

Number of times the dispatch snapshot was rebuilt.

TrimOperationCount

Number of compactions.

LastTrimTime

Last 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:

  1. Create events up front.
  2. Call EnsureCapacity if you know the expected subscriber count.
  3. Subscribe during setup or lifecycle transitions.
  4. Invoke normally with Invoke in trusted hot paths.
  5. Use InvokeSafe for diagnostics or isolation.
  6. Dispose or pause tokens instead of relying on handler removal by delegate.
  7. 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() in OnDisable.
  • Call Dispose() on the bag in OnDestroy only when you used pause/resume or persistent subscriptions.
  • Use PauseAll() / ResumeAll() for pooled objects that enroll once and toggle active state many times.
  • Use SubscribeUnique when duplicate subscription is a realistic bug.
  • Use Invoke in normal hot paths.
  • Use InvokeSafe while debugging or when one throwing subscriber should not stop the rest.
  • Keep AzEvent invocation 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:

  • PlayerEvents owns the event bus.
  • Player invokes events.
  • PlayerHud subscribes to events.
  • PlayerHud stores tokens in a bag.
  • OnDisable disposes subscriptions cleanly.
  • One token is also kept individually so a single listener can be paused/resumed.