Table of Contents

Efficient UI Event and Thread Manager for .NET / Unity

Runtime/Threading/Sentinel.cs   HeaderDoc

(c) 2025 Sator Imaging, Licensed under the MIT License https://github.com/sator-imaging/Unity-Fundamentals

With design eliminating unnecessary lock operations, Sentinel provides extreme fast and efficient way to manage exclusive and/or concurrent tasks and threads.

Basic Usage

readonly SentinelToken _token = Sentinel.GetUniqueToken();

// use `ExclusiveScope()` method to define exclusive operation block
using (Sentinel.ExclusiveScope(_token, out var rejected)
{
    if (rejected) return;

    // do the exclusive task
}

// use `DebounceScope()` with blockless-using pattern
using var _ = Sentinel.DebounceScope(_token, 3, out var rejected);

if (rejected)
    return;  // only first 3 thread or event can enter

Async Callback Handling

Even if callback is invoked only on main thread, event handler may run multiple times if it is marked async. (ex. multiple button clicks may invoke multiple callbacks)

Here shows how to prevent multiple event invocations in single thread app.

// technically, async method is immediately finished so there is chance to run multiple times
myEvent.Subscribe(async () =>
{
    using (Sentinel.SingleThreadScope(_token, out var entrantCount)
    {
        // check current entrant count
        if (entrantCount != 0)
            return;

        await FooAsync();
        await Task.Delay(1000);
    }
});

Advanced Usage

Sentinel providing features to eliminate insane Rx operator chains.

UI Event Handling

Make ThrottleFirst or Debounce in modern C# style.

myButton.onClick += async () =>
{
    // use shared token to allow only a button can work at a moment
    using (Sentinel.ExclusiveScope(_sharedTokenAcrossButtons, out var rejected)
    {
        // other buttons won't work until this event has finished
        if (rejected) return;

        myButton.enable = false;
        try
        {
            OnMyButtonClick();

            // only allow click once in second, to achieve balancing event stream
            await Task.Delay(1000);
        }
        finally
        {
            myButton.enable = true;
        }

        // reaches here after Task.Delay operation is finished.
        // and automatically free up exclusive lock by IDisposable.
    }
};

Retry Operation

You can write better UX code more simple way rather than using Rx operator chaining.

// case 1) retry entering exclusive scope
RETRY_ENTER:
    using (Sentinel.ExclusiveScope(_token, out var rejected)
    {
        if (rejected)
        {
            await Task.Delay(100);
            goto RETRY_ENTER;
        }

        // case 2) retry network access
        int retryCount = 0;
        int delay = 1000;
        while (true)
        {
            if (ct.IsCancellationRequested)
                break;

            try
            {
                if (retryCount > 0)
                {
                    await Task.Delay(delay, ct).ConfigureAwait(false);

                    // can easily implement exponential backoff
                    delay *= 2;
                }

                await myNetworkClient.GetAsync(something, timeout: 3000, ct);
                break;
            }
            catch (TimeoutException)
            {
                retryCount++;

                if (retryCount > 10)
                    throw;

                // achieve better app UX without insane Rx techniques
                if (retryCount > 3)
                    ShowToastNotification("Server now gets many traffic. Thank you for your patience.");
            }
            catch (OperationCanceledException)
            {
                break;
            }
        }
    }

Extendable Delay Operation

Here shows how to achieve "extendable wait" in UI events or multi-threaded functions. (ex. invoke callback after a second since slider dragging is finished)

private int m_waitDuration;
private int m_latestValue;

// event/thread always updates wait duration even if cannot enter exclusive block to
// prevent event invocation. as a result, event listener invokes only once when a second
// elapsed since last event arrival.
void OnChanged(int value)
{
    // always set!!
    m_waitDuration = 1000;
    m_latestValue = value;

    using (Sentinel.ExclusiveScope(_token, out var rejected)
    {
        if (rejected) return;

        // check frequency in milliseconds
        const int freq = 100;

        // this delay continues until event stream stops.
        while ((m_waitDuration -= freq) > 0)
        {
            await Task.Delay(freq, ct).ConfigureAwait(false);
        }

        // reaches here a second later since last event.
        DelayedAction(m_latestValue);
    }
}

Sentinel provides helper method to achieve more accurate delay.

// use timestamp instead of wait duration
private long m_startTimestamp;

// in event listener, repeat delay until time has elapsed
int remaining;
while ((remaining = Sentinel.GetRemainingMilliseconds(m_startTimestamp, 1000)) > 0)
{
    await Task.Delay(remaining, ct).ConfigureAwait(false);
}

Technical Notes

If you have encountered error related on SentinelToken, define preprocessor directive #define STMG_SENTINEL_ENABLE_STRICT_TYPEDEF can solve the problem.