DEV Community

Volodymyr Dombrovskyi
Volodymyr Dombrovskyi

Posted on

Sequential async execution in .NET: SemaphoreSlim, Channels, and TaskFlow

Familiar pattern?

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

public sealed class InstrumentController : IDisposable
{
    private readonly IInstrumentSession _session;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private bool _disposed;

    // Takes exclusive ownership of a session that is not thread-safe.
    public InstrumentController(IInstrumentSession session) =>
        _session = session ?? throw new ArgumentNullException(nameof(session));

    public void SetRange(int range)
    {
        _gate.Wait();
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            _session.SetRange(range);
        }
        finally { _gate.Release(); }
    }

    public async Task<double> ReadAsync(CancellationToken token = default)
    {
        await _gate.WaitAsync(token).ConfigureAwait(false);
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            return await _session.ReadAsync(token).ConfigureAwait(false);
        }
        finally { _gate.Release(); }
    }

    public async Task CalibrateAsync(CancellationToken token = default)
    {
        await _gate.WaitAsync(token).ConfigureAwait(false);
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            await _session.CalibrateAsync(token).ConfigureAwait(false);
        }
        finally { _gate.Release(); }
    }

    // Lifetime contract: the owner must prevent new calls and wait for all
    // in-flight calls (including semaphore waiters) to finish before disposal.
    // Dispose itself is not concurrent-safe; the owner serializes disposal.
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        try { _session.Dispose(); }
        finally { _gate.Dispose(); }
    }
}
Enter fullscreen mode Exit fullscreen mode

The instrument is just a concrete example of a non-thread-safe resource.

Imagine a measurement instrument with a session-based SDK. Configuration is synchronous; reading and calibration involve asynchronous I/O. Concurrent SDK calls are unsupported, but the session does not require a particular thread. The wrapper owns the session, and nobody accesses it directly.

For both examples, this is the application-specific SDK boundary:

public interface IInstrumentSession : IDisposable
{
    void SetRange(int range);
    Task<double> ReadAsync(CancellationToken token);
    Task CalibrateAsync(CancellationToken token);
}
Enter fullscreen mode Exit fullscreen mode

The examples target .NET 8 or later.

There is nothing inherently wrong with this code. SemaphoreSlim(1, 1) gives us mutual exclusion across synchronous and asynchronous methods. We acquire before entering try, so a canceled wait does not release a permit it never acquired. The permit stays held across each awaited SDK operation.

The async methods already return a task for the caller's operation, including its result or exception. An operation failure releases the permit, allowing other callers to continue. None of that requires another library.

The disposal comment matters, though. SemaphoreSlim.Dispose must not run concurrently with other members. Waiting for a permit and then disposing the semaphore would not prove that there are no other waiters. Here, the owner establishes quiescence before disposal. That is a reasonable contract when the surrounding application can enforce it.

When the requirements become a queue

Now suppose configuration, calibration, and reading must execute in submission order. The component should also reject new work when shutdown starts and wait for accepted work to settle before releasing the session.

Mutual exclusion answers whether operations can overlap. It does not, by itself, specify which waiting operation goes next. Microsoft documents no guaranteed FIFO or LIFO admission order for semaphores. Observing a particular order in a test does not establish a contract.

You can build the required behavior around a semaphore, a task chain, or a queue. But the application now has a submission protocol, an ordering policy, operation completion, and a shutdown protocol to maintain.

That is the abstraction I wanted to reuse. I am the author of TaskFlow, an open-source .NET library whose first stable release is 1.0.0. I built it for components that need to own a sequential execution lane.

By “lane,” I mean a sequence of submitted operations whose asynchronous work does not overlap. It need not be a dedicated thread. Every submission returns its own task, and the component owns the lane's lifetime.

Before showing it, there are two existing .NET abstractions worth considering.

What about Channels and ActionBlock?

Channel<T> models producers sending messages to consumers. A single consumer that awaits each handler can process them sequentially; a bounded channel adds capacity limits and backpressure. That is often the right model for telemetry, uploads, or pipeline stages.

A channel write tells the producer that the message was written, not that processing finished. If every caller needs its own result, you can add a completion source to each message and have the consumer resolve it.

ActionBlock<T> from TPL Dataflow already runs the handler for you. Its default degree of parallelism is one, including for asynchronous handlers. It also offers bounded capacity and a completion model for the block.

Both are serious alternatives. The distinction is where the application's contract lives: messages moving through a processing system, or callers submitting operations to a component that owns their execution. Either model can be built on the other.

The same resource behind TaskFlow

Install the version discussed in this article:

dotnet add package TaskFlow --version 1.0.0
Enter fullscreen mode Exit fullscreen mode

Here is the same set of session operations using TaskFlow's actual 1.0 namespace and API:

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

public sealed class QueuedInstrumentController : IAsyncDisposable
{
    private readonly IInstrumentSession _session;
    private readonly TaskFlow _flow = new();
    private bool _disposed;

    public QueuedInstrumentController(IInstrumentSession session) =>
        _session = session ?? throw new ArgumentNullException(nameof(session));

    public Task SetRangeAsync(int range, CancellationToken token = default) =>
        _flow.Enqueue(ct =>
        {
            ct.ThrowIfCancellationRequested();
            _session.SetRange(range);
        }, token);

    public Task<double> ReadAsync(CancellationToken token = default) =>
        _flow.Enqueue(ct => _session.ReadAsync(ct), token);

    public Task CalibrateAsync(CancellationToken token = default) =>
        _flow.Enqueue(ct => _session.CalibrateAsync(ct), token);

    // One owner calls DisposeAsync; concurrent disposal is unsupported here.
    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        await _flow.DisposeAsync().ConfigureAwait(false);
        _disposed = true;
        _session.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

The session operations are the same, but the public API changes: SetRange becomes SetRangeAsync, and disposal becomes asynchronous. Callers await the queued configuration operation even though the SDK call itself remains synchronous and occupies a thread while it runs.

The built-in TaskFlow executes accepted operations one at a time in FIFO enqueue order. Each caller gets its own task carrying the operation's result or exception, and a failed operation does not prevent later work from running.

Concurrent callers still race to establish enqueue order. If one command must precede another, the application must establish that relationship before submission.

The delegate must return or await all work belonging to its operation. That gives the lane a meaningful completion boundary—and gives the component something it can wait for during shutdown.

Shutdown is part of the contract

In this version, the component owns the accepted work as well as the session. DisposeAsync stops new submissions, requests cooperative cancellation, and waits for accepted queued and running operations to settle. Only then does the wrapper dispose the SDK session.

This is cancellation followed by waiting, so it does not promise that every submitted command will finish successfully. Operations must cooperate with cancellation; work that ignores it can keep shutdown waiting. Callers still await their individual tasks for results and failures. The lifecycle guide and semantics documentation cover the detailed behavior.

TaskFlow also has optional operation policies and integrations and different execution models. Those are useful when needed; the example only needs an ordered lane and an owner.

When I would use it—and when I would not

TaskFlow fits when a component accepts operations from several callers and needs to own their execution order and lifetime. A non-thread-safe session is one example; ordered asynchronous processing of notifications is another, provided submission volume is controlled and failures are observed.

I would keep SemaphoreSlim when mutual exclusion is the whole requirement and the owner already coordinates shutdown. A few repeated try/finally blocks do not justify another dependency. It also fits when synchronous and asynchronous callers need to retain their existing APIs. For short, entirely synchronous critical sections, a plain lock may be enough.

I would choose a bounded Channel<T> when buffering and backpressure are central. TaskFlow 1.0's built-in lanes have no bounded-capacity backpressure API. A slow operation holds up everything behind it, and submitting faster can accumulate pending work.

I would use ActionBlock<T> when message processing, bounded capacity, and block-level completion already express the desired model. It can execute async handlers one at a time without a custom consumer loop. Per-message replies can be added when needed.

I would avoid a single sequential lane for independent operations that should run concurrently. And I would not use TaskFlow for jobs that must survive a process crash: it is not durable job storage.

SemaphoreSlim provides mutual exclusion. I find TaskFlow useful when the component itself needs to own a FIFO sequential execution lane, with per-operation tasks and a defined lifetime.

TaskFlow 1.0.0 is available on NuGet, with source on GitHub and documentation here.

Where would you draw the boundary between a synchronization primitive and an owned queue of operations?

Top comments (0)