DEV Community

Cover image for Actors in Go: How lockstep Runs One Actor in Your Program and in Tests
Hamed Yousefi
Hamed Yousefi

Posted on Edited on Originally published at hmdsefi.hashnode.dev AI-assisted

Actors in Go: How lockstep Runs One Actor in Your Program and in Tests

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
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.Spawn returns a PID[Msg], which accepts only Msg values, so a message of another type doesn't compile.
  • The mailbox is bounded. It holds 1024 messages by default. When it's full, Send returns ErrMailboxFull instead 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.
  • Receive handles one message at a time, so stock and sold need no lock.
  • A reply is a message. Sold carries a ReplyTo[int], the typed version of part 1's Get(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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 Do that finds both full resolves with ErrOverloaded.
  • 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
    })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The test calls the same Receive as the program. What changes is the engine underneath:

Two engines run the same Receive. Left, the production engine behind lockstep.New(): a global queue of ready actors and three workers with local queues, where workers 1 and 2 run turns of actors and idle worker 3 steals half of worker 1's queue; Charge.Run calls the payment provider on a bounded pool. Right, the simulation engine behind sim.Simulate on faultline: one event queue in virtual-time order holding real events from the shop's run: Buy from alice at 13.8 ms, Buy from bob at 41.6 ms, the payment.charge result at 48.4 ms, and Sold from alice at 1.042 s. The seed orders events at the same time and draws every latency and fault, and a test handler answers payment.charge. A table compares turns, turn budget, time, randomness, effects and replay.
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:

Animation of seed 0xb12302fa26394756 on the shop that reserves first, drawn from its trace. The shop starts with stock 1 and sold 0. Alice's Buy arrives at 13.8 ms; the shop reserves the item, so stock drops to 0, and starts a charge. Bob's Buy arrives at 41.6 ms while the charge is in flight; stock is 0, so the shop turns him away. Alice's charge succeeds at 48.4 ms and sold becomes 1. At 1.04 s Alice asks how many were sold and gets 1, and at 2 s the final check passes. The run has 57 trace records and the trace hash 0xa183b40775972eab.
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 }
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode
balance: 5
balance: 10
Enter fullscreen mode Exit fullscreen mode

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:

A persistent actor handles Deposit 5. Handle returns the event Deposited 5. lockstep writes the event to the journal, then commits it with fsync, then applies it to the state, balance 5 to 10, and only then runs the Then callback that replies 10. On restart, the actor recovers its state from the newest snapshot and the events after it before it takes the next command. In production the store uses the operating system's files; in a simulation the same store runs on faultline's simulated disk.
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 Receive that 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 bench module runs the same workloads on lockstep, protoactor-go, Hollywood and GoAkt. In the CI run that bench/RESULTS.md reports, 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
Enter fullscreen mode Exit fullscreen mode

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

Elsewhere

Top comments (0)