Implementing Actors: Part 2
Introduction
Last time, we learned about internal vs. external views to actors, objects lifetimes and explicit state management.
In this part, we extend our discussion on actor handles by introducing messaging interfaces for static type checking. Afterwards, we revisit the stateful actors API to implement extensible state classes for statically typed actors.
Messaging Interfaces
Part 1 introduced the notion of inside and outside views to actors. A handle represents the outside view and it allows us to interact with actors only by sending messages to it as well as enables us to observe the lifetime of an actor.
All of our previous examples used dynamically typed actors. When implementing an
actor, we have stored our message handlers in a caf::behavior
. When spawning
one of our cell actors, CAF thus always returned a caf::actor
handle.
To enable CAF to statically type-check actor communication, we need to define a
messaging interface. In source code, we represent the interface simply as a
list of normalized function signatures. This simply states that we wrap the
result types in a caf::result
and that we write our function arguments without
any cv qualifiers. Here are a
couple of examples:
- Good:
caf::result<void>(int32_t)
caf::result<int32_t>(int32_t, int32_t)
caf::result<std::string, std::string>(my_class)
- Bad:
void(int32_t)
caf::result<std::string>(const my_class&)
Note that caf::result
allows any number of type parameters to naturally encode
multiple return types.
To declare a typed actor handle for a messaging interface, we simply list the
function signatures as template parameters to typed_actor
. CAF implements
set-theoretic semantics for messaging interfaces. This means two messaging
interfaces (and thus two handle types) are considered equal if they contain the
same signatures, regardless of ordering.
As an example, let us define a typed interface for the last cell actor we have implemented in the previous part. This type of actor supported get and put operations:
using cell_actor = caf::typed_actor<
caf::result<int32_t>(caf::get_atom),
caf::result<void>(caf::put_atom, int32_t)>;
using cell_actor_2 = caf::typed_actor<
caf::result<void>(caf::put_atom, int32_t),
caf::result<int32_t>(caf::get_atom)>;
static_assert(std::is_assignable_v<cell_actor, cell_actor_2>);
static_assert(std::is_assignable_v<cell_actor_2, cell_actor>);
The only difference between cell_actor_1
and cell_actor_2
is the order of
the message handlers. Hence, CAF considers these handle types equal and you can
assign one to the other.
If one handle type is a subset of another handle, then assignment only works in one direction: from the larger set to the smaller set. As an example, consider a simple math service. Workers have an interface that allows adding or subtracting two integers. The server interface has an additional handler that allows clients to request additional workers.
using compute_unit_actor = caf::typed_actor<
caf::result<int32_t>(caf::add_atom, int32_t, int32_t),
caf::result<int32_t>(caf::sub_atom, int32_t, int32_t)>;
using compute_service_actor = compute_unit_actor::extend<
caf::result<void>(caf::spawn_atom, int32_t)>;
// compute_unit_actor hdl = compute_service_actor{...}; // ok: subset relation
static_assert(std::is_assignable_v<compute_unit_actor, compute_service_actor>);
using compute_service_actor_2 = caf::typed_actor<
caf::result<int32_t>(caf::add_atom, int32_t, int32_t),
caf::result<int32_t>(caf::sub_atom, int32_t, int32_t),
caf::result<void>(caf::spawn_atom, int32_t)>;
static_assert(std::is_same_v<compute_service_actor, compute_service_actor_2>);
The example above also illustrates one important aspect of messaging interfaces:
there are no inheritance relations. We can extend actor interfaces with more
message handlers, but this really just copies all message handlers from the
other interface. Hence, the two definitions compute_service_actor
and
compute_service_actor_2
are identical. Of course we would not recommend
copy-and-paste programming, but it is important to keep in mind that
typed_actor<...>::extend<...>
really is just a convenient way to glue two
lists together into a single one.
Stateful Actors with Static Type Checking
Defining a type alias for caf::typed_actor
creates a new handle type that
allows CAF to type-check incoming messages. This is the outside view. However,
CAF also needs to type-check the inside of an actor in order to verify that
the actor does in fact implement the messaging interface.
For the type-checking inside of an actor, CAF checks that the type of the
behavior matches the messaging interface. Statically typed actors return a
typed_behavior<...>
from their make_behavior
member function in order to
allow CAF to perform this type checking. A behavior matches if there is a
handler for each signature in the typed_actor
.
Each handle class typed_actor<Sigs...>
also provides some type aliases to make
implementing statically typed actors easier:
behavior_type
is defined astyped_behavior<Sigs...>
impl
names the default class for implementing the interface, i.e.,typed_event_based_actor<Sigs...>
pointer_view
enables actors to store a self pointer without actually knowing the implementing class, this is atyped_actor_pointer<Sigs...>
stateful_impl<State>
finally is astateful_actor<State, impl>
(setting the second template parameter tostateful_actor
changes the base type fromcaf::event_based_actor
toimpl
)
Let us once more implement our cell actor, but using the typed interface this time:
struct cell_state {
explicit cell_state(int32_t init_value) : value(init_value) {
// nop
}
cell_actor::behavior_type make_behavior() {
return {
[this](caf::get_atom) {
return value;
},
[this](caf::put_atom, int32_t new_value) {
value = new_value;
},
};
}
int32_t value;
static inline const char* name = "cell";
};
using cell_impl = cell_actor::stateful_impl<cell_state>;
That is all! Compared to our previous implementation in Part 1, we have replaced
the return type for make_behavior
and declare our cell_impl
type as
cell_actor::stateful_impl<cell_state>
instead of
caf::stateful_actor<cell_state>
. Spawning and using our statically typed cell
is similar, but the actor system returns a cell_actor
handle instead of a
caf::actor
when spawning the cell now:
Source Code
void caf_main(caf::actor_system& sys) {
auto cell = sys.spawn<cell_impl>(11);
static_assert(std::is_same_v<decltype(cell), cell_actor>);
caf::scoped_actor self{sys};
self->send(cell, caf::put_atom_v, int32_t{42});
self->send(cell, caf::get_atom_v);
self->receive(
[](int32_t value) {
println("cell responded with: ", value);
});
// self->send(cell, "hello cell!"); -> would not compile!
}
Output
cell responded with: 42
With the statically typed cell, we now have the compile-time checking in place
that prevents sending strings to the cell. Thus, uncommenting the line sending
"hello cell!"
to the actor would now result in a compiler error where a
dynamically typed version would produce an error at runtime.
Composing State Classes with Inheritance
As we have seen earlier, extending actor interfaces only causes CAF to concatenate messaging interfaces. However, the interfaces may still capture relationships between two actor types that are relevant for an implementation.
Let us go back to the compute units that we have introduced earlier:
using compute_unit_actor = caf::typed_actor<
caf::result<int32_t>(caf::add_atom, int32_t, int32_t),
caf::result<int32_t>(caf::sub_atom, int32_t, int32_t)>;
using compute_service_actor = compute_unit_actor::extend<
caf::result<void>(caf::spawn_atom, int32_t)>;
When implementing both actor types, we need to make a decision on the compute service: what should it do if no worker is available? It could (1) raise an error, (2) spawn a worker, or (3) do the computation itself. We go with the latter version, because this design allows us to showcase inheritance-based composition of state classes. First, we implement the state class for the compute unit interface. Then, we are implementing the state class for the compute service as a derived type to fall back to the base implementation if no worker is available.
The compute unit is very straightforward, but we introduce virtual message handlers that we can leverage later to override the logic when implementing the service:
struct compute_unit_state {
virtual ~compute_unit_state () {
// nop
}
virtual caf::result<int32_t> add(int32_t x, int32_t y) {
return x + y;
}
virtual caf::result<int32_t> sub(int32_t x, int32_t y) {
return x - y;
}
template <class... Fs>
auto make_behavior(Fs... fs) {
return caf::make_typed_behavior(
std::move(fs)...,
[this](caf::sub_atom, int32_t x, int32_t y) {
return sub(x, y);
},
[this](caf::add_atom, int32_t x, int32_t y) {
return add(x, y);
}
);
}
static inline const char* name = "compute-unit";
};
using compute_unit_impl
= compute_unit_actor::stateful_impl<compute_unit_state>;
With this design, we can now override the implementation for add
and sub
in
a derived class without having to change or replace the behavior of an actor.
This allows us to only add to the behavior in the service actor. The
make_behavior
function allows us to pass in additional message handlers when
constructing the behavior for an actor. This enables us to extend the behavior
declaratively when deriving from the state.
The service actor itself is also quite straightforward: it keeps a list of available workers and either offloads work or does the computation in place, depending on whether or not it has workers available when processing a request:
struct compute_service_state : compute_unit_state {
using super = compute_unit_state;
template <class SelfPtr>
compute_service_state(SelfPtr self) : self(self) {
// nop
}
compute_service_actor::pointer_view self;
std::vector<compute_unit_actor> workers;
template <class... Ts>
auto offload(Ts&&... xs) {
assert(!workers.empty());
auto w = workers.back();
workers.pop_back();
auto rp = self->make_response_promise<int32_t>();
self->request(w, caf::infinite, std::forward<Ts>(xs)...).then(
[this, rp, w](int32_t res) mutable {
rp.deliver(res);
workers.emplace_back(std::move(w));
},
[rp](caf::error& err) mutable {
rp.deliver(std::move(err));
// drop worker
});
return rp;
}
caf::result<int32_t> add(int32_t x, int32_t y) override {
if (workers.empty()) {
println("calcuate add request in place");
return super::add(x, y);
} else {
println("offload add request");
return offload(caf::add_atom_v, x, y);
}
}
caf::result<int32_t> sub(int32_t x, int32_t y) override {
if (workers.empty()) {
println("calcuate sub request in place");
return super::sub(x, y);
} else {
println("offload sub request");
return offload(caf::sub_atom_v, x, y);
}
}
template <class... Fs>
auto make_behavior(Fs... fs) {
return super::make_behavior(
std::move(fs)...,
[this](caf::spawn_atom, int32_t num) {
for (int32_t i = 0; i < num; ++i)
workers.emplace_back(self->spawn<compute_unit_impl>());
});
}
static inline const char* name = "compute-service";
};
using compute_service_impl
= compute_service_actor::stateful_impl<compute_service_state>;
By using compute_service_actor::pointer_view
as the type of our self pointer,
we avoid tying the state to a particular implementation class. This enables us
to derive from the service state class again. Our make_behavior
is implemented
in the same style again, also allowing us to pass in additional handlers from a
subtype once more.
By following this design pattern, we can compose state classes without committing the state classes to a particular actor class.
Much like our cell actors before, we spawn the service by using the
compute_service_impl
alias and can send add, sub or spawn messages to
it. We can also assign a compute service handle to a compute unit handle (but
not the other way around):
Source Code
void caf_main(caf::actor_system& sys) {
auto service = sys.spawn<compute_service_impl>();
caf::scoped_actor self{sys};
self->send(service, caf::add_atom_v, int32_t{7}, int32_t{35});
self->receive(
[](int32_t value) {
println("7 + 35 = ", value);
});
self->send(service, caf::spawn_atom_v, 5);
auto hdl = compute_unit_actor{service};
assert(service == hdl);
self->send(hdl, caf::sub_atom_v, int32_t{42}, int32_t{7});
self->receive(
[](int32_t value) {
println("42 - 7 = ", value);
});
// auto hdl2 = compute_service_actor{hdl}; -> compiler error!
}
Output
calcuate add request in place
7 + 35 = 42
offload sub request
42 - 7 = 35
Conclusion
Implementing actors with explicit state classes brings many advantages. They enable CAF to release resources early, avoid subtle memory leaks for actors that mutually reference each other, and last but not least make it straightforward to use inheritance-based designs to compose actor implementations.
Adding static type checking does require additional boilerplate code. Once the
messaging interfaces are defined, however, the changes to an existing state
class are very straightforward: use a typed pointer view for the self pointer
(if needed) and change make_behavior
to return a typed_behavior<...>
that
matches the messaging interface.
We hope this guide on implementing actors connected the dots between handle
types and implementation types (such as event_based_actor
, stateful_actor
,
and typed_event_based_actor
) as well as providing useful design patterns to
implement extensible state classes.