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.