【问题标题】:Clojure head retention in doseq, run! loops剂量q中的Clojure头部保留,运行!循环
【发布时间】:2018-08-02 02:48:23
【问题描述】:

这里是 Clojure 初学者/中级,

我有一个很大的 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开销异常的头部保留发生在哪里?我已经尝试过run! 甚至loop recur 而不是doseq——同样的事情发生了。

我的run-processing 函数一定有问题吗?那为什么当我直接运行它时它表现得很好? 有点迷茫,希望有任何帮助。

【问题讨论】:

  • 您是否尝试过增加 JVM 堆大小?会不会是,doseq 本身分配的内存刚好够导致 OOM?
  • 另外,您不希望将运行处理置于循环中。我想你正在使用 data.xml,它是惰性的,但 x 是一个 seq 头。一旦 run-processing 运行,底层序列就完全实现了,并将在 doseq 的持续时间内保存在内存中。
  • @IgorKharin 所以当我使用任何类型的循环时序列将保存在内存中,但如果我直接调用运行处理则不会?但是在doseq docstring中明确指出“不保留序列的头部”。那么当我需要多次调用 run-processing (例如使用不同的参数)时该怎么办?
  • @nha,我已经尝试使用 dorun 获得相同的结果。在 clojuredocs 上我到底应该注意什么?

标签: clojure out-of-memory lazy-sequences


【解决方案1】:

您能否发布一个具体示例,即使它太小而无法生成 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 的上限是一个单元素向量,里面有一个奇怪的符号。这意味着“无限”还是什么?如果是这样,为什么它被包裹在一个向量中?这看起来像是一个问题(或者可能是 clojure.core 错误),因为在单元素向量上的 doseq 循环应该只运行一次。

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

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

更新:

Re(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 一次并从 xml/parse 传递整个惰性结果。

【讨论】:

  • 为什么你觉得奇怪?我认为这是使用线程宏时的标准做法,与(#(run-processing %)) 相同。 doseq 一元素向量仅用于说明问题点。
  • 难道你不应该将匿名函数放在额外的括号中吗?我认为你错了。
【解决方案2】:

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

所以当我使用任何类型的循环时,序列都会保存在内存中, 但如果我直接调用运行处理不会?但是在doseq 文档字符串中 它明确指出“不保留序列的头部”。 那么当我需要多次调用run-processing时该怎么办 (例如,使用不同的论点)?

这是我们的函数:

(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 并返回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 与此无关:它“不保留序列的头部”它消耗,在我们的例子中是 [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-openquery 来自 clojure.java.jdbc 就是一个很好的例子。它所做的是获取ResultSet,将其映射到纯Clojure 数据结构,并强制它被完全读取(使用doallresult-set-fn)以释放连接。

注意它永远不会泄漏ResultSet,唯一的选择是获得结果seq (result-set-fn),这是一个“回调”:query 想要控制ResultSet 生命周期和确保它在query 返回后关闭。不然太容易犯类似的错误了。

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


对cmets的回答

正如我所说,我无法准确告诉您导致 OOM 的原因。可能是:

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

  2. Clojure 优化了 x 绑定。

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

那么为什么在doseq 中包装运行处理会使x 保留头部?在 我的例子我不多次使用 x (与你的相反 “运行处理 x THEN 在相同的 x 上运行报告”)。

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

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

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

什么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 是一个 seq 头并且运行处理实现它”的意思。

关于“进程文件!应该(理想情况下)永远不要将 x 给任何人” - 如果我错了,请纠正我,但不会映射到纯 Clojure 数据 带有 doall 的结构使它们驻留在内存中,这很糟糕 如果文件太大(如我的情况)?

process-file! 不使用doallrun! 是一个 reduce 并返回 nil。

【讨论】:

  • 感谢您的详尽回答,但我仍然想念一些东西。 1)我在两种情况下都使用x绑定:直接调用运行处理(第一个代码片段)和doseq循环(第二个代码片段)。那么为什么在doseq 中包装运行处理会使x 保留头部呢?在我的示例中,我不使用 x 多次(与您的“运行处理 x THEN 在 SAME x 上运行报告”相反)。
  • 2) 在我的例子中,运行处理应该做很多事情——它是一个顶级函数,可以处理find-item-nodes、映射和其他东西,并由多方法和config 参数驱动。 3)关于“进程文件!应该(理想情况下)永远不要把 x 给任何人” - 如果我错了,请纠正我,但不会映射到带有 doall 的纯 Clojure 数据结构,使它们驻留在内存中,这将是如果文件太大(如我的情况)不好?
  • @Twice_Twice “抱头”碰巧发生在我们当中。终于找到the video
  • 我想我现在理解得更好了(感谢视频顺便说一句!),要点是:不要将惰性序列放入任何类型的绑定中,如 let 并且不要创建匿名 @987654382 @ 绑定 seq :)
  • @Twice_Twice 我想我真的不擅长解释这一点,对不起。 :( 同样,问题不在于doseq,而是事实上我们有一个 seq 头引用,并且它的底层序列得到了完全实现。问:(do (run-processing x) (run-processing x))(dotimes [i 2] (run-processing x)) 之间有什么区别吗?这正是我试图用process-xml! 来说明:没有区别,也不是因为循环。
【解决方案3】:

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

Clojure 的神奇之处在于清除本地变量:它分析任何代码,并且在最后一次使用本地绑定后,在运行该表达式之前将其设置为 (Java) null。所以对于

(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 的头部传递给函数,则该函数将保留头部,直到函数(闭包)超出范围。

【讨论】:

  • 所以,问题是:通过编写(fn [x] ...),我创建了一个无法清除的x 的本地绑定(在第一个示例中清除它的唯一原因是本地清除)?但是如果我想两次处理同一个文件(例如我有不同的处理步骤)怎么办?我也应该打开它两次吗?如果我打开的是流而不是文件怎么办?
  • 不行,本地的fn参数当然可以清除。就像在您的第一个示例中所做的那样。同样非常重要的是:Clojure 编译器在最后一次使用绑定时清除(将其设置为null。如果您在循环中使用绑定 (doseq),则 clojure 编译器无法执行此操作。编译器什么时候应该清除它?在最后一次迭代中清除它还不够聪明。顺便说一句,不知道你为什么接受另一个答案......
  • 我已经尝试过run!,结果相同。即使我使用(run! (partial run-processing conn config) [0 1]) 并且根本不使用任何绑定。
  • 对,同样的问题:如果您对 seq 进行两次迭代,则第二次迭代需要 seq 的头部。没有其他办法了。想一想:如果你对序列进行垃圾收集,第二次迭代应该如何工作?您需要在一次迭代中完成这项工作,而不是两次。然后您可以在迭代时立即进行垃圾收集。
  • 嗯,该陈述是对您最初问题的直接答案。您问为什么第一个代码有效(ans:locals clearing),但包裹在doseq 中的代码不起作用(locals clearing 失败)。如果你需要走 seq 两次,那根本就没有办法。您应该能够编写它,以便您只走一次。如果绝对必要,请阅读输入两次。所以我的回答没有回答你的第二个问题。我只是回答了你原来的问题。
猜你喜欢
  • 2013-04-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-11-17
  • 1970-01-01
  • 2022-01-24
相关资源
最近更新 更多