为什么这个Scalaz 7枚举器会泄漏内存?

4
以下定义会导致内存泄漏:
def enumIterator1[E, F[_]: Monad](x: => Iterator[E]) : EnumeratorT[E, F] =
  new EnumeratorT[E, F] {
    def apply[A] = (s: StepT[E, F, A]) => {
      def go(xs: Iterator[E]): IterateeT[E, F, A] =
        if(xs.isEmpty) s.pointI
        else {
          val next = xs.next
          s mapCont { k => 
            k(Iteratee.elInput(next)) >>== enumIterator1[E, F](xs).apply[A] 
          }
        }
      go(x)
    }
  }

泄漏可以通过以下测试观察到:
(Iteratee.fold[Array[Byte], IO, Long](0L)(_+_.length) 
  &= enumIterator1(
    Iterator.continually(
      Array.fill(1 << 16)(0.toByte)).take(1 << 16))
).run.unsafePerformIO

然而,一个微小的变化(即将xs.next调用移动)就可以防止内存泄漏:
def enumIterator1[E, F[_]: Monad](x: => Iterator[E]) : EnumeratorT[E, F] =
  new EnumeratorT[E, F] {
    def apply[A] = (s: StepT[E, F, A]) => {
      def go(xs: Iterator[E]): IterateeT[E, F, A] =
        if(xs.isEmpty) s.pointI
        else {
          // val next = xs.next (moved down)
          s mapCont { k => 
            val next = xs.next
            k(Iteratee.elInput(next)) >>== enumIterator1[E, F](xs).apply[A] 
          }
        }
      go(x)
    }
  }

为什么?

我有一个模糊的概念,认为这种行为与闭包的引用模式有关,但我无法提供具体原因。我正在追踪另一个内存泄漏,我怀疑(希望?)了解这个泄漏可能有助于确定那个问题的原因。

1个回答

3
问题在于传递给 mapCont 的匿名函数闭包了 next。这又被我们传递给 enumIterator 的惰性变量所闭合,它又被新的由 enumIterator1 形成的 Enumerator 所闭合,后者又被应用于匿名函数中,最终被传递给下一次迭代的 mapCont 中的匿名函数所闭合。
因此,通过一系列闭包,每个枚举器都会闭合其前身。无论是否捕获 next,这种情况可能都会发生,因此你会有一个轻微的内存泄漏。然而,你最终会在其中一个闭包中捕获 next,这意味着你的迭代器生成的每个值都会一直保存在内存中,直到整个过程完成(而这些值占用了大量内存)。
通过将 next 移动到传递给 mapCont 的匿名函数内部,next 不再被我们的闭包链所捕获,因此主要内存泄漏消失了(尽管你的闭包仍然相互闭合,这可能是一个问题)。
最好的解决方法可能是简化它。正如 Brian Kernighan 所说:

众所周知,调试比一开始编写程序要难两倍。因此,如果你在编写它时越聪明,那么你将如何调试它?

我不确定我完全理解代码,但我认为以下代码是等价的:
def enumIterator1[E, F[_]: Monad](x: => Iterator[E]) : EnumeratorT[E, F] =
  new EnumeratorT[E, F] {
    def apply[A] = {
      val xs = x
      def innerApply(s: StepT[E, F, A]): IterateeT[E, F, A] = {
        if(xs.isEmpty) s.pointI
        else {
          val next = xs.next
          s mapCont { cont => // renamed k to cont, as the function, rather than the variable, is k
            cont(Iteratee.elInput(next)) >>== innerApply
          }
        }
      }
      innerApply
    }
  }

你也许会从更加显式的方式中获益。例如,如果你定义一个命名的类,在顶层作用域中,并明确传递所需的任何内容,而不是使用隐式地闭合其范围内所需的任何内容的匿名EnumeratorT
我使用了-XX:+HeapDumpOnOutOfMemoryError、VisualVM和javap来查找问题的原因。它们应该是你需要的一切。
更新
认为我开始理解代码应该做什么了,并相应地更新了代码。我认为问题出在使用了enumIterator1[E, F](xs).apply[A]。代码创建了一个新的EnumeratorT只是为了获得其apply方法,但同时又创建了一个按名称变量并闭合了所有的东西。由于xs的值在递归之间不会改变,所以我们创建了一个闭合于valxsinnerApply方法,并重复使用innerApply
更新2
我很好奇,所以我研究了一下Scalaz源码,看看他们是如何解决这个问题的。这里是一些与Scalaz本身类似的代码。
def enumIterator[E, F[_]](x: => Iterator[E])(implicit MO: MonadPartialOrder[F, IO]) : EnumeratorT[E, F] =
  new EnumeratorT[E, F] {
    import MO._ // Remove this line, and you can copy and paste it into your code
    def apply[A] = {
      def go(xs: Iterator[E])(s: StepT[E, F, A]): IterateeT[E, F, A] =
        if(xs.isEmpty) s.pointI
        else {
          s mapCont { k => 
            val next = xs.next
            k(elInput(next)) >>== go(xs)
          }
        }
      go(x)
    }
  }

他们使用柯里化(currying)来捕获xs,而不是闭包(closure),但仍然是一种“内部应用”方法。


我正在尝试理解这一系列闭包,但是第二步让我很难理解。为什么mapCont的匿名函数参数会被enumIterator1的按名称参数所引用?(我猜你是指enumIterator1而不是enumIterator - Aaron Novstrup
我可能犯了一个错误,但是如果我没记错的话,这是因为它通过outer$找到了xs - James_pic
这有一定的道理,但如果是这样的话,那么它似乎必须是编译器的错误。闭包不应该持有它们从未使用过的引用。感谢您追踪此问题。我计划在接下来的几天内验证您的解释,然后授予勾和赏金。 - Aaron Novstrup
我怀疑我的代码在某些微妙的方面有所不同 - 我对iteratees的经验来自于Play,它有一些微妙的差异。你也应该检查我的闭包链的工作方式 - 我的方法基本上是通过查看堆转储中的引用,并尝试通过在javap中查看它们来弄清楚相应的类是做什么的。但无论如何,简化都应该使事情更清晰。 - James_pic
刚看到你的更新。关于第二个更新,EnumeratorT.scala 的提交历史可能会很有帮助。具体来说,这些 提交记录。;-) - Aaron Novstrup
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接