Actor Style Concurrency in Go With One Owner Goroutine
Build a generic actor in Go: one goroutine owns the state, callers send messages, with replies, backpressure, panic safety, clean shutdown and a benchmark.

The actor model is older than Go, and Go programmers reinvent it constantly without using the word. A goroutine owns some state. Everyone else who wants to read or change that state sends it a message. The goroutine processes the messages one by one, in order, so the state is only ever touched from one place, and there are no locks to take in the wrong order. The pattern fits a rate limiter that refills over time, a connection registry that must notify when entries expire, a game room, a device session, a job scheduler: things with state and behavior, where the behavior is easier to write as a sequential loop than as a set of methods guarded by a mutex.
Tested with Go 1.25.6, Apple M1 Max (10 cores), macOS on arm64
This guide builds a small generic actor, shows how to handle replies, backpressure, panics and shutdown, tests it under the race detector, and measures what it costs against a mutex so you can decide when it is worth it. It is part of the concurrency patterns cluster, applies the ownership idea from sharing memory by communicating, and is the design that the comparison in channels or mutexes calls the owner goroutine. Everything ran on Go 1.25.6.
The shape
Strip the idea to its parts. There is some state S. There is a mailbox, a channel of messages. There is a loop that receives a message, applies it to the state, and goes back for the next one. Everything else (replies, stopping, errors) is extra structure around that loop.
The usual Go version of a message is a closure. Instead of declaring a struct type for every operation and a big switch to dispatch it, the caller sends a function that receives a pointer to the state:
package main
import (
"errors"
"fmt"
"sync"
)
// ErrStopped is returned when a message is sent to an actor that has stopped.
var ErrStopped = errors.New("actor stopped")
// Actor owns a value of type S and runs messages against it, one at a time.
type Actor[S any] struct {
state S
inbox chan func(*S)
quit chan struct{} // closed by Stop; tells the loop to finish up
done chan struct{} // closed by the loop when it has exited
once sync.Once
}
// Spawn starts an actor with the given initial state and mailbox capacity.
func Spawn[S any](initial S, mailbox int) *Actor[S] {
a := &Actor[S]{
state: initial,
inbox: make(chan func(*S), mailbox),
quit: make(chan struct{}),
done: make(chan struct{}),
}
go a.loop()
return a
}
func (a *Actor[S]) loop() {
defer close(a.done)
for {
select {
case fn := <-a.inbox:
a.run(fn)
case <-a.quit:
for { // finish what was already accepted
select {
case fn := <-a.inbox:
a.run(fn)
default:
return
}
}
}
}
}
// run applies one message. A panic in a message must not kill the actor, or every later
// caller would wait for a goroutine that no longer exists.
func (a *Actor[S]) run(fn func(*S)) {
defer func() {
if r := recover(); r != nil {
fmt.Println("actor recovered from panic:", r)
}
}()
fn(&a.state)
}The state field is only read or written inside loop (through run), and that is the entire safety argument. There is no mutex because there is no sharing: any code that wants the state has to be a closure that runs on the actor's goroutine, which is the one place the state is allowed to be touched.
Three details deserve a sentence each. The mailbox is a buffered channel with a size chosen by the caller, so the actor has a defined amount of queued work. The loop has two exits from its main select, and the quit branch drains whatever is already in the mailbox before returning, so work that was accepted is not lost. And run recovers from panics, which I come back to under failure handling.
Send and Call
There are two ways to talk to an actor. Send is fire and forget: queue the closure and return. Call also gets an answer back, by creating a reply channel and having the closure fill it. Go does not let a method have its own type parameters, so Call is a function rather than a method:
package main
// Send queues fn to run on the actor. It blocks while the mailbox is full, which is the
// backpressure, and returns ErrStopped if the actor has been stopped.
func (a *Actor[S]) Send(fn func(*S)) error {
select {
case <-a.quit:
return ErrStopped
default:
}
select {
case a.inbox <- fn:
return nil
case <-a.quit:
return ErrStopped
}
}
// Call runs fn on the actor and returns its result. It gives up with ErrStopped if the
// actor stops before answering.
func Call[S, R any](a *Actor[S], fn func(*S) R) (R, error) {
reply := make(chan R, 1) // buffered, so the actor never blocks on a caller that left
if err := a.Send(func(s *S) { reply <- fn(s) }); err != nil {
var zero R
return zero, err
}
select {
case r := <-reply:
return r, nil
case <-a.done:
// The loop exited. The reply may still have been produced just before it did.
select {
case r := <-reply:
return r, nil
default:
var zero R
return zero, ErrStopped
}
}
}
// Stop asks the actor to finish its queued work and exit, and waits until it has.
func (a *Actor[S]) Stop() {
a.once.Do(func() { close(a.quit) })
<-a.done
}Each piece of Call is there to prevent a specific hang. The reply channel has capacity one so the actor's closure can always deliver its answer and move on, even if the caller timed out and left. The caller waits on a.done as well as the reply, so if the actor stops before it gets to this message, the caller returns ErrStopped instead of blocking forever. The second select inside the done branch handles the narrow case where the reply was produced just before the loop exited, so a completed answer is not thrown away. And Send checks quit first, so a send to a stopped actor fails fast rather than landing in the buffer where nobody will read it.
Stop is idempotent through the sync.Once, and it waits for done, which means that when Stop returns, every accepted message has been processed and the goroutine has exited. That is the property a shutdown sequence needs (starting and stopping goroutines in order).
A concrete actor: a bank account
An account is the standard teaching example because it has an invariant (the balance never goes negative) that is easy to break with concurrent updates and trivial to hold with one owner. The state is a struct, and the operations are closures that check the invariant and return a result:
package main
import "errors"
var ErrInsufficient = errors.New("insufficient funds")
type account struct {
balance int
history []string
}
// Account is a concurrency-safe bank account. Every method runs on the account's own goroutine.
type Account struct {
actor *Actor[account]
}
func NewAccount(opening int) *Account {
return &Account{actor: Spawn(account{balance: opening}, 64)}
}
func (a *Account) Deposit(n int) error {
return a.actor.Send(func(s *account) {
s.balance += n
s.history = append(s.history, "deposit")
})
}
func (a *Account) Withdraw(n int) error {
res, err := Call(a.actor, func(s *account) error {
if s.balance < n {
return ErrInsufficient
}
s.balance -= n
s.history = append(s.history, "withdraw")
return nil
})
if err != nil {
return err
}
return res
}
func (a *Account) Balance() (int, error) {
return Call(a.actor, func(s *account) int { return s.balance })
}
func (a *Account) Close() { a.actor.Stop() }Notice what the callers see: ordinary methods with ordinary signatures. The actor is an implementation detail, hidden behind Account, which is the right place for it. Deposit is fire and forget, since nobody waits for a deposit to apply; Withdraw and Balance need an answer, so they use Call. The two-level error in Withdraw is worth reading twice: err means the call failed (the actor is stopped), and res is the business result (insufficient funds). Mixing them would make a stopped account look like an empty one.
Here it is under load, with a hundred goroutines each depositing 10 and then trying to withdraw 10:
package main
import (
"errors"
"fmt"
"sync"
)
func main() {
acct := NewAccount(0)
var wg sync.WaitGroup
var refused, applied int
var mu sync.Mutex
for range 100 {
wg.Go(func() {
acct.Deposit(10)
err := acct.Withdraw(10)
mu.Lock()
defer mu.Unlock()
if errors.Is(err, ErrInsufficient) {
refused++
} else if err == nil {
applied++
}
})
}
wg.Wait()
bal, _ := acct.Balance()
fmt.Println("withdrawals applied + refused:", applied+refused)
fmt.Println("balance is never negative:", bal >= 0)
fmt.Println("money conserved:", bal == 1000-applied*10)
acct.Close()
fmt.Println("after Close:", acct.Deposit(1))
}withdrawals applied + refused: 100
balance is never negative: true
money conserved: true
after Close: actor stoppedWhatever interleaving the scheduler picks, the three checks hold: every one of the 100 withdrawals was either applied or refused, the balance is never negative, and the final balance equals the deposits minus the applied withdrawals. A Withdraw that runs before its own goroutine's Deposit has been processed is refused, which is correct: ordering between callers is whatever the mailbox order is, and the invariant holds regardless. After Close, a deposit returns actor stopped. The mutex in main only protects the two tallies the demo keeps; the account itself has none.
Backpressure and the full mailbox
A buffered mailbox of 64 messages absorbs bursts. When it fills, Send blocks, and the caller slows to the speed of the actor. That is usually what you want, because it stops an overloaded actor from accumulating an unbounded backlog, which in a server shows up as memory growth and latency that climbs forever. A bounded queue is the cheapest form of load shedding.
There are cases where blocking is wrong. A request handler should not wait forever on an overloaded actor. Add a TrySend that does a non-blocking send and reports failure so the handler can return a 503, or pass a context to Send and select on it (timeouts on every blocking call). Choose per call site: a background job can block, a user-facing request should give up. The same trade-off, in more depth, is the subject of load shedding.
Failure handling
The recover in run is a design decision with a cost. Without it, a panic in one message kills the actor's goroutine, and since the goroutine owns the state, the state is gone with it; every later Call would wait on a loop that is not running, until the caller sees a.done. With it, the actor survives a bad message and keeps serving, but the state may be half-modified by the closure that panicked, because Go does not roll back. Whether continuing is right depends on the actor: for a cache or a session table it usually is; for one with strict invariants, such as the account, I would rather return an error from the message and treat a panic as a bug that should crash.
A better compromise is to recover, record the panic and mark the actor as failed, so later calls return an error until it is reset. Whatever you pick, a supervisor above the actor should decide what to do next. Running goroutines under a supervisor covers restart policies, and the guidance in recovering from panics in goroutines explains why a goroutine without a recover takes down the whole process.
The other failure is deadlock by re-entrance. A closure running on the actor must not Call the same actor: the actor is busy running this message, so it can never receive the nested one, and the call waits forever. The rule is that code inside a message may touch the state directly (it has the pointer) but must not send to its own mailbox with Call. A plain Send of a follow-up message is fine, as long as the mailbox has room.
Testing it
The tests that matter are the ones about shutdown, because that is where actors hang. These run under -race:
package main
import (
"errors"
"sync"
"testing"
"time"
)
func TestConcurrentDepositsAreAllApplied(t *testing.T) {
acct := NewAccount(0)
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
for range 100 {
if err := acct.Deposit(1); err != nil {
t.Error(err)
}
}
})
}
wg.Wait()
bal, err := acct.Balance() // queued behind every deposit, so it sees them all
if err != nil || bal != 5000 {
t.Fatalf("balance = %d, %v; want 5000", bal, err)
}
acct.Close()
}
func TestWithdrawNeverGoesNegative(t *testing.T) {
acct := NewAccount(100)
defer acct.Close()
if err := acct.Withdraw(101); !errors.Is(err, ErrInsufficient) {
t.Fatalf("want ErrInsufficient, got %v", err)
}
if err := acct.Withdraw(100); err != nil {
t.Fatalf("withdraw of exactly the balance should work: %v", err)
}
}
func TestCallAfterStopDoesNotHang(t *testing.T) {
acct := NewAccount(0)
acct.Close()
done := make(chan error, 1)
go func() { _, err := acct.Balance(); done <- err }()
select {
case err := <-done:
if !errors.Is(err, ErrStopped) {
t.Fatalf("want ErrStopped, got %v", err)
}
case <-time.After(time.Second):
t.Fatal("Call hung on a stopped actor")
}
}
func TestStopFinishesQueuedWork(t *testing.T) {
a := Spawn(0, 100)
for range 100 {
a.Send(func(n *int) { *n++ })
}
a.Stop()
if a.state != 100 { // safe to read: the loop has exited
t.Fatalf("state = %d after Stop, want 100", a.state)
}
}
func TestPanicInMessageDoesNotKillActor(t *testing.T) {
a := Spawn(0, 4)
defer a.Stop()
a.Send(func(*int) { panic("boom") })
got, err := Call(a, func(n *int) int { *n = 7; return *n })
if err != nil || got != 7 {
t.Fatalf("actor should survive a panic, got %d, %v", got, err)
}
}=== RUN TestConcurrentDepositsAreAllApplied
--- PASS: TestConcurrentDepositsAreAllApplied (0.01s)
=== RUN TestWithdrawNeverGoesNegative
--- PASS: TestWithdrawNeverGoesNegative (0.00s)
=== RUN TestCallAfterStopDoesNotHang
--- PASS: TestCallAfterStopDoesNotHang (0.00s)
=== RUN TestStopFinishesQueuedWork
--- PASS: TestStopFinishesQueuedWork (0.00s)
=== RUN TestPanicInMessageDoesNotKillActor
actor recovered from panic: boom
--- PASS: TestPanicInMessageDoesNotKillActor (0.00s)
PASS
ok example.test/lab 1.278sThe one that earns its keep is TestCallAfterStopDoesNotHang. It fails if the done branch is missing from Call, and that is the bug that otherwise appears as a goroutine stuck forever during a deploy. TestStopFinishesQueuedWork reads the state directly, which is legitimate only after Stop returned, because by then the loop has exited and nothing else touches it.
What it costs
An actor trades speed for structure, and the price is worth measuring rather than guessing. This benchmark compares a counter behind a mutex with one behind the actor, using Call for the read-modify-write and Send for a fire-and-forget increment, with all ten cores contending:
package main
import (
"sync"
"testing"
)
func BenchmarkMutexCounter(b *testing.B) {
var mu sync.Mutex
n := 0
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
n++
mu.Unlock()
}
})
}
func BenchmarkActorSend(b *testing.B) {
a := Spawn(0, 1024)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
a.Send(func(n *int) { *n++ })
}
})
a.Stop()
}
func BenchmarkActorCall(b *testing.B) {
a := Spawn(0, 1024)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Call(a, func(n *int) int { *n++; return *n })
}
})
a.Stop()
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkMutexCounter-10 9859413 110.3 ns/op 0 B/op 0 allocs/op
BenchmarkActorSend-10 6802201 178.3 ns/op 0 B/op 0 allocs/op
BenchmarkActorCall-10 1000000 1016 ns/op 160 B/op 2 allocs/op
PASS
ok example.test/lab 3.879sOn this machine the mutex counter cost about 110 ns per increment, a send into the actor about 178 ns, and a call that waits for a reply about 1,016 ns with two allocations and 160 bytes per operation. A send into an actor is therefore the same order of magnitude as a contended lock: producers rarely wait, because the buffer absorbs them. A call is roughly nine times the price, because every operation is a round trip through the scheduler and allocates a reply channel and a closure. So actors are cheap to feed and expensive to ask. That settles where they belong: put an actor around state whose operations are commands (do this, then carry on) or whose operations are slow enough to dwarf the hand-off, and keep reads on hot paths on a different mechanism, such as an atomic snapshot the actor publishes after each change.
Actors that own a clock
The case where an actor is clearly better than a mutex is state that changes on its own schedule. A token bucket refills over time, a session table expires idle entries, a batcher flushes every second. With a mutex you need a separate goroutine with a ticker that takes the lock; with an actor the ticker is one more case in the same select that already serializes everything:
func (l *limiter) loop() {
tick := time.NewTicker(l.refillEvery)
defer tick.Stop()
for {
select {
case req := <-l.requests:
req.reply <- l.take(req.n) // l.tokens is only touched here
case <-tick.C:
l.tokens = min(l.tokens+l.refill, l.capacity)
case <-l.quit:
return
}
}
}Refilling and granting happen in the same goroutine, so a grant can never observe a half-finished refill and there is no lock to order against the timer. The result reads like the description of the problem. That readability is the actual benefit of the pattern, and the one I would weigh most when choosing it over a lock. The token bucket article builds the real thing with golang.org/x/time/rate, and periodic tasks covers the ticker handling.
Sharding actors by key
A single actor serializes everything, which makes it a bottleneck when the state is a large collection and operations on different keys are independent. The remedy is the same as for a sharded lock: run N actors and route each key to one by hash. Operations on the same key stay ordered, because they always land on the same mailbox, and operations on different keys proceed in parallel. A chat server does this with one actor per room, a game with one per match, a device gateway with one per device. The per-entity actor is the natural unit, and it removes the need for any lock around the collection of actors apart from a map from id to actor, which is itself a candidate for a sharded map.
Deciding
I reach for an actor when the state has behavior over time (it expires things, retries, rate limits, notifies), when invariants span several fields, or when I would otherwise need a mutex and a condition variable and a background goroutine anyway. I avoid it for plain shared data, for anything on a hot read path, and wherever "just a mutex" fits in six lines. The pattern also composes: an actor can own a map and publish snapshots through atomic.Pointer for readers, which gives cheap reads and single-owner writes, the combination described under copy on write.
For further reading, the Go blog's pipelines article is the canonical treatment of goroutines that own a stage, and the sync package documentation covers the Once used to make Stop idempotent.


