问题 为什么这会导致Haskell Conduit库中的内存泄漏?


我有一个 导管 管道处理一个长文件。我想每1000条记录为用户打印一份进度报告,所以我写了这样的:

-- | Every n records, perform the IO action.
-- Used for progress reports to the user.
progress :: (MonadIO m) => Int -> (Int -> i -> IO ()) -> Conduit i m i
progress n act = skipN n 1
   where
      skipN c t = do
         mv <- await
         case mv of
            Nothing -> return ()
            Just v ->
               if c <= 1
                  then do
                     liftIO $ act t v
                     yield v
                     skipN n (succ t)
                  else do
                     yield v
                     skipN (pred c) (succ t)

无论我怎么称呼它,它都会泄漏内存,即使我只是告诉它打印一个句号。

据我所知,该函数是尾递归的,并且两个计数器都经常被强制(我尝试将“seq c”和“seq t”放入,但无济于事)。任何线索?

如果我输入一个“awaitForever”,为每条记录打印一份报告,那么它可以正常工作。

更新1:仅在使用-O2编译时才会发生这种情况。分析表明泄漏的内存在递归的“skipN”函数中分配,并由“SYSTEM”保留(无论这意味着什么)。

更新2:我已经成功治愈了它,至少在我目前的计划中是这样。我用这个替换了上面的函数。请注意,“proc”的类型为“Int - > Int - > Maybe i - > m()”:要使用它,请调用“await”并将结果传递给它。出于某种原因,交换“await”和“yield”解决了这个问题。所以现在它在产生前一个结果之前等待下一个输入。

-- | Every n records, perform the monadic action. 
-- Used for progress reports to the user.
progress :: (MonadIO m) => Int -> (Int -> i -> IO ()) -> Conduit i m i
progress n act = await >>= proc 1 n
   where
      proc c t = seq c $ seq t $ maybe (return ()) $ \v ->
         if c <= 1
            then {-# SCC "progress.then" #-} do
               liftIO $ act t v
               v1 <- await
               yield v
               proc n (succ t) v1
            else {-# SCC "progress.else" #-} do
               v1 <- await
               yield v
               proc (pred c) (succ t) v1

因此,如果您在Conduit中有内存泄漏,请尝试交换yield并等待操作。


12328
2017-07-16 16:30


起源

这实际上不是尾递归,最后一次调用不是 skipN 而是为了 (>>) (yield v) (skipN x y)。在使用monad编写递归例程时,这是一个常见的陷阱。我不确定GHC是否会在不查看核心转储的情况下正确优化这一点,但我最初的猜测是你实际上并没有使用尾递归函数。 - bheklilr
@dfeuer同样的原因 sum (x:xs) = x + sum xs 不是尾递归,最后调用的函数不是 sum但是 (+) 因为它相当于 sum (x:xs) = (+) x xs。这就是为什么我们经常使用带有累加器参数的辅助函数编写递归函数,或者只是使用 fold如果情况足够简单,例如 sum = go 0 where { go a [] = a; go a (x:xs) a = go (x + a) xs } 要么 sum = foldl' (+) 0。自从做符号desugars使用 >> 和 >>=,这意味着堆栈中的最后一个调用是其中一个调用,而不是它的第二个参数。 - bheklilr
我认为你们都过于关注尾部递归部分。也不 pipes 也不 conduit 需要尾递归才能在恒定的空间中运行。尾部递归讨论只是一个红色的鲱鱼。 - Gabriel Gonzalez
如果您可以发布完整的可运行代码,即包括示例用法,将会非常有用 progress 这表明内存泄漏。 - Tom Ellis
@dfeuer:在我们看到这次尝试之前,我们无法确定他是否试图以正确的方式强迫它! - Tom Ellis


答案:


这不是一个anwser,但它是一些我为测试而破解的完整代码。我根本不知道管道,所以它可能不是最好的管道代码。我强迫所有看起来需要被迫的东西,但它仍然会泄漏。

{-# LANGUAGE BangPatterns #-}

import Data.Conduit
import Data.Conduit.List
import Control.Monad.IO.Class

-- | Every n records, perform the IO action.
--   Used for progress reports to the user.
progress :: (MonadIO m) => Int -> (Int -> i -> IO ()) -> Conduit i m i
progress n act = skipN n 1
   where
      skipN !c !t = do
         mv <- await
         case mv of
            Nothing -> return ()
            Just !v ->
               if (c :: Int) <= 1
                  then do
                     liftIO $ act t v
                     yield v
                     skipN n (succ t)
                  else do
                     yield v
                     skipN (pred c) (succ t)

main :: IO ()
main = unfold (\b -> b `seq` Just (b, b+1)) 1
       $= progress 100000 (\_ b -> print b)
       $$ fold (\_ _ -> ()) ()

另一方面,

main = unfold (\b -> b `seq` Just (b, b+1)) 1 $$ fold (\_ _ -> ()) ()

没有泄漏,所以有些东西 progress 确实似乎确实是问题所在。我看不出来。

编辑:泄漏只发生在ghci!如果我编译一个二进制文件并运行它没有泄漏(我应该先测试一下......)


7
2017-07-16 22:25



谢谢。我打算今天写这样的东西。 - Paul Johnson
这不会强制't',所以你可能仍在积累thunk。我今晚必须尝试玩它。 - Paul Johnson
@PaulJohnson,那个爆炸模式 skipN !c !t 肯定看起来像是力量 t。没有必要强迫 c (虽然速度可能是一个好主意)因为它是强迫的 if 经常就够了。 - dfeuer
请看下面的答案,我认为汤姆的解决方案不会泄漏内存,而是会发生一些事情 print。 - Michael Snoyman


我认为汤姆的答案是正确的,我把它作为一个单独的答案开始,因为它可能会引入一些新的讨论(因为它只是一个评论太长了)。在我的测试中,取代了 print b 在汤姆的例子中 return () 摆脱内存泄漏。这让我觉得问题实际上就是这个问题 print不是 conduit。为了测试这个理论,我在C中编写了一个简单的辅助函数(放在helper.c中):

#include <stdio.h>

void helper(int c)
{
    printf("%d\n", c);
}

然后我在Haskell代码中输入了这个函数:

foreign import ccall "helper" helper :: Int -> IO ()

我把电话换成了 print 打电话给 helper。程序的输出是相同的,但我没有泄漏,最大驻留时间为32kb vs 62kb(我还修改了代码以停止在10m记录以便更好地进行比较)。

当我完全切断导管时,我看到类似的行为,例如:

main :: IO ()
main = forM_ [1..10000000] $ \i ->
    when (i `mod` 100000 == 0) (helper i)

但是,我不相信这真的是一个错误 print 要么 Handle。我的测试从未表明泄漏达到了任何实质性的内存使用,所以它可能只是缓冲区正朝着极限增长。我需要做更多的研究才能更好地理解这一点,但我想首先看看这个分析是否与其他人看到的相符。


5
2017-07-17 16:23



以前我只在ghci中测试过我的代码,我没有费心去编译它。现在已经完成了后者,我注意到了 没有 在编译版本中泄漏(即使在 -O0)。那么也许ghci中有一个错误? (我在7.6)。 - Tom Ellis
顺便说一句,在ghci中,内存泄漏是 巨大。它迅速吞噬了我4GB内存的50%。 - Tom Ellis
@TomEllis,听起来你已准备好提交错误报告了。 - dfeuer
@PaulJohnson:您是否看到编译版本中的空间泄漏? - Tom Ellis
是的,泄漏是在编译版本中。 - Paul Johnson


我知道这是两年之后,但我怀疑发生了什么事情是完全懒惰正在提升身体的一部分等待到等待之前,这导致了空间泄漏。它看起来类似于m中“增加共享”部分中的情况关于这个主题的博客文章


1
2017-09-30 02:52