Implementing Actors: Part 2

logo.png
 

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 as typed_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 a typed_actor_pointer<Sigs...>
  • stateful_impl<State> finally is a stateful_actor<State, impl> (setting the second template parameter to stateful_actor changes the base type from caf::event_based_actor to impl)

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.

Previous
Previous

Distributed Actors

Next
Next

Implementing Actors: Part 1