庄周梦蝶

生活、程序、未来

声明:本博客所有文章,未经允许,禁止转载。谢谢。

当LazySeq遇上closure

| Comments

LazySeq在Clojure里很关键,它解决了持久数据结构在多次操作中的性能问题,避免了多趟(pass)扫描数据结构,将性能的开销“分担”到一个一个的“实现”的步骤里去(当然,这一步可能比较大,一个chunk是32个元素)。

正因为有LazySeq,

(->> (get-titles)
     (map compute-checksum)
     (filter verify-ok?)
     count)

也只是需要扫描一趟。

但是,只要LazySeq的头元素(head)仍然被持有(专业点属于可能叫reachable),它就没办法被回收,并且是所有已经reliazed的元素都将持续保存在内存里面。 如果是个大的数据结构,那将占用相当多的内存,影响性能。

当LazySeq遇上Closure的时候,悲剧可能就发生了。看一个例子:

user=> (defn f [g] (g))
#'user/f 
user=> (defn t1 [c] (f (fn [] (dorun (map identity c)))))
#'user/t1
user=> (t1 (range 1e8))
OutOfMemoryError Java heap space  clojure.lang.ChunkBuffer.<init> (ChunkBuffer.java:20)

f函数只是执行传入的函数,t1方法是构造一个匿名函数并传给f执行(为什么这么做?这只是例子……),匿名函数中调用dorun强制map返回的LazySeq“实现”下,但是dorun不会持有LazySeq的head,因此理论上不应该会有内存溢出,比如我们直接这样跑是不会有内存溢出的:

user=> (dorun (map identity (range 1e8)))
nil

但是一放到匿名函数里,并让f来执行就出现内存溢出了。为什么呢?

———————– 我是分割线 ——————————-

问题在于匿名函数使用了非参数(没有在匿名函数的参数列表[]里出现)的外部参数c,这形成了一个closure,更专业点,我们叫closed-over closure。在SICP里,只有引用了“自由变量”的函数才可以称为closure,不过通常我们习惯性地将所有匿名函数也称为closure。这里的“自由变量”就是t1的参数c,它被匿名函数closed over了。我们接地气一些,就叫“保存”了这个变量c,并且这个匿名函数可以在多次调用中使用这个c。

因此原因很简单,LazySeq被dorun“实现”了之后,虽然dorun不持有head,但是匿名函数持有这个LazySeq“实现”后的集合,并且匿名函数无法在f调用完成之前被释放,导致内存被占满并溢出。

———————– 我是分割线 ——————————-

解决办法,古怪的once和fn*出场:

user=> (defn t2 [c] (f (^:once fn* [] (dorun (map identity c)))))
#'user/t2

这里用fn*而不是fn只是因为fn不可以传入metadata,而^:once就是一个metadata,告诉clojure的编译器说,这个匿名函数的Closure只会被调用一次,不要让Closure继续保存“自由变量”(或者clojure里叫LocalBinding)。让我们测试下(请确保在OOM之后重启过REPL,前面的REPL已经填满了垃圾元素):

user=> (t2 (range 1e8))
nil   

Cool! It works.问题顺利解决。虽然这里once的用法很怪异。不过这个用法在clojure.core里很普遍,比如future宏:

(defmacro future
  [& body] `(future-call (^{:once true} fn* [] ~@body)))    

这个问题的更多讨论请参考这篇博客和这个论坛帖子。上面的例子就来自那里。

———————– 我是分割线 ——————————-

没有结束,我们再稍微深入一些,看看Clojure是怎么特殊处理once元信息的。首先,请遵循我过去写的《Clojure Hacking Guide》来打印下t1和t2的字节码,我将打印出来的字节码存入文件并opendiff了一下,基本上两者生成的字节码是一致的,除了一个地方:

Snip20140119_1

左边是t1,右边是t2。

t2的匿名函数做了一个很关键的事情,就是通过PUTFIELD将自己内部的c的值设置为null(就是ACONST_NULL指令压入栈的null值),就这样“抛弃”了本该持有的c集合。

具体到Clojure编译器在Github的源码),关键代码是这样:

if(onceOnly && clear && lb.canBeCleared)
{
gen.loadThis();
gen.visitInsn(Opcodes.ACONST_NULL);
gen.putField(objtype, lb.name, OBJECT_TYPE);
} 

真相大白,水落石出,淫者见淫,智者见智……

声明:本博客所有文章,未经允许,禁止转载。谢谢。

Clojure

« Hello,Docker Clojure的条件编译 »