tin  1.5.9
actor_tutorial

Purpose

This tutorial shows how to compose several local runtime components with tin actors.

The important distinction is:

  • a channel stores values;
  • an HSM runtime owns behavior and usually has an event queue;
  • an actor is a component that exposes work through step(), drain(), empty(), and pending_events();
  • a port exposes one typed input or output side of an actor;
  • a link moves one typed value from an output port to an input port;
  • an actor group gives several actors a deterministic stepping order;
  • the application loop, executor, test, RTOS task, or platform adapter is the execution context that calls the actors.

Actors do not create threads. They do not register callbacks. They do not name topics, devices, services, or middleware endpoints. They make local typed handoffs explicit so the execution loop can be reviewed.

Scenario

The example is a small measurement cell:

  1. A sensor actor receives Sample values.
  2. A controller actor wraps a queued HSM runtime.
  3. The HSM accumulates samples and publishes Report values.
  4. A reporter actor consumes reports.
  5. The caller owns the loop that advances links and actors.

The same shape works for embedded firmware, a host replay tool, a simulator, a trading strategy harness, or a test fixture. The domain names change; the local runtime mechanics stay the same.

Step 1: Define Local Event Types

Events and payloads are ordinary C++ types. They are the only values moved between actors in this tutorial.

struct Sample {
int value{};
};
struct Report {
int total{};
};

Step 2: Build A Source Actor

The sensor actor owns a bounded channel. The actor is not the channel; it is the component that decides how samples enter the local system and exposes an output side that links can read from.

#include "tin/runtime.h"
struct SensorActor {
using event_type = Sample;
bool publish(Sample sample) {
return samples.try_send(sample);
}
bool try_receive(Sample& sample) {
return samples.try_receive(sample);
}
bool step() {
return false;
}
std::size_t drain() {
return 0;
}
bool empty() const {
return samples.empty();
}
std::size_t pending_events() const {
return samples.size();
}
};
Definition: sync.h:507
Tin runtime-kernel facade.

publish() is the example ingress point. In a product this could be called by a driver poller, replay reader, middleware adapter, simulator, or test. The actor reports pending work by looking at its channel.

Step 3: Author The Behavior

The controller behavior is a normal tsm machine definition. The definition states what handling a Sample means. Here the machine stays in Ready and updates context data through an action.

#include "tin/runtime.h"
#include "tin/tsm.h"
struct Controller {
struct Ready {};
void accumulate(Sample const& sample) {
total += sample.value;
(void)reports.try_send(Report{ .total = total });
}
int total{};
using transitions =
};
consteval auto transitions(TransitionEntries...)
Definition: tsm.h:2448
transition_table< TransitionEntries... > Ts
Definition: tsm.h:2445
Tin state-machine layer compatibility header.

The HSM context is the Controller object in this compact form. That is why the action can update total and publish into reports. Larger systems can split the behavior definition from the mutable context with using context_type = ...; see tsm.

Step 4: Wrap The HSM Runtime As An Actor

A queued HSM runtime already has send_event, step, drain, empty, and pending_events. The wrapper below adds an output side for reports and a resource snapshot that includes both the HSM event queue and the report channel.

using ControllerRuntime = tsm::Runtime<
Controller,
struct ControllerActor {
using event_type = Report;
bool send_event(Sample sample) {
return runtime.send_event(sample);
}
bool try_receive(Report& report) {
return runtime.context().reports.try_receive(report);
}
bool step() {
return runtime.step();
}
std::size_t drain() {
return runtime.drain();
}
bool empty() const {
return runtime.empty() && runtime.context().reports.empty();
}
std::size_t pending_events() const {
return runtime.pending_events() + runtime.context().reports.size();
}
auto snapshot =
tin::runtime_resources<ControllerRuntime>::snapshot();
snapshot.queue_slots += decltype(Controller::reports)::capacity();
return snapshot;
}
ControllerRuntime runtime{};
};
auto send_event(Runtime &runtime, Event &&event)
Definition: coroutine.h:972
runtime::Runtime< Definition, Policy, MachinePolicy > Runtime
Definition: runtime.h:35
runtime::resource_snapshot resource_snapshot
Definition: runtime.h:131
Definition: policy.h:29
Definition: policy.h:52
Definition: policy.h:89

send_event(Sample) is the actor's input side. try_receive(Report&) is its output side. step() dispatches at most one queued HSM event. drain() runs the queued HSM until the runtime has no immediate work.

The wrapper also decides what "empty" means for the component. The controller is idle only when the HSM queue is empty and no outbound reports remain.

Step 5: Build A Sink Actor

The reporter consumes reports. It has no internal queue in this small example, so its stepping functions report no pending work.

struct ReporterActor {
bool send_event(Report report) {
last_total = report.total;
++reports;
return true;
}
bool step() {
return false;
}
std::size_t drain() {
return 0;
}
bool empty() const {
return true;
}
std::size_t pending_events() const {
return 0;
}
int last_total{};
int reports{};
};

A real sink might write to another bounded channel, a telemetry encoder, a test recorder, a fieldbus adapter, or a host socket adapter. The actor contract does not require those choices to be visible to the source.

Step 6: Wire Typed Ports And Links

Ports are non-owning views. They do not allocate storage and they do not own the actors. A link connects exactly one output event type to exactly one input event type.

SensorActor sensor;
ControllerActor controller;
ReporterActor reporter;
auto samples = tin::make_output_port<Sample>(sensor);
auto controller_input = tin::make_input_port<Sample>(controller);
auto reports = tin::make_output_port<Report>(controller);
auto reporter_input = tin::make_input_port<Report>(reporter);
auto sensor_to_controller =
tin::actor_link{ samples, controller_input };
auto controller_to_reporter =
tin::actor_link{ reports, reporter_input };
actor_link(Source &, Sink &) -> actor_link< Source, Sink >

actor_link::step() moves at most one value. If the source produces a value but the sink rejects it because the sink's bounded queue is full, the link keeps one pending value and retries it later. The sink's overflow policy still controls admission; the link prevents accidental loss between source and sink.

Step 7: Choose The Execution Context

The execution context is the code that calls the actors. In a unit test it can be the test body. In bare-metal firmware it can be the main superloop. On an RTOS it can be a task. On Linux it can be a realtime thread or event-loop adapter. The actor API is the same.

tin::actor_group actors{ sensor, controller, reporter };
(void)sensor.publish(Sample{ .value = 2 });
(void)sensor.publish(Sample{ .value = 4 });
while (!actors.empty()) {
(void)sensor_to_controller.step();
(void)actors.step();
(void)controller_to_reporter.step();
}
actor_group(Actors &...) -> actor_group< Actors... >

This loop is intentionally explicit:

  1. Move at most one sample toward the controller.
  2. Give each actor at most one unit of work in declaration order.
  3. Move at most one report toward the reporter.
  4. Repeat until all actors report idle.

For tests, drain() is often more convenient:

while (!actors.empty()) {
(void)sensor_to_controller.step();
(void)actors.drain();
(void)controller_to_reporter.step();
}

For firmware, the same shape usually sits inside a platform loop:

for (;;) {
poll_platform_inputs();
(void)sensor_to_controller.step();
(void)actors.drain();
(void)controller_to_reporter.step();
wait_for_next_tick_or_interrupt();
}

The wait function belongs to the application or thal platform adapter. The runtime actor layer only defines the deterministic local work surface.

Step 8: Account For Resources

Actor groups can aggregate resource snapshots at compile time.

using CellResources =
tin::actor_group_resources<SensorActor,
ControllerActor,
ReporterActor>;
constexpr auto resources = CellResources::snapshot();
static_assert(!resources.uses_heap);
static_assert(resources.queue_slots == 8);

The 8 queue slots come from the controller's HSM queue and report channel in this tutorial. Actors without a resource_snapshot() contribute zero. That keeps small direct actors easy to write while allowing production actors to publish their storage budgets.

Complete Example

The complete buildable version of this tutorial is in examples/actor_cell/main.cpp.

Build and run it from a configured build tree:

cmake --build build/quality --target actor_cell
./build/quality/bin/actor_cell

Expected output:

actor_cell.total=6 reports=2 queue_slots=8

When To Use This Pattern

Use actor composition when a local system has multiple components that should advance deterministically under an execution loop you control:

  • a driver or replay source feeding samples into behavior;
  • one or more HSM runtimes owning reviewed state behavior;
  • a simulation or model adapter producing local typed events;
  • a reporting, telemetry, command, or persistence sink;
  • a platform loop that must make scheduling order visible.

For a single HSM with one event queue, a queued runtime by itself may be enough. Actors become useful when the application has several independently stepped components and needs explicit local wiring between them.

Related Pages