๐Ÿ  VisualStudioTutor.com  ยท  C# Tutorial Home  ยท  C# Lesson 38 of 40
Lesson 38 of 40 Concurrency Expert โฑ 40 min

Channels & the Actor Model

Build an asynchronous mailbox with System.Threading.Channels and use an actor-like design in which one message loop owns mutable state, reducing the need for explicit locks.

Part 1: What You Will Learn

System.Threading.Channels provides an efficient asynchronous queue. It is useful for producer-consumer pipelines and for actor-like designs where messages are processed by one owner of the state.

  • Create bounded and unbounded channels.
  • Write messages asynchronously with a ChannelWriter.
  • Read messages with ReadAllAsync().
  • Use bounded capacity to apply backpressure.
  • Build a small actor-like component without sharing its mutable state.

Project setup: Create a .NET 10 Console App named ChannelActorDemo. System.Threading.Channels is included with modern .NET.

Part 2: Topic-Specific Working Example

This example creates a shopping-cart actor. Multiple producers can send messages, but only the actor's message loop changes the cart total.

using System.Threading.Channels;

await using var cart = new CartActor();

Task[] producers = Enumerable.Range(1, 5)
    .Select(i => cart.SendAsync(new AddItem(i * 10m)).AsTask())
    .ToArray();

await Task.WhenAll(producers);

decimal total = await cart.GetTotalAsync();
Console.WriteLine($"Cart total: {total:C}");

public abstract record CartMessage;
public sealed record AddItem(decimal Price) : CartMessage;
public sealed record GetTotal(TaskCompletionSource<decimal> Reply) : CartMessage;
public sealed record Stop : CartMessage;

public sealed class CartActor : IAsyncDisposable
{
    private readonly Channel<CartMessage> _mailbox;
    private readonly Task _messageLoop;

    public CartActor()
    {
        _mailbox = Channel.CreateBounded<CartMessage>(
            new BoundedChannelOptions(100)
            {
                SingleReader = true,
                SingleWriter = false,
                FullMode = BoundedChannelFullMode.Wait
            });

        _messageLoop = RunAsync();
    }

    public ValueTask SendAsync(CartMessage message) =>
        _mailbox.Writer.WriteAsync(message);

    public async Task<decimal> GetTotalAsync()
    {
        var reply = new TaskCompletionSource<decimal>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        await SendAsync(new GetTotal(reply));
        return await reply.Task;
    }

    private async Task RunAsync()
    {
        decimal total = 0;

        await foreach (CartMessage message in _mailbox.Reader.ReadAllAsync())
        {
            switch (message)
            {
                case AddItem add:
                    total += add.Price;
                    break;

                case GetTotal query:
                    query.Reply.SetResult(total);
                    break;

                case Stop:
                    _mailbox.Writer.TryComplete();
                    return;
            }
        }
    }

    public async ValueTask DisposeAsync()
    {
        await SendAsync(new Stop());
        await _messageLoop;
    }
}

Part 3: How the Channel and Actor Work

  • The Channel<CartMessage> is the actor's mailbox.
  • SingleReader = true documents that only one loop consumes messages.
  • Several producers may call SendAsync() concurrently because SingleWriter = false.
  • BoundedChannelFullMode.Wait creates backpressure: when the mailbox is full, writers wait instead of allowing unlimited memory growth.
  • The total variable lives only inside RunAsync(). Because one consumer owns it, no lock is required.
  • TaskCompletionSource<decimal> turns a message into a request/reply operation.

This is actor-like rather than a full actor framework. Production actor systems may add supervision, persistence, remoting, routing, retries, and distributed placement.

Part 4: Mini Project & Practice

Mini project: build an inventory actor with AddStock, RemoveStock, and GetQuantity messages. Reject a removal when insufficient stock exists.

Next, experiment with a very small bounded capacity such as 2 and send many messages quickly. Observe how writers wait when the consumer cannot keep up.

Best practice: use channels when you need asynchronous handoff or pipeline coordination. Do not add an actor abstraction when a normal method call or immutable data flow is simpler.

When you are comfortable with this lesson, continue to Lesson 39.

C# in Visual Studio 2026

๐Ÿ“˜ This lesson is part of the book C# in Visual Studio 2026 by Dr. Liew Voon Kiong.

View on Amazon Kindle Edition