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 = truedocuments that only one loop consumes messages.- Several producers may call
SendAsync()concurrently becauseSingleWriter = false. BoundedChannelFullMode.Waitcreates backpressure: when the mailbox is full, writers wait instead of allowing unlimited memory growth.- The
totalvariable lives only insideRunAsync(). Because one consumer owns it, nolockis 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