How to Implement an Actor Model in Go
You're building a chat room. Ten users type messages at once. If you update the shared message history directly, you hit race conditions. You could protect the state with a mutex, but mutexes block threads and can get messy when the state update involves multiple steps or I/O. Instead, you want an entity that owns its state, receives requests via a message queue, and processes them one at a time. That entity is an actor. Go doesn't have a built-in actor framework, but the primitives to build one are right there in the standard library.
The Actor Pattern
An actor is an object that encapsulates state and behavior. It never shares memory. It communicates only by sending and receiving messages. Think of an actor like a receptionist at a busy office. Clients drop requests into a tray. The receptionist picks up one request, handles it, updates the logbook, then picks up the next. Two clients can drop requests simultaneously, but the receptionist processes them sequentially. The logbook is safe because only the receptionist touches it.
In Go, the actor is a goroutine. The tray is a channel. The logbook is the local variables inside the goroutine. The goroutine runs a loop that reads from the channel. Every message triggers a state update. Because the loop runs in a single goroutine, updates are serialized automatically. No mutex needed.
Actors isolate state. Channels coordinate work.
Minimal Actor
Here's the skeleton of an actor: a struct holds a channel, and a goroutine loops over that channel to process messages.
// Actor encapsulates state and a message channel.
type Actor struct {
// inbox queues messages for sequential processing.
inbox chan string
}
// run loops over the inbox, processing one message at a time.
func (a *Actor) run() {
// range exits when the channel closes, stopping the goroutine.
for msg := range a.inbox {
fmt.Println("Processing:", msg)
}
}
The struct defines the shape. The inbox field is private, following Go's convention of lowercase names for unexported fields. The run method contains the processing loop. The receiver name a is short and matches the type, which is the standard Go style for methods.
Here's how you create the actor, send a message, and shut it down.
func NewActor() *Actor {
a := &Actor{
// Buffer size 1 prevents deadlock on the first send.
inbox: make(chan string, 1),
}
// The goroutine is the actor's execution context.
go a.run()
return a
}
func (a *Actor) Send(msg string) {
a.inbox <- msg
}
func (a *Actor) Close() {
close(a.inbox)
}
func main() {
actor := NewActor()
actor.Send("Hello")
// Close stops the actor and releases the goroutine.
actor.Close()
}
The constructor creates the channel with a buffer of one. This allows the first send to return immediately even if the goroutine hasn't started reading yet. The Send method pushes a message into the inbox. The Close method closes the channel, which signals the range loop to exit. The goroutine finishes, and the actor is done.
The goroutine owns the state. The channel is the only door.
How It Runs
When you call NewActor, the function allocates the struct and creates the channel. It launches a goroutine that calls run. The goroutine blocks on range a.inbox waiting for data. Back in main, you call Send. The send operation puts the value into the channel buffer. Because the buffer has space, Send returns immediately. The goroutine wakes up, pulls the value, and prints it. Finally, Close closes the channel. The range loop detects the close, exits, and the goroutine finishes. The program ends.
Even if ten goroutines call Send concurrently, the run loop processes them one by one. The channel serializes the access. The actor's internal state is safe because only one goroutine ever touches it.
Actors That Reply
Real actors often need to reply to requests. You can't just send a message and forget it if you need the result. The standard pattern is to include a reply channel in the message. The caller creates a channel, sends it to the actor, and blocks waiting for a response. The actor processes the request and sends the result back.
Here are the types and constructor for a counter actor that returns the new value.
// msg carries a reply channel so the actor can send results back.
type msg struct {
reply chan int
}
// Counter holds the state and inbox.
type Counter struct {
inbox chan msg
count int
}
func NewCounter() *Counter {
c := &Counter{inbox: make(chan msg, 1)}
go c.run()
return c
}
The msg struct wraps the reply channel. The Counter struct holds the inbox and the internal count. The constructor starts the processing loop.
Here's the loop and the public API.
func (c *Counter) run() {
for m := range c.inbox {
c.count++
// Write to reply channel to unblock the caller.
m.reply <- c.count
}
}
func (c *Counter) Increment() int {
// Buffered reply channel prevents deadlock if the actor crashes.
reply := make(chan int, 1)
c.inbox <- msg{reply: reply}
// Wait for the actor to process and respond.
return <-reply
}
The run method increments the count and sends the result to the reply channel. The Increment method creates a buffered reply channel, sends the message, and waits for the response. The reply channel is buffered to one so the actor can send the result even if the caller has already moved on or crashed. Without the buffer, the actor could block forever trying to send to a dead caller.
In production code, pass a context.Context as the first argument to Increment. If the context cancels, you need a way to abort the send or the receive. Actors often leak goroutines if a reply channel is never read. Always ensure the caller reads the reply or the actor handles cancellation.
Reply channels turn requests into conversations. Buffer the reply to avoid deadlock.
Handling Multiple Commands
A single actor often handles different kinds of requests. You can use an interface for the message type to keep the inbox clean. The actor loops over the interface and calls a method on the message to handle itself. This avoids giant switch statements and makes adding new commands easy.
// Command defines the interface for all messages.
type Command interface {
handle(c *Counter)
}
// IncrementCmd requests an increment and expects a reply.
type IncrementCmd struct {
reply chan int
}
func (cmd IncrementCmd) handle(c *Counter) {
c.count++
cmd.reply <- c.count
}
// ResetCmd sets the count back to zero.
type ResetCmd struct{}
func (ResetCmd) handle(c *Counter) {
c.count = 0
}
The Command interface requires a handle method. Each message type implements this method with its own logic. The actor's loop becomes trivial.
func (c *Counter) run() {
for cmd := range c.inbox {
// Delegate processing to the message itself.
cmd.handle(c)
}
}
The loop just calls handle. The message knows how to update the state. This pattern scales well. You can add DecrementCmd, GetCountCmd, or ShutdownCmd without touching the loop.
Actors introduce a small overhead. Every message goes through a channel, which involves a scheduler handoff. If your actor just increments an integer, a mutex is faster. Actors win when the work inside the loop is substantial, or when the state update involves multiple fields that must stay consistent. The serialization cost is the price you pay for eliminating race conditions in complex state machines.
Pitfalls and Errors
If you create an unbuffered channel and try to send before the goroutine starts reading, the program deadlocks. The runtime halts with fatal error: all goroutines are asleep - deadlock!. Always buffer the inbox to at least one, or ensure the goroutine is running before the first send.
If you close the inbox channel twice, the program panics with panic: close of closed channel. Use a sync.Once or a boolean flag to guard the close operation if multiple callers might try to stop the actor.
The worst bug is the silent leak. If the actor blocks on a receive and no one sends, the goroutine stays alive forever. If you spawn actors dynamically, your process eats memory until it crashes. Always design a shutdown path. Closing the inbox is the standard way to signal termination. If the actor spawns child goroutines, it must wait for them to finish before exiting.
A leaked actor is a memory leak waiting to happen. Close the inbox.
When to Use Actors
Use an actor when you need to serialize access to mutable state without locking. Use a mutex when you have simple shared data and high contention where channel overhead is too high. Use a worker pool when you have many independent tasks and need to bound concurrency. Use plain sequential code when the logic is linear and concurrency adds no value.
Actors solve complexity. Mutexes solve speed. Pick the right tool.
Where to go next
Actors often need timeouts and cancellation. Learn how to control lifecycles with context.WithTimeout vs context.WithDeadline.
If you need actors across network boundaries, look at Machinery for distributed task processing.
Master the plumbing for cancellation and deadlines in the Complete Guide to the context Package.