Thread-Safe Async State Machines in C

Building thread-safe asynchronous state machines in custom C# libraries requires managing state transitions reliably across concurrent execution contexts while handling non-blocking asynchronous operations without deadlocks or race conditions. This article explores the core synchronization strategies, design patterns, and C# primitives necessary to build robust async state machines, covering atomic operations, async-compatible locks, and event-loop-style message passing.

The Core Challenge: Asynchrony and Thread Concurrency

Standard C# state machines rely on synchronous locks (lock (syncRoot)) to guard state mutations. However, standard locks are thread-affine and cannot span across an await expression; the compiler forbids using await inside a lock block because the continuation may resume on a different thread, causing synchronization faults or deadlocks.

To build an asynchronous state machine, you must handle two concurrent dimensions:

  1. Thread Safety: Ensuring that multiple threads calling transition triggers simultaneously do not corrupt internal state.
  2. Transition Atomicity: Ensuring that no other transition can begin while an asynchronous transition (involving I/O, network calls, or timers) is pending.

Strategy 1: Async Locking with SemaphoreSlim

The most direct way to serialize asynchronous transitions is by utilizing a SemaphoreSlim initialized with an initial and maximum count of 1. This acts as an async-compatible mutual exclusion lock (async mutex).

Implementation Pattern

using System;
using System.Threading;
using System.Threading.Tasks;

public enum ConnectionState
{
    Disconnected,
    Connecting,
    Connected,
    Disconnecting
}

public class AsyncConnectionStateMachine
{
    private readonly SemaphoreSlim _gate = new(1, 1);
    public ConnectionState State { get; private set; } = ConnectionState.Disconnected;

    public async Task ConnectAsync(CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (State != ConnectionState.Disconnected)
            {
                throw new InvalidOperationException($"Cannot connect while in state: {State}");
            }

            State = ConnectionState.Connecting;

            // Perform asynchronous connection logic
            await ExecuteConnectAsync(cancellationToken).ConfigureAwait(false);

            State = ConnectionState.Connected;
        }
        catch
        {
            State = ConnectionState.Disconnected;
            throw;
        }
        finally
        {
            _gate.Release();
        }
    }

    private async Task ExecuteConnectAsync(CancellationToken cancellationToken)
    {
        // Simulated asynchronous work
        await Task.Delay(100, cancellationToken).ConfigureAwait(false);
    }
}

Considerations for SemaphoreSlim

  • Re-entrancy: Unlike Monitor/lock, SemaphoreSlim is not re-entrant. Calling a method that requests the lock from within a state action already holding the lock will cause a permanent deadlock.
  • Fairness: By default, SemaphoreSlim does not guarantee FIFO order. If strict FIFO transition ordering is required, an explicit transition queue must be used.

Strategy 2: Single-Writer Actor Pattern via Channels

For high-throughput systems or scenarios where lock contention is undesirable, serialize state machine mutations using a message-passing actor model. By routing trigger requests through a System.Threading.Channels.Channel<T>, you ensure that exactly one worker loop mutates the state sequentially.

Implementation Pattern

using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public class ActorStateMachine
{
    private enum Trigger { Start, Complete, Fail }

    private readonly Channel<Trigger> _triggers = Channel.CreateUnbounded<Trigger>(
        new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });

    private readonly TaskCompletionSource _completion = new();
    private int _currentState; // 0: Idle, 1: Running, 2: Finished

    public ActorStateMachine()
    {
        Task.Run(ProcessTriggersAsync);
    }

    public ValueTask FireAsync(Trigger trigger) => _triggers.Writer.WriteAsync(trigger);

    private async Task ProcessTriggersAsync()
    {
        var reader = _triggers.Reader;
        while (await reader.WaitToReadAsync().ConfigureAwait(false))
        {
            while (reader.TryRead(out var trigger))
            {
                await ApplyTransitionAsync(trigger).ConfigureAwait(false);
            }
        }
    }

    private async Task ApplyTransitionAsync(Trigger trigger)
    {
        switch (_currentState)
        {
            case 0 when trigger == Trigger.Start:
                _currentState = 1;
                await Task.Delay(50); // Async transition work
                break;

            case 1 when trigger == Trigger.Complete:
                _currentState = 2;
                _triggers.Writer.Complete();
                break;
        }
    }
}

Advantages of the Channel Approach

  • Zero Locks: Eliminates deadlocks caused by locking mechanisms.
  • Natural Backpressure: You can bound the channel capacity to limit memory consumption under heavy trigger loads.
  • Predictable Ordering: Transitions are processed strictly in the order they were scheduled.

Strategy 3: Lightweight Lock-Free CAS (Compare-And-Swap)

If transition logic does not require prolonged asynchronous execution inside the transition window itself, lock-free primitives using Interlocked provide the highest performance.

This pattern is best suited for state machines with simple linear flows, such as lifecycle guards (Created -> Running -> Disposed).

using System;
using System.Threading;
using System.Threading.Tasks;

public class LifecycleStateMachine
{
    private const int StateCreated = 0;
    private const int StateRunning = 1;
    private const int StateDisposed = 2;

    private int _state = StateCreated;

    public async Task StartAsync()
    {
        // Atomically ensure we are transitioning from Created to Running
        if (Interlocked.CompareExchange(ref _state, StateRunning, StateCreated) != StateCreated)
        {
            throw new InvalidOperationException("Component has already started or is disposed.");
        }

        // Asynchronous initialization safe to run here
        await Task.Yield();
    }

    public async ValueTask DisposeAsync()
    {
        // Atomically mark as disposed from any state other than Disposed
        int previousState = Interlocked.Exchange(ref _state, StateDisposed);
        if (previousState == StateDisposed)
        {
            return;
        }

        // Perform cleanup
        await Task.CompletedTask;
    }
}

Best Practices for Custom Async State Machines

  1. Always Use ConfigureAwait(false): Custom libraries should never capture synchronization contexts. Resuming on arbitrary contexts (like UI or ASP.NET thread contexts) degrades throughput and can induce deadlocks.
  2. Accept CancellationToken on Transitions: Asynchronous operations may stall. Every async trigger method must accept and observe a CancellationToken to cancel long-running transitions.
  3. Handle State Rollbacks on Failure: If an exception occurs within an asynchronous transition, internal states can become corrupted if not handled explicitly. Use try-catch blocks to transition the machine into a designated Faulted state.
  4. Expose Read-Only Snapshots: Do not expose mutable state objects. Expose states as immutable enums, records, or read-only properties to prevent external consumers from bypassing state machine validation.