Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

stlab::actor<T> #525

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open

stlab::actor<T> #525

wants to merge 16 commits into from

Conversation

fosterbrereton
Copy link
Member

@fosterbrereton fosterbrereton commented Apr 27, 2023

actor<T> provides asynchronous, serialized access to an instance of T, running on an execution context of choice. Instead of a traditional message-passing actor model implementation, actor<T> is given work by way of lambdas, whose results are then optionally extracted by the caller via a stlab::future<R>.

actor<T> is a lightweight alternative to a dedicated thread managing some background service for a host application. The problem with background threads is that they consume considerable resources even when they are idle. Furthermore, many background services don't need the "always on" characteristics of a thread, and would be comfortable running only when necessary.

However, actor<T> is not a panacea. There are several caveats to keep in mind:

  1. thread_local variables may not retain state from task to task. Given the implementation details of the actor's executor (e.g., it may be scheduled on any number of threads in a thread pool), an actor may jump from thread to thread. Since thread_local variables have a per-thread affinity by definition, the variable values may change unexpectedly.
  2. The thread cache penalty paid when an actor changes threads may not be suitable for high-performance/low-latency requirements. There is a cost associated with an actor jumping from one thread to another, and as in the previous case, this may happen depending on the implementation of the executor. If this cache penalty is too expensive for your use case, a dedicated worker thread may be a better fit.
  3. The tasks given to an actor should not block. If the actor must wait for external input (mouse events, network/file IO, etc.) it should be fed in from outside the actor. Because the context of execution is not "owned" by the actor, it cannot presume to block the context waiting for something else to happen, or else it risks hanging (e.g., an unresponsive main thread) or deadlocking (e.g., waiting for a task that cannot complete until this task completes.)

(@dabrahams rightly observes that these issues are caveats about executors more than they are to actors.)

Example

Say we have a service, type_rasterizer, that we'd like to put on a background thread:

class image {
    //...
};
struct type_rasterizer {
    void set_text(std::string&& text);
    image rasterize();
    // ... 
};

In our application, then, we will create an actor that manages an instance of this engine. By giving it the default_executor, the actor will run on a thread of the OS-provided thread pool (e.g., GCD on macOS/iOS).

struct my_application {
    artemis::actor<type_rasterizer> _rasterizer(stlab::default_executor,
                                                "app text rasterizer");
    // ... 
};

Then as your application is running, you can send "messages" in the form of lambdas to this actor to perform serialized, asynchronous operations. Note the first parameter of the lambda is the type_rasterizer itself:

void my_application::do_rasterize(std::string&& text) {
    _rasterizer.send([_text = std::move(text)](type_rasterizer& rasterizer) mutable {
        // This lambda will execute on the `default_executor`. Note that while in this
        // lambda, the name of the thread will be the name of the actor. In this case,
        // "app text rasterizer".
        rasterizer.set_text(std::move(_text));
        return rasterizer.rasterize();
    }).then(stlab::main_executor, [](image my_rasterized_text){
        draw_image_to_screen(my_rasterized_text);
    }).detach();
}

You could also pass the argument to the lambda itself:

_rasterizer.send([](type_rasterizer& rasterizer, std::string text) {
    rasterizer.set_text(std::move(text));
    return rasterizer.rasterize();
}, std::move(text));

Note that the actor is not always running. That is, no threads are blocked on behalf of the actor while it waits for tasks to come in. Rather, the actor only schedules itself to run on its executor when it has work to do. Once the work is completed, the actor relinquishes the thread it is running on back to the executor. In this way, actors are considerably less resource-intensive than a dedicated worker thread to some background service.

@fosterbrereton fosterbrereton changed the title Actor stlab::actor<T> Apr 27, 2023
@fosterbrereton fosterbrereton changed the title stlab::actor<T> Actors via stlab::actor<T> Apr 27, 2023
@fosterbrereton fosterbrereton changed the title Actors via stlab::actor<T> stlab::actor<T> Apr 27, 2023
@dabrahams
Copy link
Contributor

Aren't all your caveats actually caveats about various executors and not about actors at all?

stlab/concurrency/actor.hpp Outdated Show resolved Hide resolved
stlab/concurrency/actor.hpp Show resolved Hide resolved
stlab/concurrency/actor.hpp Outdated Show resolved Hide resolved
stlab/concurrency/actor.hpp Outdated Show resolved Hide resolved
stlab/concurrency/actor.hpp Outdated Show resolved Hide resolved
test/actor_tests.cpp Outdated Show resolved Hide resolved
test/actor_tests.cpp Outdated Show resolved Hide resolved
test/actor_tests.cpp Outdated Show resolved Hide resolved
{
stlab::actor<int> a(stlab::default_executor, "send_then_from_void");
stlab::future<void> f0 = a.send(increment_by, 42);
stlab::future<int> f1 = a.then(stlab::future<void>(f0), [](auto& x) { return x; });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why x is ref?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These first parameters are the actor's task local state - in this case an int. The lambda has exclusive access to this task state and is allowed to modify it if it so chooses. I have an example where this is done. In this case there isn't much difference between an int and an int&, but the actor's task local state can be anything, and in such case the pattern to take a reference will save unnecessary copies.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have made the non-ref change here, but hopefully the above comment adds clarity as to why a reference here is both allowed and desired.

/**************************************************************************************************/

struct temp_thread_name {
explicit temp_thread_name(const char* name) { stlab::set_current_thread_name(name); }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not set the name of the thread. It is not worth the "trouble" from my point of view. The tasks should be short living anyway so setting the name does not make really sense. If it is just for some milliseconds who will observer the thread name?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very helpful for debugging, as often you will break in a random thread (managed by the thread pool) and you will want to know specifically which actor you are dealing with (especially when there are many actor<T> instances floating around for the same T). I suppose a workaround would be to have some kind of identifier in the T of every actor, but that seemed like a less useful pattern than letting the actor name the thread while it is using it.

return rasterizer.rasterize();
}).then(stlab::main_executor, [](image my_rasterized_text){
draw_image_to_screen(my_rasterized_text);
}).detach();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detach is worrisome. We should discuss more in slack (and apologies, this week I'm going to be busy getting ready for C++Now so won't be responsive), but most actor systems usually create a "main actor" so you can send a result to the main actor. An actor itself is also a type of "future" so you should be able to send() to an actor and not get a future back (I don't know if you want to do that automatically for a void result, or make it a separate call). And because of this you should be able to attach an actor to a future as a continuation without generating a new future. Something like: actor. after(future, [](auto& a, auto future-result) { /* operate on actor type */ });

I would probably block on actor destruction. I detached futures - but that adds a lot of complexity (like pre-exit - and requirement that every continuation can execute unstructured) - but there should be a way to await completion of all calls to an actor so you can have:

  actor<type> a;
  //...
  co_await a.complete();
} // actor destructs here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to chat with you more on Slack about this one. I'm thinking they would do well as separate APIs from the ones that do return futures (as I have seen cases where an actor's result will want to get shuttled to another continuation lambda, possibly to another actor, but not required.)

stlab/concurrency/actor.hpp Outdated Show resolved Hide resolved
_this->_instance._x = T(std::forward<Args>(args)...);
},
std::forward<Args>(args)...)
.detach();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concerned by any call to detach().

stlab/concurrency/actor.hpp Outdated Show resolved Hide resolved
stlab/concurrency/actor.hpp Show resolved Hide resolved
@fosterbrereton
Copy link
Member Author

Aren't all your caveats actually caveats about various executors and not about actors at all?

Yes, that's a great point. They have been brought up in the past talking about actors, though, so I thought they bore mentioning here if someone new to actors/executors is unaware of them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants