Part 1 of this series explained actors in pictures, and part 2 showed Discord, LinkedIn and Halo running them on Elixir, Akka and Orleans. None of those runtimes is Go. Go gives you goroutines and channels and leaves the rest to you. You decide which goroutine owns which state, what happens when a channel fills up, what restarts a goroutine that panicked, and how to test any of it.
I've been building lockstep to answer those questions with actors. It's an actor runtime for Go, and v0.2.0 came out on 1 November 2026. You write actors with typed messages, bounded mailboxes, supervision and timers, and lockstep runs them in your program on a pool of worker goroutines. The same actor code also runs in go test, in a simulation with its own clock and network. There, one number, the seed, decides the order of every message.
This article builds part 1's one-item shop with lockstep and runs it in both places.
The shop from part 1, in Go
Part 1 opened with a shop that has one item left in stock. As an actor, the shop owns stock, buyers send it Buy messages, and there is nothing to lock. A real shop takes payment before it hands over the item, so this one charges a card. In lockstep the shop is a Go type with a Receive method. All the code in this article ran on lockstep v0.2.0 and Go 1.27.1:
// Msg is every message the shop accepts.
type Msg interface{ isMsg() }
// Buy asks the shop to sell one item to User.
type Buy struct{ User string }
// Sold asks how many items the shop has sold.
type Sold struct{ Reply lockstep.ReplyTo[int] }
// charged is the result of a Charge effect.
type charged struct {
user string
err error
}
func (Buy) isMsg() {}
func (Sold) isMsg() {}
func (charged) isMsg() {}
// Shop sells a limited stock.
type Shop struct{ stock, sold int }
// NewShop returns a factory for a shop with stock items.
func NewShop(stock int) func() lockstep.Actor[Msg] {
return func() lockstep.Actor[Msg] { return &Shop{stock: stock} }
}
func (s *Shop) Receive(ctx *lockstep.Context[Msg], msg Msg) {
switch m := msg.(type) {
case Buy:
if s.stock == 0 {
return // sold out
}
s.stock-- // reserve the item before the slow payment call
lockstep.Do(ctx, Charge{User: m.User}, func(_ struct{}, err error) Msg {
return charged{user: m.User, err: err}
})
case charged:
if m.err != nil {
s.stock++ // payment failed: put the item back
return
}
s.sold++
case Sold:
m.Reply.Send(ctx, s.sold)
}
}
The parts of an actor from part 1 are all here, with Go types on them:
![The shop actor in lockstep. Its address is a PID[Msg], lockstep://shop/user/shop#100000003. Buy messages from alice and bob wait in a bounded mailbox of 1024 messages. Receive(ctx, msg) takes one message at a time and owns the private state stock: 1 and sold: 0. Four arrows leave Receive: send messages with lockstep.Send or ReplyTo.Send, create actors with lockstep.Spawn, update its own fields for the next message, and start I/O with lockstep.Do, whose result comes back to the mailbox as a charged message.](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq43zedhx662ya4k9s785.png)
Part 1's actor, with Go types and the PID the shop gets in the test below. The fourth arrow is new: the actor starts I/O and gets the result back as a message.
-
The address is a typed PID.
lockstep.Spawnreturns aPID[Msg], which accepts onlyMsgvalues, so a message of another type doesn't compile. -
The mailbox is bounded. It holds 1024 messages by default. When it's full,
SendreturnsErrMailboxFullinstead of letting the queue grow. Part 1 listed unbounded mailboxes as a cost of Erlang's and Akka's defaults, and part 2 showed Discord paying it. -
Receivehandles one message at a time, sostockandsoldneed no lock. -
A reply is a message.
Soldcarries aReplyTo[int], the typed version of part 1'sGet(replyTo).
The shop takes the item out of stock before it charges the card. A charge takes tens of milliseconds, and another Buy can arrive while it's in flight. Part 1's account animation ended on exactly that: the order in which messages arrive decides who is declined. Reserving first checks and takes the item in one turn, lockstep's name for one call to Receive, so no order of messages can sell it twice. Part 4 runs the version that takes the item after the charge.
Effects: I/O that doesn't block the actor
Charging a card means waiting on the network, and an actor must not wait inside Receive, because its mailbox doesn't move while it waits. lockstep.Do starts an effect and returns at once. When the payment provider answers, the function passed to Do turns the result into a charged message, and the shop handles it in a later turn. Charge is the effect, and its Run method makes the real call:
// Charge is the effect that charges a user's card.
type Charge struct{ User string }
func (Charge) Name() string { return "payment.charge" }
func (c Charge) Run(ctx context.Context) (struct{}, error) {
return struct{}{}, chargeCard(ctx, c.User) // the payment provider's API
}
Effects are part of one rule for all actor code: it doesn't block, start goroutines, read the wall clock, or use global randomness. Time comes from ctx.Now, ctx.After and ctx.Every, randomness from ctx.Rand, and I/O from effects. When every delivery, timer, random number and I/O call goes through the runtime, the runtime decides all of them. That's what lets a second engine, the simulation below, decide them differently.
lockstep ships an analyzer, lockstep-vet, that checks the rule. Here it is on a version of the shop that charges the card in a goroutine and records each sale with time.Now:
shop.go:47:3: LS100: go statement starts a goroutine; use lockstep.Do for work outside the turn (actor code: (*Shop).Receive)
shop.go:56:21: LS100: time.Now uses the wall clock; use ctx.Now, ctx.After or ctx.Every (actor code: (*Shop).Receive)
It runs through go vet or as a golangci-lint plugin. It also reports an ignored error from Send, a message the sender keeps using after it sent it, and a reply that nobody waits for. And it reports a message type that Receive has no case for.
The shop in your program
lockstep.New() starts the engine your program runs on, which lockstep calls the production engine. This program spawns the shop with one item, lets Alice buy, lets Bob buy 30 ms later, and asks how many items were sold:
func main() {
sys := lockstep.New()
defer func() { _ = sys.Shutdown(context.Background()) }()
pid, err := lockstep.Spawn(sys, "shop", shop.NewShop(1)) // one item in stock
check(err)
fmt.Println("spawned", pid)
// Alice buys first; Bob buys 30 ms later.
check(lockstep.Send[shop.Msg](sys, pid, shop.Buy{User: "alice"}))
time.Sleep(30 * time.Millisecond)
check(lockstep.Send[shop.Msg](sys, pid, shop.Buy{User: "bob"}))
time.Sleep(100 * time.Millisecond) // the payment answers in 10 to 59 ms
sold, err := lockstep.Ask(sys, pid, func(r lockstep.ReplyTo[int]) shop.Msg {
return shop.Sold{Reply: r}
}).Await(context.Background())
check(err)
fmt.Println("sold:", sold)
}
chargeCard here is a stand-in for the payment provider that answers after 10 to 59 ms, and check exits on an error:
spawned lockstep://local/user/shop#6494268c00000003
sold: 1
2026/11/02 17:07:44 INFO shutdown started node=local actors=1
2026/11/02 17:07:44 INFO shutdown finished node=local elapsed=552.709µs
Ask puts a ReplyTo[int] into the Sold message and returns a reply that Await waits for. Await blocks, so it belongs outside actors. Inside an actor, lockstep.Pipe delivers the reply as a message instead. The last two lines come from the runtime's log/slog logger, which also logs failures, restarts and overload.
The production engine runs actor turns on a pool of worker goroutines, GOMAXPROCS of them by default:
- Turns of different actors run in parallel; turns of one actor never overlap. In one turn, an actor handles up to 32 messages before it gives its worker to another actor. A worker with nothing to do takes ready actors from a busier worker's queue.
- Messages from one sender to one receiver arrive in send order.
-
Effects run on their own pool. At most 64 run at once and 1024 more wait. A
Dothat finds both full resolves withErrOverloaded. - A full mailbox has a policy. The default fails the send. The others drop the newest or oldest message or block the sender.
- Supervision follows Erlang's. One-for-one and all-for-one strategies restart failed actors, with restart limits and backoff.
The same shop in a test
sim.Simulate runs a test body once per seed, each time in a fresh simulated world. This one has three nodes: a server for the shop, and two clients, alice and bob:
func TestOneItemTwoBuyers(t *testing.T) {
opts := sim.Options{Options: faultline.Options{Seeds: 100, Duration: 2 * time.Second}}
sim.Simulate(t, opts, oneItemTwoBuyers)
}
func oneItemTwoBuyers(s *sim.Sim) {
w := s.World()
var shop lockstep.PID[Msg]
var spawnErr error
s.Node("shop", func(sys *lockstep.System) {
shop, spawnErr = lockstep.Spawn(sys, "shop", NewShop(1)) // one item in stock
})
s.Client("alice", func(*lockstep.System) {})
s.Client("bob", func(*lockstep.System) {})
// The payment API answers after 10 to 60 ms, chosen by the seed.
s.HandleEffect("payment.charge", func(c sim.EffectCall) sim.EffectResult {
return sim.EffectResult{Value: struct{}{}, Latency: kernel.Uniform(c.Rand(), 10*time.Millisecond, 60*time.Millisecond)}
})
w.RunFor(10 * time.Millisecond) // let the nodes boot
// Alice buys first; Bob buys 30 ms later.
_ = sim.Send[Msg](s, "alice", shop, Buy{User: "alice"})
w.RunFor(30 * time.Millisecond)
_ = sim.Send[Msg](s, "bob", shop, Buy{User: "bob"})
w.RunFor(time.Second)
sold, err := sim.Ask(s, "alice", shop, func(r lockstep.ReplyTo[int]) Msg { return Sold{Reply: r} })
w.Final("never sell more than the stock", func() error {
if err := errors.Join(spawnErr, err); err != nil {
return err
}
if sold > 1 {
return fmt.Errorf("sold %d items with 1 in stock", sold)
}
return nil
})
}
The timing is the program's, in virtual time, the simulation's own clock: Alice buys at 10 ms and Bob 30 ms later. After a second, Alice asks how many items were sold, and the final check runs when the two seconds are up. w.RunFor(time.Second) moves the clock forward without sleeping. All 100 seeds pass:
ok example.com/shop 0.303s
The test calls the same Receive as the program. What changes is the engine underneath:

Both engines call the same Receive. They differ in who picks the next turn, what time it is, and what answers an effect.
sim.Simulate runs on faultline, my deterministic simulation library for Go. Every turn handles one message and is one event in a single queue ordered by virtual time. The seed breaks ties between events at the same time, and it draws every network latency and every effect's latency. Charge.Run never runs, because a call to a real payment provider can't be replayed. The test answers the effect by its name, payment.charge, with a handler instead.
Here is the first of the 100 seeds, drawn from its trace:

Bob's Buy arrives while Alice's charge is still in flight. The stock already reads 0, so the shop turns him away. Every time in the animation comes from the trace.
On this seed, Bob's Buy arrives at 41.6 ms, 7 ms before Alice's charge comes back. On other seeds the charge comes back sooner, and Bob's Buy arrives after it. The shop sells one item either way.
A run is a list of events, and the trace hash is a hash of all of them. Run this seed again with FAULTLINE_TRACE=full, and its trace files show the same 57 records and the same hash, 0xa183b40775972eab. When a seed fails, the output includes a command that replays that exact run.
State that survives a restart
The shop keeps its stock in memory. If it restarts, NewShop(1) creates it again with one item in stock and nothing sold, so it can sell the same item twice. State that must survive goes in a persistent actor, which v0.2.0 adds in lockstep/persist. This one is the bank account from part 1, written as event sourcing: a Deposit command stores a Deposited event, and the balance is the sum of the events:
func (Account) Initial() int64 { return 0 }
func (Account) Handle(ctx *persist.Context[Deposit, Deposited, int64], _ int64, d Deposit) []Deposited {
ctx.Then(func(ctx *persist.Context[Deposit, Deposited, int64], balance int64) {
d.Reply.Send(ctx, balance) // runs after the event is committed
})
return []Deposited{{Amount: d.Amount}}
}
func (Account) Apply(balance int64, e Deposited) int64 { return balance + e.Amount }
The README's program spawns it with persist.Spawn on a store in the directory data, deposits 5, and prints the balance. Run it twice:
go run .
go run .
balance: 5
balance: 10
The second run starts a new system on the same directory. The account recovers the balance of 5 from its journal before it handles the new deposit:

The reply runs after the fsync, so a reply means the event is on disk. A restart rebuilds the state from the journal.
Handle returns events instead of changing the state, and Apply folds one event into the state. lockstep writes the events to a journal, calls fsync, and only then applies them and runs the callbacks registered with ctx.Then. While the actor recovers or commits, its commands wait in the mailbox. With a snapshot store, it also saves a snapshot every SnapshotEvery events and recovers from the newest one.
In your program, the store, persist/filestore, keeps its files on the local disk. In a test, filestore.New(s.Volume("n1")) runs the same store on node n1's simulated disk.
What lockstep is good for
I'd reach for lockstep when correctness depends on the order of messages and failures: consensus, replication, and ledgers or inventories where a lost or doubled update costs money. It also fits services that retry and fail over, and workflows where many long-lived workers each keep state. Those are the shapes from part 2: one actor per guild, per online member, per game session, on one machine for now.
It fits less well when one piece of state must be updated from many cores at once, because an actor handles one message at a time. A stateless request-response service doesn't need actors at all.
What it costs
- You write actors. lockstep simulates actors, not arbitrary goroutines, so existing goroutine code needs restructuring before a seed can decide its order.
- Your program is one process. There is no remoting or clustering yet. A PID names its node, but nodes exchange messages only in a simulation, so part 1's location transparency exists only in tests for now.
-
A turn can't be preempted. A
Receivethat never returns holds its worker. lockstep logs a turn that has run for 5 seconds, with its worker's stack, but it can't stop it. -
Benchmarks compare it with three Go libraries. The
benchmodule runs the same workloads on lockstep, protoactor-go, Hollywood and GoAkt. In the CI run thatbench/RESULTS.mdreports, from 31 October 2026, ping-pong between one pair of actors took 783 ns per message on lockstep. It took 384 on Hollywood, 423 on GoAkt and 2,670 on protoactor-go. Hollywood and GoAkt also had lower send latency, and lower ask latency with one caller, than lockstep in that run. The file has every row. - The API can change before v1.0.
lockstep needs Go 1.26 or later:
go get github.com/hmdsefi/lockstep@v0.2.0
The README starts with part 1's counter, and the repository is at github.com/hmdsefi/lockstep.
In the next article, I'll take the item after the charge instead of before it, and a seed will find what goes wrong. Then I'll crash nodes, partition the network and retry requests.
Sources
lockstep v0.2.0
- Release notes. https://github.com/hmdsefi/lockstep/releases/tag/v0.2.0
- README: the counter, the production engine and the persistent account. https://github.com/hmdsefi/lockstep/tree/v0.2.0#readme
- "Why lockstep": the shop example this article builds on. https://github.com/hmdsefi/lockstep/blob/v0.2.0/docs/why-lockstep.md
- "lockstep architecture": the two engines, effects, mailboxes and persistence. https://github.com/hmdsefi/lockstep/blob/v0.2.0/docs/architecture.md
- Benchmark results. https://github.com/hmdsefi/lockstep/blob/v0.2.0/bench/RESULTS.md
Elsewhere
- faultline, deterministic simulation testing for Go. https://github.com/hmdsefi/faultline
- Part 1: "The Actor Model: Why It Was Invented and How It Works". https://hmdsefi.hashnode.dev/actor-model-why-it-was-invented-how-it-works
- Part 2: "Actors in Production: How Discord, LinkedIn and Halo Use the Actor Model". https://hmdsefi.hashnode.dev/actor-model-in-production-discord-linkedin-halo
Top comments (0)