Skip to content
Kumar Chandrachooda
.NET

A Channel Between the Endpoint and the Loop

How FeedR's quotes feed decouples a microsecond HTTP endpoint from an hours-long price generator: System.Threading.Channels as a command queue, marker records as the protocol, and an Interlocked flag for idempotent start/stop. Part 3 of the FeedR deep dive.

By Kumar Chandrachooda 06 Oct 2025 6 min read
The endpoint drops a command in the pipe and leaves; the loop on the other end does the living

There is a category of HTTP endpoint that should never do the work it names. POST /pricing/start is a perfect specimen: the caller wants a long-running price generator to begin ticking, but the request deserves an answer in microseconds. If the handler starts the generator inline, you've tied an hours-long process to an HTTP request's lifetime — cancellation tokens fire when the client disconnects, timeouts lurk, and two concurrent callers race to start the thing twice.

FeedR's quotes service — the heart of the open-source DevMentors sample this series dissects — solves this with a pattern I keep coming back to in my own services: an in-process command queue built on System.Threading.Channels, with an HTTP endpoint on one end and a BackgroundService on the other. The whole mechanism is maybe sixty lines, and every line teaches something.

The pipe

The channel itself is wrapped in the smallest possible class and registered as a singleton:

internal sealed class PricingRequestsChannel
{
    public readonly Channel<IPricingRequest> Requests =
        Channel.CreateUnbounded<IPricingRequest>();
}

Channel<T> is .NET's purpose-built async producer/consumer primitive — a better-behaved successor to BlockingCollection<T> that awaits instead of blocking on both ends. Wrapping it in a named class rather than registering Channel<IPricingRequest> directly is a small touch worth copying: the DI container now holds a pricing requests channel, not “some channel of some interface”, and adding a second channel later can't cause an ambiguous resolution.

The commands flowing through it are the cheapest possible protocol — empty marker records under a marker interface:

internal interface IPricingRequest { }

internal record StartPricing : IPricingRequest;
internal record StopPricing  : IPricingRequest;

No properties, no payloads. The type is the message. This looks almost too minimal until you need a third command — SetTickInterval(TimeSpan Interval), say — and discover the shape was ready for it all along: add a record, add a switch arm, done.

The producer: an endpoint that only enqueues

The minimal API endpoints become trivial, and that is the point:

app.MapPost("/pricing/start", async (PricingRequestsChannel channel) =>
{
    await channel.Requests.Writer.WriteAsync(new StartPricing());
    return Results.Ok();
});

app.MapPost("/pricing/stop", async (PricingRequestsChannel channel) =>
{
    await channel.Requests.Writer.WriteAsync(new StopPricing());
    return Results.Ok();
});

The handler's entire job is to translate an HTTP verb into a domain command and drop it in the pipe. Since the channel is unbounded, WriteAsync completes synchronously in practice — the endpoint's latency is effectively the cost of allocating one empty record.

A design nit I'd argue in review: these return 200 OK, which implies the pricing is started. It isn't — the command has merely been queued. 202 Accepted is the semantically honest status for “I have taken your request and will act on it”, and FeedR itself uses exactly that on the news endpoint. Small thing, but clients build retry logic on top of these signals.

The consumer: one loop that owns the lifecycle

On the other end of the pipe sits a hosted BackgroundService whose ExecuteAsync is a single await foreach over the channel — quoting the actual source, because the dispatch is where it gets spicy:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    await foreach (var request in _requestsChannel.Requests.Reader.ReadAllAsync(stoppingToken))
    {
        var _ = request switch
        {
            StartPricing => StartGeneratorAsync(),
            StopPricing  => StopGeneratorAsync(),
            _            => Task.CompletedTask
        };
    }
}

ReadAllAsync turns the channel into an IAsyncEnumerable that waits — without a thread, without polling — until a command arrives. Type-pattern switching over the marker records is the whole command dispatcher.

Now look closely at var _ =. The switch arms return tasks that are deliberately not awaited. This is load-bearing: StartGeneratorAsync runs for as long as the generator runs — hours, potentially. If the loop awaited it, the service could never hear the StopPricing command sitting behind it in the channel; the whole feed would deadlock against its own queue. Fire-and-forget here is not sloppiness, it's what keeps the control loop responsive.

But it has a real cost the sample doesn't pay anywhere visible: unobserved exceptions. If StartGeneratorAsync throws — Redis down, serializer failure — that exception evaporates. Nothing logs it; the service hums along with a dead generator and a green health status (if it had one). In my own code the pattern I use is a small FireAndForget(this Task task, ILogger logger) extension that attaches a fault continuation, which preserves the responsiveness while giving failures somewhere to go. If you take one adjustment away from this article, take that one.

Idempotent start/stop with one integer

Two StartPricing commands must not spawn two generators. FeedR guards this with the leanest concurrency tool available:

private int _runningStatus;

private async Task StartGeneratorAsync()
{
    if (Interlocked.Exchange(ref _runningStatus, 1) == 1)
    {
        _logger.LogInformation("Pricing generator is already running.");
        return;
    }

    await foreach (var currencyPair in _pricingGenerator.StartAsync())
    {
        await _streamPublisher.PublishAsync("pricing", currencyPair);
    }
}

Interlocked.Exchange atomically sets the flag to 1 and returns what it was — if it was already 1, someone else won the race and this call bows out. Stop mirrors it, swapping in 0. No locks, no semaphores, and it's correct even though the dispatch above is fire-and-forget, because the atomicity lives at the point of state transition rather than in the queue.

The loop body is also where the previous article's story connects to the next one: every generated tick is published to the Redis pricing channel via IStreamPublisher — the seam that part 4 opens up.

The generator: an async iterator as an infinite feed

The generator itself is an IAsyncEnumerable<CurrencyPair> random walk over four EUR pairs. Shape, condensed:

public async IAsyncEnumerable<CurrencyPair> StartAsync()
{
    _isRunning = true;
    while (_isRunning)
    {
        foreach (var (symbol, pricing) in _currencyPairs)
        {
            if (!_isRunning) yield break;

            var newPricing = pricing + NextTick();   // random ±0.05 step
            _currencyPairs[symbol] = newPricing;
            yield return new CurrencyPair(symbol, newPricing,
                DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
            await Task.Delay(TimeSpan.FromSeconds(1));
        }
    }
}

An infinite feed expressed as an async iterator is wonderfully composable — the consumer just await foreaches and each tick arrives with backpressure built in (the next tick isn't generated until the previous one is consumed and the delay elapses). Stopping is cooperative: StopAsync flips _isRunning, and the iterator notices at the next check.

Two honest observations for the code-review file. First, _isRunning is a plain bool written by one task and read by another with no volatile, Interlocked, or lock — in practice the await boundaries insert enough memory barriers that it works, but it's the kind of “works by accident of the scheduler” detail I'd never let ship without at least a comment. Second, the random walk has no floor: four random ±0.05 steps a second will eventually walk EURUSD negative if you leave it running over a long weekend. Both are completely fine for a teaching sample; both are exactly where a real market-data feed would need real answers (proper synchronization, mean reversion or clamping).

Why not just… a boolean and a lock?

It's worth asking what the channel buys over the obvious alternative — endpoint takes a lock, checks a flag, starts a Task.Run. Three things, in my experience:

  1. Serialization of intent. Commands are processed one at a time in arrival order by a single consumer. Start-stop-start arriving in a burst produces deterministic behavior instead of three handlers interleaving on the thread pool.
  2. A place for the queue to be. The moment requests can arrive faster than they're handled, you have a queue whether you designed one or not. Channel<T> makes it explicit, observable, and — if you switch to CreateBounded — droppable under pressure with a policy you chose (BoundedChannelFullMode.DropOldest is often right for command streams like this).
  3. A miniature of the big architecture. This is the same producer/decouple/consume shape FeedR uses between services with Redis, executed inside one process. Learn it here where you can step through it in a debugger, and the distributed version holds no surprises.

Unbounded is a reasonable default for a channel that carries a few empty records per hour. The reflex to build, though, is asking “what happens when this fills?” the moment the producer is anything busier than a human with curl.

Next in the series: the tick leaves the process. Part 4 follows _streamPublisher.PublishAsync("pricing", ...) into FeedR.Shared — a streaming abstraction with a no-op default, a Redis implementation that isn't quite what its namespace claims, and a DI trick that makes the whole thing swappable.