Clojure head retention in doseq,运行!循环

Clojure head retention in doseq, run! loops

这里是 Clojure beginner/intermediate,

我有一个很大的 XML 文件(~ 240M),出于 ETL 目的,我需要逐项延迟处理。 有一些 run-processing 函数,它做了很多有副作用的事情,数据库交互,写入日志等。

当我将上述功能应用于文件时,一切 运行 都很顺利:

...
(with-open [source (-> "in.xml"
                       io/file
                       io/input-stream)]
   (-> source
       xml/parse
       ((fn [x]
          ;; runs fine
          (run-processing conn config x)))))

但是当我将相同的函数放入任何类型的循环(如 doseq)时,我得到 OutOfMemoryException(GC 开销)。

...
(with-open [source (-> "in.xml"
                       io/file
                       io/input-stream)]
  (-> source
      xml/parse
      ((fn [x]
         ;; throws OOM GC overhead exception
         (doseq [i [0]]
            (run-processing conn config x))))))

我不明白,导致GC开销异常的head retention发生在什么地方?我已经尝试 run! 甚至 loop recur 而不是 doseq — 同样的事情发生了。

一定是我的run-processing功能有问题吧?那为什么当我直接 运行 它时它表现正常? 有点糊涂,欢迎大家帮忙。

你能post一个具体的例子吗,即使它太小也不会产生OOM异常?

我首先看到的是您正在使用 (fn [x] ...) 创建一个函数,然后立即用第二对括号调用它:

   (-> source
       xml/parse
       ((fn [x]
          ;; runs fine
          (run-processing conn config x)))))

我觉得这很奇怪。为什么要这样构建代码?

在失败的 doseq 示例中,您具有相同的结构:

  (-> source
      xml/parse
      ((fn [x]
         ;; throws OOM GC overhead exception
         (doseq [i [0]]
            (run-processing conn config x))))))

你还会注意到doseq中的上限是一个one-element向量,里面有一个奇怪的符号。这意味着 "infinity" 还是什么?如果是这样,为什么它被包裹在一个向量中?这看起来像是一个问题(或者可能是 clojure.core 错误),因为 doseq 循环遍历 one-element 向量应该 运行 恰好一次。

还有一点,循环变量i从未被使用——这是故意的吗?它似乎与第一个(工作)示例非常不同。

此外,(取决于您的代码的详细信息)创建包含 doseq 的函数与立即调用它之间的某些交互可能是问题的原因。

更新:

关于(fn [x] ...)的形式,我会这样写:

(->  source
     xml/parse
     #(run-processing conn config %)))

(->> source     ; note "thread-last" macro
     xml/parse
     (run-processing conn config)))

也许对于 doseq`,您的意图更像是这样:

(-> source
    xml/parse
    #(doseq [single-item %]
      (run-processing conn config single-item)))

然而,在这种情况下,我们一次为单个项目调用 run-processing 多次,而在我们调用 run-processing 一次并传递来自 [=25 的整个惰性结果之前=].

虽然我不知道到底是什么导致了 OOM,但我想提供一些一般性建议并在评论中详细说明我们的讨论。

So the sequence will be kept in memory when I use any sort of loop, but will not if I call run-processing directly? But in doseq docstring it's clearly stated that "Does not retain the head of the sequence". Then what should I do when I need to call run-processing several times (e.g. with different arguments)?

所以这是我们的函数:

(defn process-file! [conn config name]
  (with-open [source (io/input-stream (io/file name))]
    (-> (xml/parse source)
        ((fn [x]
           (doseq [i [0]]
             (run-processing conn config x)))))))

其中 xlazy-seq(如果您使用的是 data.xml),例如:

x <- xml iterator <- file stream

如果 run-proccessing 一切正常,(完全消耗 x 和 returns nil)没有问题——问题是 x 绑定自己。当run-processing运行时,它完全意识到序列x的头部

(defn process-xml! [conn config x]
  (run-processing conn config x)
  ;; X IS FULLY REALIZED IN MEMORY
  (run-reporting conn config x))

(defn process-file! [conn config name]
  (with-open [source (io/input-stream (io/file name))]
    (->> (xml/parse source)
         (process-xml! conn config))))

如您所见,我们没有逐项使用文件并立即将其丢弃——这要归功于 xdoseq 与此无关:它 "does not retain the head of the sequence" 它消耗 ,在我们的例子中是 [0]


这种方法不是很惯用,原因有二:

1。 run-processing 做得太多了

它知道数据来自何处、以何种形式存在、如何处理数据以及如何处理数据。更典型的 proccess-file! 看起来像这样:

(defn process-file! [conn config name]
  (with-open [source (io/input-stream (io/file name))]
    (->> (xml/parse source)
         (find-item-nodes)
         (map node->item)
         (run! (partial process-item! conn config)))))

这并不总是可行的,也不适合所有用例,但还有一个这样做的理由。

2。 process-file! 应该(理想情况下)永远不要将 x 给任何人

从您的原始代码中可以立即看出这一点:它使用了 with-openclojure.java.jdbc 中的 query 就是一个很好的例子。它所做的是获取 ResultSet,将其映射到纯 Clojure 数据结构,并强制将其完全读取(result-set-fn of doall)以释放连接。

注意它如何从不泄漏 ResultSet,唯一的选择是获得结果 seq (result-set-fn),这是一个 "callback": query 想要控制 ResultSet 生命周期并确保它关闭一次 query returns。不然太容易犯类似错误了

(但如果我们将类似于 process-xml! 的函数传递给 result-set-fn,我们就可以做到。)


回复评论

正如我所说,我无法准确告诉您是什么导致了 OOM。可能是:

  1. run-processing 本身。无论如何,JVM 内存不足,添加一个简单的 doseq 会导致 OOM。这就是为什么我建议稍微增加堆大小作为测试。

  2. Clojure 优化了x绑定。

  3. (fn [x] (run-processing conn config x)) 仅由 JVM 内联,随后通过 x 绑定解决了问题。

So why does wrapping run-processing in doseq makes x retain head? In my examples I don't use x more than once (contrary to your "run-processing x THEN run-reporting on SAME x").

问题的根源不在于重用x,而是x存在的唯一事实。让我们做一个简单的 lazy-seq:

(let [x (range 1 1e6)])

(让我们忘记 range 是作为 Java class 实现的。)

是什么xx 是一个惰性序列头,它是构造下一个值的方法。

x = (recipe)

让我们推进它:

(let [x (range 1 1e6)
      y (drop 5 x)
      z (first y)])

现在是 xyy

x = (1) -> (2) -> (3) -> (4) -> (5) -> (6) -> (recipe)
y = (6) -> (recipe)
z = 6

希望你现在能明白我的意思"x is a seq head and run-processing realizes it"。

About "process-file! should (ideally) never give x to anyone" - correct me if I'm wrong, but doesn't mapping to pure Clojure data structures with doall makes them reside in memory, which would be bad if the file is too big (as in my case)?

process-file! 不使用 doallrun! 是一个减少和 returns 零。

要了解为什么您的 doseq 不起作用,我们首先必须了解为什么 (run-processing conn config x) 起作用:

这里 Clojure 的魔力是本地清除:它分析任何代码,一旦最后一次使用本地绑定,它就会在 [= 之前​​设置为 (Java) null 37=]那个表情。所以对于

(fn [x])
   (run-processing conn config x))

x 将在 运行 run-processing 之前被清除。注意:禁用本地清除(编译器选项)时,您可能会遇到相同的 OOM 错误。

现在当你写的时候会发生什么:

(doseq [_ [0])
   (run-processing conn config x))

编译器如何知道最后一次使用 x 并清除它?我不可能知道:它在循环中使用。所以它永远不会被清除,x 将保留头部。

注意:当智能 JVM 实现了解到调用函数无法再访问本地内存位置并向垃圾收集器提供绑定时,它可能会在将来更改此设置。虽然,当前的实现并不那么聪明。

当然很容易修复它:不要在循环中使用 x。使用其他构造,如 run!,这只是一个函数调用,将在调用 run! 之前正确清除本地。但是,如果您将 seq 的头部传递给一个函数,该函数将保留在头部直到函数(闭包)超出范围。