JS闭包上下文对象的寿命?

Lifespan of JS closure context objects?

背景

我正在尝试将 elixir 的 actor 模型 语言原语移植到 JS 中。我想出了一个解决方案(在 JS 中)来模拟 receive 长生不老药关键字,使用“接收器”函数和生成器。

这里有一个简化的实现和演示来向您展示这个想法。

API:

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

演示

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()

关注

以上模型有效。但是我有点担心这种方法的性能影响,我不清楚它创建的所有闭包上下文的内存影响。

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 是 returns 一个“接收者”的“初始化器”。为了保持内部状态,我将它(state 变量)保存在“接收者”函数的闭包上下文中。

当收到消息时,当前接收者可以修改内部状态,并将其传递给loop并递归创建一个新接收者 替换当前的。

显然,新的接收者也有一个新的闭包上下文来保持新的状态。这个过程在我看来可能创建一个深层的linked上下文对象链来防止GC?

我知道闭包 引用的上下文对象可能 在某些情况下被 link 编辑。如果它们是 linked,它们显然不会在最内层的闭包被释放之前被释放。据this articleV8优化在这方面很保守,图片看起来不好看。

问题

如果有人能回答这些问题,我将不胜感激:

  1. loop 示例是否创建了深度 linked 上下文对象?
  2. 在此示例中,上下文对象的生命周期是什么样的?
  3. 如果当前的例子没有,这个 receiver 创建 receiver 机制最终会在其他情况下创建深度 linked 上下文对象吗?
  4. 如果问题 3 为“是”,您能否举个例子来说明这种情况?

跟进 1

@TJCrowder 的后续问题。

Closures are lexical, so the nesting of them follows the nesting of the source code.

说得好,这是显而易见的事情,但我错过了

只是想证明我的理解是正确的,举一个不必要复杂的例子(请耐心等待)。

这两个在逻辑上是等价的:

// 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)
      }
    }
  }
}

但是内存影响完全不同。

  1. 进入loop_troublec0持有s0; returns r0 -> c0.
  2. 进入r0,创建c1,持有s1gibberish,returns r1 -> c1.
  3. 步入r1c2被创建,持有s2,returns r2 -> c2

我相信在上面的案例中,当r2(最里面的箭头函数)被用作“当前接收者”时,它实际上不仅仅是r2 -> c2,而是r2 -> c2 -> c1 -> c0 ,所有三个上下文对象都被保留(如果我在这里已经错了,请纠正我)。

问题:哪种情况是正确的?

  1. 所有三个上下文对象都被保留只是因为 gibberish 我故意放在那里的变量。
  2. 或者即使我删除 gibberish,它们也会保留。也就是说s1 = s0 + msg.value的依赖就足以linkc1 -> c0.

跟进 2

所以作为“容器”的环境记录总是被保留,因为容器中包含的“内容”可能因引擎而异,对吗?

一种非常幼稚的未优化方法可能会盲目地将所有局部变量以及 argumentsthis 包含到“内容”中,因为规范没有说明任何关于优化的内容。

一种更聪明的方法是查看 nest 函数并检查到底需要什么,然后决定将什么包含到内容中。这在 article I linked 中称为“促销”,但那条信息可以追溯到 2013 年,恐怕它可能已经过时了。

您是否有关于此主题的更多最新信息要分享?我对 V8 如何实现这种策略特别感兴趣,因为我目前的工作严重依赖于电子运行时。

注意:此答案假定您使用的是 strict mode。你的片段没有。我建议 always 使用严格模式,通过使用 ECMAScript 模块(自动处于严格模式)或将 "use strict"; 放在代码文件的顶部。 (如果你想使用松散模式,我将不得不更多地考虑 arguments.callee.caller 和其他类似的怪物,我没有在下面。)

  1. Does the loop example creates deeply linked context objects?

不深,不。对 loop 的内部调用不会 link 这些调用创建的上下文到调用它们的上下文。重要的是函数 loop 是在哪里创建的,而不是从哪里调用的。如果我这样做:

const r1 = loop(1);
const r2 = r1({type: "ADD", value: 2});

这会创建两个函数,每个函数都关闭创建它的上下文。该上下文是对 loop 的调用。该调用 context links 到声明 loop 的上下文 - 您代码段中的全局上下文。对 loop 的两次调用的上下文彼此不 link。

  1. What does the lifespan of context object look like in this example?

只要引用它的接收器函数被保留(至少在规范方面),它们中的每一个都会被保留。当接收函数不再有任何引用时,它和上下文都符合 GC 的条件。在我上面的示例中,r1 不保留 r2,并且 r2 不保留 r1.

  1. If current example does not, can this receiver creates receiver mechanism ends up creating deeply linked context objects under other situation?

很难排除所有,但我不这么认为。闭包是词法的,所以它们的嵌套遵循源代码的嵌套。

  1. If "yes" to question 3, can you please show an example to illustrate such situation?

N/A


注意:在上面我使用了“上下文”,就像你在问题中所做的一样,但可能值得注意的是,保留的是 environment record,它是创建的执行上下文的一部分通过调用函数。闭包不保留执行上下文,而是环境记录。但区别很小,我提到它只是因为如果你深入研究规范,你会看到这种区别。


关于您的跟进 1:

c3 is not created, since there's no closure

c3 被创建,只是它在调用结束后没有保留,因为它没有关闭。

Question: which case is true?

都没有。所有三个上下文(c0c1c2)都会保留(至少在规范方面),无论是否存在 gibberish 变量或 s0参数或 s1 变量等。上下文不必为了存在而具有参数或变量或任何其他绑定。考虑:

// ge = global environment record

function f1() {
    // Environment record for each call to f1: e1(n) -> ge
    return function f2() {
        // Environment record for each call to f2: e2(n) -> e1(n) -> ge
        return function f3() {
            // Environment record for each call to f3: e3(n) -> e2(n) -> e1(n) -> ge
        };
    };
}

const f = f1()();

即使 e1(n)e2(n)e3(n) 没有参数或变量,它们仍然存在(并且在上面它们至少有两个绑定,一个用于arguments 和一个 this,因为它们不是箭头函数)。在上面的代码中,只要 f 继续引用由 f1()() 创建的 f3 函数,e1(n)e2(n) 都会被保留。

至少,规范是这样定义的。理论上,这些环境记录可以被优化掉,但这是 JavaScript 引擎实现的细节。 V8 在一个阶段做了一些闭包优化,但放弃了大部分,因为(据我所知)它在执行时间上的成本比它在内存减少方面所弥补的要多。 但即使他们在优化,我认为这是他们优化的环境记录的内容(删除未使用的绑定,诸如此类),而不是他们是否继续存在。 见下文,我发现了一篇 2018 年的博客 post,表明他们 确实 有时将它们完全排除在外。


再跟进 2:

So environment record as a "container" is always retained...

在规范方面,是的;这不一定是引擎的字面意思。

...as of what "content" is included in the container might vary across engines, right?

是的,所有规范都规定了行为,而不是你如何实现它。从上文 environment records link 部分:

Environment Records are purely specification mechanisms and need not correspond to any specific artefact of an ECMAScript implementation.

...but that piece of info dates back to 2013 and I'm afraid it might be outdated.

我认为是的,是的,尤其是因为 V8 从那时起 changed engines entirely,用 Ignition 和 TurboFan 取代了 Full-codegen 和 Crankshaft。

By any chance, do you have more up-to-date information on this topic to share?

不是真的,但我确实发现 this V8 blog post from 2018 说他们在某些情况下会“省略”上下文分配。所以肯定会进行一些优化。