Carl Hewitt and his colleagues published the actor model in 1973. Every computer they could have run it on had one processor. There was no multicore to exploit, no cache coherence to worry about, no NUMA. They were after a clean formalism for concurrent computation — a way to reason about interacting processes, motivated by AI research rather than by hardware.
Fifty years later it’s the model that survives contact with a 64-core machine, and the reason is an accident of good design.
An actor is a sequential program with a mailbox. It owns its state. Nothing else can touch that state. It receives messages one at a time, processes each to completion, and communicates only by sending messages to other actors. That’s the whole model.
Notice what’s absent: shared mutable state. Which means no locks on the hot path, no mutexes, no atomics protecting a data structure two threads are fighting over, no false sharing, no lock convoys, no priority inversion. Concurrency exists between actors, never inside one, so each actor’s code reads like single-threaded code and can be reasoned about that way.
Hewitt wasn’t designing around cache lines. He got there anyway, because the constraint that made the formalism tractable — no shared state — is the same constraint that makes code scale across cores.
Kaspar’s actor framework
Kaspar is my open-source CME futures trading system, and the actor framework underneath it is the part I’d point at first. C++20, MIT licensed.
It’s good at throughput — actors on separate threads and cores, no locks between them, scaling with hardware. It’s also good at single-threaded latency, where the whole path collapses into one thread and one core with nothing in the way. Those usually sound like opposing design goals, and in most frameworks you have to pick one up front.
Here you don’t, because it isn’t a code decision. You build the system as a graph of actors, get it correct, and then work out where the critical paths are — with a profiler, on real data, after the thing exists. The topology that makes it fast is applied at deployment.
That ordering matters more than it sounds. Guessing your hot path before you’ve built the system is how you end up optimizing something that turns out not to be on it.
Two design decisions matter most. Dispatch is O(1) — a vector lookup by integer message ID, no virtual dispatch, no hash maps, no RTTI on the fast path. And messages can be passed on the stack, with no allocation and no queueing at all.
Here’s the standard version first.
Ping-pong, the normal way
Straight from the repo (actors/cpp/examples/ping_pong.cpp):
struct Ping : public Message_N<100> {
int count;
Ping(int c) : count(c) {}
};
struct Pong : public Message_N<101> {
int count;
Pong(int c) : count(c) {}
};
class PingActor : public Actor {
Actor* pong_actor;
Actor* manager;
int max_count;
public:
PingActor(Actor* pong, Actor* mgr, int max = 5)
: pong_actor(pong), manager(mgr), max_count(max)
{
strncpy(name, "PingActor", sizeof(name));
MESSAGE_HANDLER(msg::Start, on_start);
MESSAGE_HANDLER(Pong, on_pong);
}
void on_start(const msg::Start*) {
pong_actor->send(new Ping(1), this);
}
void on_pong(const Pong* m) {
if (m->count >= max_count) {
manager->terminate();
} else {
pong_actor->send(new Ping(m->count + 1), this);
}
}
};
class PongActor : public Actor {
public:
PongActor() {
strncpy(name, "PongActor", sizeof(name));
MESSAGE_HANDLER(Ping, on_ping);
}
void on_ping(const Ping* m) {
reply(new Pong(m->count));
}
};MESSAGE_HANDLER registers the handler at construction. send() is asynchronous: the message goes into the target’s mailbox and the caller returns immediately. The target’s own thread picks it up and dispatches.
That’s the classic actor model, and it’s correct. It’s also doing a lot of work per message: a heap allocation, a mutex and condition variable to enqueue, a thread wakeup on the other side, and a delete when the handler returns. Fine at ten thousand messages a second. Not fine when you’re trying to get from a market data packet to an order on the wire.
The same thing, on the stack
void on_start(const msg::Start*) {
for (int i = 1; i <= max_count; ++i) {
Ping p(i); // stack allocated
auto rep = pong_actor->fast_send(&p, this); // runs in THIS thread
auto* pong = static_cast<const Pong*>(rep.get());
// pong->count is available right here
}
manager->terminate();
}fast_send doesn’t queue. It looks up the handler and calls it in the caller’s thread, then hands back whatever the handler passed to reply(). The message never touches the heap, never enters a mailbox, and never wakes another thread.
What’s left is an index into a vector and an indirect call — which is the same shape as a virtual method call, and costs about the same. On the fast path, sending a message to another actor is roughly as expensive as calling a virtual function. No allocation, no mutex contention, no scheduler. You get the actor model’s isolation guarantees at the price of a vtable dispatch you’d have paid anyway in any C++ system with an interface in it.
The detail worth noticing: PongActor is unchanged. Same class, same handler, same reply() call. The receiving actor has no idea whether it was invoked asynchronously from its own thread or synchronously from someone else’s. That’s not a coincidence — it’s the property the whole design rests on. The actor is written once, and how it gets driven is somebody else’s decision.
Where actors get mapped to threads
Which brings us to the part that actually produces the latency number.
In Kaspar, the mapping from actors to threads is not a property of the actor code. It’s a deployment decision, made in configuration and wiring, changeable without touching a single handler:
Own thread. Each actor gets a
std::threadand a mailbox, optionally pinned to a specific core.Group. Several actors share one thread and one message queue. Messages between them never cross a thread boundary.
Remote. The actor lives in another process, or on another machine, reached over ZMQ. The
send()call site is identical.fast_send. No thread transition at all — the handler executes in the caller’s stack frame.
So you write the system as a clean graph of actors, then decide separately where the boundaries fall. And you decide that by asking one question: where does the critical path cross a thread?
Every one of those crossings costs you a context switch. The sender enqueues, signals a condition variable, and the kernel has to schedule the receiving thread — which means a trip through the scheduler, a cache that’s now cold for the incoming thread, and jitter you don’t control. A few microseconds each time, and worse in the tail than in the mean.
The optimization is to collapse the hot path. On the market-data-to-order path, put the book, the execution logic, and the order manager in the same Group, or wire them with fast_send. Everything that isn’t on that path — logging, database writes, monitoring, the console — goes to its own thread, its own core, and can be as slow as it likes without touching you.
You can’t get to zero. Something has to hand you the packet, and something has to put your order on the wire, so the kernel is involved at both ends. But between those two points, the entire decision path can run in one thread, on one core, without a single scheduler interaction.
Narrowed down that way, Kaspar’s critical path is about two context switches, and tick-to-trade comes in around 150 microseconds.
The point isn’t the number in isolation. It’s that the number is a deployment property. The actor code didn’t change. The strategy didn’t change. What changed was where the thread boundaries were drawn — and that’s a thing you can measure, adjust, and re-measure without rewriting anything.
What’s different from Hewitt
The classical model has one actor, one mailbox, one logical thread of control, and asynchronous send as the only way to communicate. Kaspar keeps the semantics and changes two things, both of which Hewitt had no reason to care about in 1973.
fast_send. In the pure model every message is asynchronous — you post it and carry on. That’s the right default and it’s what makes the model composable. But when the caller is going to block on the answer anyway, queueing the message buys you nothing and costs you a thread transition. fast_send runs the handler in the caller’s thread and returns the reply directly. Semantically the receiving actor still processes one message at a time and still owns its state; the only thing that’s changed is whose stack it runs on. This is the single biggest departure, and it’s where most of the latency win comes from.
Explicit actor-to-thread mapping. Hewitt’s actors are logical entities. How they get scheduled onto hardware is an implementation detail the model deliberately doesn’t specify — which is fine for a formalism, and useless when you’re trying to hit a latency target. Kaspar makes the mapping an explicit, first-class deployment decision.
The mechanism is the Group: a set of actors sharing one thread and one message queue. Actors in the same Group exchange messages without any thread transition at all.
One thread per Group is a deliberate choice, not a limitation I haven’t gotten to. It could be extended to a thread pool, but I haven’t needed it — and there’s a reason to be suspicious of the idea. Multiple threads servicing one queue means messages between actors in that Group can now land on different threads, which puts context switches back into exactly the place you built the Group to remove them from. You’d be adding parallelism inside the unit whose whole purpose is to be a serial fast path.
If a group of actors is genuinely saturating its thread, the answer is simpler: split them into two Groups. Now you’ve made a deliberate decision about where a thread boundary sits, and you can see it in the topology rather than having the scheduler make it for you at runtime. Same total thread count, but the boundary is where you put it.
Actors are the right shape for AI agents
This one I didn’t design for, and it’s turned out to matter as much as the latency.
An actor is a small, closed unit with an explicit contract: these messages in, those messages out, private state, no reaching into anything else. That’s an easy thing for a human to hold in their head — and it turns out to be an even better fit for a coding agent, because the agent never has to understand the whole system to write a correct piece of it.
You can hand Claude Code a description in English — “an actor that watches book updates, tracks the imbalance over a rolling window, and emits a signal message when it crosses a threshold” — and get back the class, the message structs with their IDs, the MESSAGE_HANDLER registrations, and the handler bodies. The structure is mechanical and the contract is local, so there’s very little room for the agent to be creatively wrong about the parts that aren’t the actual logic.
Testing is where it really pays off. An actor is trivially testable because you can drive it directly: construct it, send it a message, assert on the reply. No harness, no mocking, no test scaffolding to invent.
TEST(ActorTest, HandlesPingMessage) {
PongActor pong;
Manager mgr;
mgr.manage(&pong);
auto reply = pong.fast_send(new Ping(42), nullptr);
ASSERT_NE(reply, nullptr);
auto* pong_msg = dynamic_cast<const Pong*>(reply.get());
ASSERT_NE(pong_msg, nullptr);
EXPECT_EQ(pong_msg->count, 42);
}fast_send again — it runs the handler synchronously in the test’s own thread, so the test has no threading in it at all. No sleeps, no waiting on a condition variable, no flaky timing. Agents write these without difficulty and, more importantly, they write ones that actually pass for the right reason.
Which is the broader point. When you’re letting an agent generate code, what you owe it isn’t review — it’s an architecture where the dangerous mistakes are structurally unavailable. An actor can’t corrupt another actor’s state, can’t peek at data it wasn’t sent, and can’t introduce a race, because it has no shared memory to race over. The blast radius of a bad generation is one mailbox.
Simulation
The same mechanism that makes the fast path fast makes the backtest correct.
Run every actor in one Group and you get strict, single-queue message ordering across the entire pipeline — market data, order placement, fill matching, all in the order they occurred. Same PCAP in, bit-identical output every time. Deterministic replay isn’t a separate feature bolted on for testing; it’s the same thread-mapping knob turned the other direction.
An actor model built for a 1973 mainframe gives you, on 2026 hardware: no locks, linear scaling across cores, a latency path you tune at deployment rather than in code, and a backtest that can’t see the future because the strategy has no way to ask.
Not bad for a formalism designed before anyone had two processors to run it on.
Kaspar is MIT licensed: github.com/vincent212/kaspar-hft. The actor framework lives in actors/, the ping-pong example in actors/cpp/examples/.
v@m2te.ch

