Velo by Example: Actors

An actor class runs on its own thread with private state. You hold a typed handle actor[T]; calls cross the thread boundary through async (dispatch) and await (collect).

Mark a class with actor. Every new spawns a fresh worker thread, and the variable receives an actor[T] handle, not the instance itself.

actor class Counter() {
    int n = 0;
    func bump() int {
        n += 1;
        n;
    };
};

actor[Counter] c = new Counter();

Call synchronously with await receiver.method(args)async is implied. Reach for a bare async only when you want the future[T] itself, to overlap work, and await it later.

int n = await c.bump();         # 1 — synchronous call

future[int] f = async c.bump();  # start without waiting
int m = await f;                 # 2 — collect later

Each actor owns its data — two actors mean two threads, two private ns, no shared memory, no locks.

actor[Counter] a = new Counter();
actor[Counter] b = new Counter();

await a.bump();   # 1
await b.bump();   # 1 — independent
await a.bump();   # 2

Because async returns immediately, two calls on two actors run in parallel. Wait time is the slower one, not the sum.

future[int] fa = async a.bump();   # both dispatched
future[int] fb = async b.bump();   # before either blocks
int x = await fa;
int y = await fb;

Arguments and return values are deep-copied across the boundary (primitives, arrays, tuples, and data class values). Other actor[T] values cross by reference. Plain class instances, function values, and pointers can't — make them a data class or wrap them in their own actor.

actor class Bag() {
    array[int] held = new array[int]{};
    func put(array[int] xs) void { held = xs; };
};

actor[Bag] b = new Bag();
array[int] xs = new array[int]{1, 2, 3};
await b.put(xs);
xs[0] = 99;             # local mutation —
                        # the actor's copy is unaffected