Background
I'm trying to port the elixir's actor model language primitives into JS. I came up with a solution (in JS) to emulate the receive elixir keyword, using a "receiver" function and a generator.
Here's a simplified implementation and demo to show you the idea.
APIs:
type ActorRef: { send(msg: any): void }
type Receiver = (msg: any) => Receiver
/**
* `spawn` takes a `initializer` and returns an `actorRef`.
* `initializer` is a factory function that should return a `receiver` function.
* `receiver` is called to handle `msg` sent through `actorRef.send(msg)`
*/
function spawn(initializer: () => Receiver): ActorRef
Demo:
function* coroutine(ref) {
let result
while (true) {
const msg = yield result
result = ref.receive(msg)
}
}
function spawn(initializer) {
const ref = {}
const receiver = initializer()
ref.receive = receiver
const gen = coroutine(ref)
gen.next()
function send(msg) {
const ret = gen.next(msg)
const nextReceiver = ret.value
ref.receive = nextReceiver
}
return { send }
}
function loop(state) {
console.log('current state', state)
return function receiver(msg) {
if (msg.type === 'ADD') {
return loop(state + msg.value)
} else {
console.log('unhandled msg', msg)
return loop(state)
}
}
}
function main() {
const actor = spawn(() => loop(42))
actor.send({ type: 'ADD', value: 1 })
actor.send({ type: 'BLAH', value: 1 })
actor.send({ type: 'ADD', value: 1 })
return actor
}
window.actor = main()
Concern
Above model works. However I'm a bit concern about the performance impact of this approach, I'm not clear about the memory impact of all the closure contexts it creates.
function loop(state) {
console.log('current state', state) // <--- `state` in a closure context <─┐ <─────┐
return function receiver(msg) { // ---> `receiver` closure reference ──┘ │
if (msg.type === 'ADD') { │
return loop(state + msg.value) // ---> create another context that link to this one???
} else {
console.log('unhandled msg', msg)
return loop(state)
}
}
}
loop is the "initializer" that returns a "receiver". In order to maintain a internal state, I keep it (state variable) inside the closure context of the "receiver" function.
When receive a message, the current receiver can modifies the internal state, and pass it to loop and recursively create a new receiver to replace current one.
Apparently the new receiver also has a new closure context that keeps the new state. This process seems to me may create a deep chain of linked context objects that prevents GC?
I know that context objects referenced by closure could be linked under some circumstance. And if they're linked, they are obviously not released before the inner-most closure is released. According to this article V8 optimization is very conservative on this regard, the picture doesn't look pretty.
Questions
I'd be very grateful if someone can answer these questions:
- Does the
loopexample creates deeply linked context objects? - What does the lifespan of context object look like in this example?
- If current example does not, can this
receivercreatesreceivermechanism ends up creating deeply linked context objects under other situation? - If "yes" to question 3, can you please show an example to illustrate such situation?
Follow-Up 1
A follow-up question to @TJCrowder.
Closures are lexical, so the nesting of them follows the nesting of the source code.
Well said, that's something obvious but I missed
Just wanna confirm my understanding is correct, with an unnecessarily complicated example (pls bear with me).
These two are logically equivalent:
// global context here
function loop_simple(state) {
return msg => {
return loop_simple(state + msg.value)
}
}
// Notations:
// `c` for context, `s` for state, `r` for receiver.
function loop_trouble(s0) { // c0 : { s0 }
// return r0
return msg => { // c1 : { s1, gibberish } -> c0
const s1 = s0 + msg.value
const gibberish = "foobar"
// return r1
return msg => { // c2 : { s2 } -> c1 -> c0
const s2 = s1 + msg.value
// return r2
return msg => {
console.log(gibberish)
// c3 is not created, since there's no closure
const s3 = s2 + msg.value
return loop_trouble(s3)
}
}
}
}
However the memory impact is totally different.
- step into
loop_trouble,c0is created holdings0; returnsr0 -> c0. - step into
r0,c1is created, holdings1andgibberish, returnsr1 -> c1. - step into
r1,c2is created, holdings2, returnsr2 -> c2
I believe in the above case, when r2 (the inner most arrow function) is used as the "current receiver", it's actually not just r2 -> c2, but r2 -> c2 -> c1 -> c0, all three context objects are kept (Correct me if I'm already wrong here).
Question: which case is true?
- All three context objects are kept simply because of the
gibberishvariable that I deliberately put in there. - Or they're kept even if I remove
gibberish. In other word, the dependency ofs1 = s0 + msg.valueis enough to linkc1 -> c0.
Follow-Up 2
So environment record as a "container" is always retained, as of what "content" is included in the container might vary across engines, right?
A very naive unoptimized approach could be blindly include into the "content" all local variables, plus arguments and this, since the spec didn't say anything about optimization.
A smarter approach could be peek into the nest function and check what exactly is needed, then decide what to include into content. This is referred as "promotion" in the article I linked, but that piece of info dates back to 2013 and I'm afraid it might be outdated.
By any chance, do you have more up-to-date information on this topic to share? I'm particularly interested in how V8 implements such strategy, cus my current work heavily relies on electron runtime.