What Swift-style actors could look like in C#
Martijn Storck
What I like about Swift actors is that they’re part of the language. Actor isolation isn’t a framework convention or a runtime API bolted onto the side. You declare an actor, write ordinary methods that work with its state and let the compiler guard the boundary.
That made me wonder whether we could replicate the same idea in C#. Mention actors in a .NET application and the conversation quickly turns to Akka.NET or Orleans. Those are interesting systems, but they also bring concepts such as actor identities, supervision, distribution, placement and persistence. What if C# itself understood that an object owns mutable state and concurrent callers have to take turns accessing it?
That is what Swift gives you. An actor is a reference type whose mutable state is isolated. Code inside the actor accesses that state normally, while code outside it has to wait for its turn:
actor ShoppingCart {
private var items: [String] = []
func add(_ item: String) {
items.append(item)
}
func getItems() -> [String] {
items
}
}
let cart = ShoppingCart()
await cart.add("Coffee")
let items = await cart.getItems()
Even if you know only C# and not Swift, the use of await might stand out here. Neither add nor getItems is marked async, because their bodies do not perform asynchronous work. In C#, awaitability is normally part of a method’s return type. Swift also considers the isolation of the caller: code outside the actor may have to suspend until it can enter the actor, so the call requires await. Code already running on the same actor can call the methods synchronously. The potential suspension belongs to crossing the isolation boundary, not to the method body itself.
If you know Erlang/OTP, the underlying shape is familiar. A GenServer is a state-owning process with a mailbox. It handles synchronous calls and asynchronous casts sequentially, returning an updated state after each request. Swift puts a different interface on that idea: you call ordinary methods instead of sending explicit messages to callbacks, and the compiler enforces the isolation boundary.
That is the level of actor I am interested in here. There is no distributed runtime hiding in the Swift example. The important part is that only isolated code can access the actor’s state, and that such code has to take turns. Calls from outside the actor are asynchronous because they may have to wait.
Building the small version in C#
The runtime part is not particularly exotic. System.Threading.Channels already gives us a synchronized FIFO queue with multiple producers and a single consumer. That is most of what we need for a tiny in-process actor:
using System.Threading.Channels;
public abstract class Actor
{
private readonly Channel<Action> _mailbox =
Channel.CreateUnbounded<Action>(
new UnboundedChannelOptions
{
SingleReader = true,
AllowSynchronousContinuations = false
});
protected Actor()
{
_ = ProcessMessages();
}
protected Task Invoke(Action action)
{
var completion = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
_mailbox.Writer.TryWrite(() =>
{
try
{
action();
completion.SetResult();
}
catch (Exception exception)
{
completion.SetException(exception);
}
});
return completion.Task;
}
protected Task<T> Invoke<T>(Func<T> action)
{
var completion = new TaskCompletionSource<T>(
TaskCreationOptions.RunContinuationsAsynchronously);
_mailbox.Writer.TryWrite(() =>
{
try
{
completion.SetResult(action());
}
catch (Exception exception)
{
completion.SetException(exception);
}
});
return completion.Task;
}
private async Task ProcessMessages()
{
await foreach (var message in _mailbox.Reader.ReadAllAsync())
{
message();
}
}
}
Every invocation puts a delegate in the mailbox and returns a TaskCompletionSource-backed task to the caller. There can be many concurrent writers, but ProcessMessages is the only reader. It executes one delegate at a time and completes the caller’s task with either the result or the exception. The operations themselves are deliberately small synchronous state changes; the caller waits asynchronously for its operation to reach the front of the queue.
An application actor still looks like a fairly ordinary C# object:
public sealed class ShoppingCart : Actor
{
private readonly List<string> _items = [];
public Task Add(string item) =>
Invoke(() => _items.Add(item));
public Task<string[]> GetItems() =>
Invoke(() => _items.ToArray());
}
Concurrent callers can now use the cart without a lock around every operation:
var cart = new ShoppingCart();
await Task.WhenAll(
cart.Add("Coffee"),
cart.Add("Tea"),
cart.Add("Cake"));
string[] items = await cart.GetItems();
Only the mailbox consumer touches _items, so those mutations cannot run at the same time. GetItems also returns a copy rather than allowing the mutable list itself to escape the actor.
This is enough runtime for plenty of in-process domain objects. It is also not something I would quietly turn into an application-wide convention, because its most important rule is voluntary. Nothing stops someone from adding this property:
public IReadOnlyList<string> BrokenItems => _items;
The compiler has no idea that ShoppingCart is meant to be isolated. The code builds, the mutable list escapes and the actor guarantee is gone. The mailbox was the easy part.
If actors were part of C#
I would want actors to look boring. Imagine actor as another kind of reference type alongside class and record:
public actor ShoppingCart
{
private readonly List<string> _items = [];
public void Add(string item)
{
_items.Add(item);
}
public string[] GetItems() => [.. _items];
}
Inside the actor, this is normal synchronous C#. Outside the actor, crossing the isolation boundary would be asynchronous:
var cart = new ShoppingCart();
await cart.Add("Coffee");
string[] items = await cart.GetItems();
The compiler could generate something close to the Channel<Action> dispatch loop above. More importantly, it would understand the boundary. Actor state could only be accessed by isolated code, external member calls would require await, and values crossing the boundary would have to be safe to share. A deliberately non-isolated member could exist for immutable state, but opting out should be explicit.
This syntax is only a sketch, not a C# proposal. There are plenty of language-design questions hiding behind those few lines: inheritance, interfaces, cancellation, actor shutdown and which types are safe to pass across the boundary. The handwritten example also uses an unbounded mailbox, so a real implementation would need an answer for backpressure and overload.
None of those questions require actors to become Orleans. A language-level actor can remain an ordinary in-process object. No cluster, no remoting, no persistence and no globally addressable identity. When those features are the actual requirement, use a system designed for them.
For everything else, I would love to have this small tool in C#. A queue can serialize the work today. What a library cannot add is a compiler that keeps the state isolated.
This post was written with OpenAI Codex, which also generated the header image.