Skip to content

The Execution Model

Guide Home | Previous: Monitoring | Next: Backpressure — When Arrival Outpaces Processing

What you get from this chapter

You can tell what runs together inside one Spot, what queues up behind it, and what decides that boundary. The code comes from the Bingo sample README in the examples repository; follow its Download, Build, and Run sections to reproduce the execution-model example.

Spot and Actor summarized it as "work addressed to it is handled one at a time." This chapter covers how far that sentence holds — which work shares one line, which work is split, and what decides the boundary.

1. The Queues Work Waits In

Work arriving at a Spot waits in two queues. Packets addressed to the Spot itself and timers go into the Spot queue; payloads addressed to an Actor that belongs to the Spot go into the Actor queue.

A message addressed to an Actor does not pass through the Spot queue. No Spot callback receives it and hands it on; it enters the Actor queue from the start.

Queue Goes in Does not go in
Spot queue Payloads addressed to the Spot, matching Logical Multicast payloads, timer callbacks, Actor join and leave, lifecycle callbacks Actor business payloads
An Instance Spot's queue Payloads addressed to the Spot and timer callbacks Anything about Actors. Rejected at registration
Actor queue Actor business payloads —

Registering Actor membership or a Logical Multicast subscription on an Instance Spot is rejected when it is registered or when the Spot is prepared, not while it runs.

2. What Decides the Serialization Boundary

Whether work from different queues may run at the same time is decided by the kind of Spot and its execution mode.

Serialization boundary State ownership
Entry Spot Serializes the Spot queue and the Actor queue separately. Different queues may run at the same time Each Actor owns its own. State shared between Actors belongs in an external store
User Spot, SpotWide (default) Serializes Spot handlers, member Actor handlers, timers and lifecycle callbacks through one common gate The Spot instance owns it. State shared with Actors needs no separate synchronization
User Spot, PerActor Serializes per Actor and per Spot lane. Different lanes may run at the same time Each Actor owns its own. State shared between lanes belongs in an external store
Instance Spot Serializes the Spot queue's handlers and timers. There is no Actor queue The Spot instance owns it

↗ View larger

The execution mode is fixed when the factory is registered and is not changed while running.

3. SpotWide — Execution Through One Gate

SpotWide is the default. Every callback bound for that Spot — another Actor's message, a timer, a lifecycle callback — passes one common gate and runs one turn at a time in a single lane.

↗ View larger

No two turns run at the same instant, so a handler touches the state of the Spot and of its member Actors directly, in ordinary code, without locks. In relocation the Spot and its Actors move together as one unit. In exchange, one long callback delays every following callback of that Spot.

Being the default, it can be left out; writing it down makes the mode the Spot runs in visible at the registration.

// spot_wide is the default. Naming it here keeps the choice visible:
// every callback of this room runs through one gate.
.add_spot_factory<bingo_room_spot_t> (sample_names_t::room_spot)
.set_execution_mode (user_spot_execution_mode_t::spot_wide)

4. PerActor — One Lane Each

PerActor is chosen when throughput requires each Actor to run independently. The Spot itself is used as a shell that holds no state.

Different lanes run at the same time, so state that several Actors change together, and schedules kept per Spot, belong in an external store such as Redis or a database. The factory's relocation policy can only be RecreateOnRelocation.

An Entry Spot follows the same model. Its Spot queue and Actor queue are separate, so it carries the same constraints.

5. Serial Execution and Thread Occupancy

Serial execution does not mean holding one thread throughout. When a handler reaches a suspension point the executing thread handles other work, but the turn is held until the handler completes. Under SpotWide the next callback of the same Spot does not start in the meantime.

Async and Yield both submit an operation and wait for its application result. await on Async releases only the thread and keeps the current turn. Use Async only for short waits. Use Yield when the next turn in the same Spot has to run during a long I/O wait.

Yield is a terminator that ends the current turn and gives back the shared gate that lets one thing run at a time in the Spot when it starts waiting for a result. While it waits, queued turns in the same Spot run. When the reply arrives, its continuation gets that gate again and continues in a new turn at the tail of the queue.

↗ View larger

Another turn may have changed state across a Yield, so check again before changing state after resuming. Copy values handed to the operation inside the current turn before Yield. When a member Actor in SpotWide uses Yield, it gives back only the shared Spot gate. Its own Actor queue position remains reserved, so its next message cannot run first, while another Actor, Spot handler, or timer can run.

Yield is available where there is a shared turn: a SpotWide User Spot and an Instance Spot. An Entry Spot and a PerActor User Spot have no shared turn and therefore do not offer Yield. For calls such as requests and workers that offer it, the terminator name is:

Language Yield terminator
C#/.NET Yield(...)
C++ .yield()
Java .yield(...)
Kotlin .yield()
Node/TypeScript .yield()

↗ View larger

This is the player-record request in a Bingo room join. In every tab, the marked Yield terminator line is where the room Spot gives its turn back.

.yield<get_player_record_res_t>() gives the room Spot turn back.

get_player_record_res_t record;
try {
    get_player_record_req_t get_record;
    get_record.set_actor_id (actor.actor_id);
    record = co_await _context->outbound ()
               .request (sample_names_t::api_channel, std::move (get_record))
               .yield<get_player_record_res_t> ();
}
catch (...) {
    _pending_joins.erase (actor.actor_id);
    throw;
}
const auto resumed = _pending_joins.find (actor.actor_id);
if (resumed == _pending_joins.end () || resumed->second.room_id () != request.room_id ()
    || !_game.can_accept_player ()) {
    _pending_joins.erase (actor.actor_id);
    co_await _context->leave_actor (actor);
    co_return;
}