Help me find flaws in this pattern: spawning task from constructor

I hope the MWE is self-explanatory, but I’ll say a few words after the code block anyways:

struct Agent
    id::Int
    in::Channel
    out::Channel
    Agent(id, in, out) = new(id, in, out)
    function Agent(id, in, out, f::Function)
        x = new(id, in, out)
        Threads.@spawn f(x)
        return x
    end
end


# this is a test/MWE function, but imagine it's a complex agent with a complex state
function agent(x::Agent, f::Function)
    @info "Agent $(x.id) started"
    # lots of thread-safe / data-race free mutable state here
    state = 0
    for msg in x.in
        @info "Agent $(x.id) received $msg at state $state"
        put!(x.out, f(msg))
        state += 1
    end
end


behavior(x) = agent(x, a -> a + 1)
const AG = Agent(1, Channel{Int}(1), Channel{Int}(1), behavior)

put!(AG.in, 6)
sleep(1)
@info "out: $(take!(AG.out))"

readline()

The question mainly targets the tight coupling of the constructor (I can do the same with an outer constructor) with spawning a consumer/producer task.

The documentation is not very helpful in evaluating the sanity of such patterns.

I find the above quite nice on the ergonomics side of things - and I do not get any weird results to prohibit me from using it.

In a way, the object constructor also defines the object behavior. Now I can pass my object to other functions and create closures that can be used in the constructors of additional Agent instances.

In practice, by only changing the function passed to the constructor, I am basically chaining these functions in a thread-safe manner (since all the “state” mutation takes place inside a channel).

Please note that my question is not referring to the potential danger of having those tasks floating around without any control/supervision (I could easily add a field isalive::Ref{Bool} to the Agent and then have a kill switch at hand that is readable from agent scope and also from the outside world). But let’s not go there for now.

This is likely 100% on me but it really isn’t self-explanatory to me. Could you please elaborate a bit on what you’re aiming to do and how this code helps you with that? Maybe afterwards I can infer from that what your question is but tbh I would also prefer if you could write it out a bit more explicitly.

Everything that the MWE accomplishes can also be done by spawning the task after the instance is created. So it is not like here is a little something that I want to achieve and is not possible to achieve in any other way.

As I said in the OP: I consider my proposal as an ergonomic way to couple object creation with object behavior. Also, I think it reduces the complexity of the actor-model approach (especially given the fact that you could define even more complex behavior - like having the actor subscribe to other actors at the creation time). Even more, it could facilitate a simplified syntax for achieving complex actor hierarchies (see Akka).

So there is no such thing as one thing that I want to achieve. Conceptually speaking I am building complex actors interactions within complex actor hierarchies (and there is no package in Julia satisfying my needs).

So my question would be reduced to mere spawning tasks from inside a constructor thing (because I am already convinced that the coupling of object creation with behavior definition is a good thing - especially given the fact that I can create multiple constructors and have creation, behavior + interaction + hierarchy determined by a simple MyActor(...) instantiation call).

P. S. Please note that in my scenario, the struct is simply a container to link together multiple channels (and I can also add an ID and even an actor type if I define it as a parametric composite). So basically, all is reduced more or less to the behavior. But instead of having multiple agent constructors + multiple methods for defining the behavior, I find that only using constructors for both greatly reduces the complexity (and number of LOC). So all this depends on whether it is acceptable to spawn tasks from within constructors.

Seems like a reasonable approach for defining a actor like object. I would either couple it even more strongly, i.e., hide the behavior from the agent function as well such that the constructor could be called as Agent(1, Channel{Int}(1), Channel{Int}(1), a -> a + 1) or instead just expose/export an API consisting of functions only. Thus, the user could create_agent(id, in_chan, out_chan, fun), send(agent, inp) etc. and does not know about your internal data type. In that case, whether the agent behavior is stored in the struct or simply in a closure when spawning the agent task would be purely an implementation detail – which you can decide based on testability or such.

1 Like