使用 StateT s IO a 的内存泄漏在哪里?

Where is the memory leak in using StateT s IO a?

意图:学习小应用Haskell:下载一篇维基百科文章,然后下载从它链接的所有文章,然后下载从它们链接的所有文章,以及依此类推...直到达到指定的递归深度。结果保存到文件中。

方法:使用StateT跟踪下载队列、下载文章和更新队列。我递归地构建一个列表 IO [WArticle] 然后打印它。

问题: 在分析时我发现使用的总内存 与下载的文章数量成正比。

分析: 根据文献,我认为这是一个懒惰 and/or 严格问题。 BangPatterns 减少了消耗的内存但没有解决比例问题。此外,我知道所有文章都在文件输出开始之前下载。

可能的解决方案:

1)函数getNextNode :: StateT CrawlState IO WArticle(下)已经有IO。一种解决方案是只在其中写入文件并且只 return 状态。不过,这意味着文件是以非常小的块写入的。感觉不是很Haskell..

2) 具有函数buildHelper :: CrawlState -> IO [WArticle](下)return[IO WArticle]。尽管我不知道如何重写该代码并且在评论中被建议不要这样做。

这些提议的解决方案是否比我认为的更好,或者是否有更好的替代方案?

import GetArticle (WArticle, getArticle, wa_links, wiki2File) -- my own
type URL = Text

data CrawlState =
     CrawlState  ![URL]       ![(URL, Int)]
          --    [Completed]    [(Queue, depth)]
-- Called by user
buildDB :: URL -> Int -> IO [WArticle]
buildDB startURL recursionDepth = buildHelper cs
    where cs = CrawlState [] [(startURL, recursionDepth)]

-- Builds list recursively
buildHelper :: CrawlState -> IO [WArticle]
buildHelper !cs@(CrawlState _ queue) = {-# SCC "buildHelper" #-}
  if null queue
    then return []
    else do
      (!article, !cs') <- runStateT getNextNode cs
      rest <- buildHelper cs'
      return (article:rest)

-- State manipulation
getNextNode :: StateT CrawlState IO WArticle
getNextNode = {-# SCC "getNextNode" #-} do
  CrawlState !parsed !queue@( (url, depth):queueTail ) <- get
  article <- liftIO $ getArticle url
  put $ CrawlState (url:parsed) (queueTail++ ( if depth > 1
          then let  !newUrls  = wa_links article \ parsed
                    !newUrls' = newUrls          \ map fst queue
                    in zip newUrls' (repeat (depth-1))
          else []))
  return article

startUrl = pack "https://en.wikipedia.org/wiki/Haskell_(programming_language)"
recursionDepth = 3

main :: IO ()
main =  {-# SCC "DbMain" #-}
  buildDB startUrl recursionDepth
   >>= return . wiki2File
   >>= writeFile "savedArticles.txt"

完整代码位于 https://gitlab.com/mattias.br/sillyWikipediaSpider。当前版本仅限于从每个页面下载前八个链接以节省时间。在不更改它的情况下,它以 ~600 MB 的堆使用量下载 55 页。

感谢您的帮助!

2) Is [IO WArticle] want I want in this case?

不完全是。问题在于某些 IO WArticle 操作取决于先前操作的结果:指向未来页面的链接驻留在先前获得的页面中。 [IO Warticle] 无法提供:从某种意义上说,它是纯粹的,您始终可以在列表中找到一个动作,而无需执行之前的动作。

我们需要的是一种"effectful list",可以让我们一个一个地抽取文章,逐步执行必要的效果,而不是强制我们一次性完全生成列表。

有几个库在返回最终结果之前提供这些 "effectful lists": streaming, pipes, conduit. They define monad transformers that extend a base monad with the ability to yield 中间值。通常最终结果的类型与产生的值不同;它可能只是单位 ().

注意:这些库的FunctorApplicativeMonad实例与纯列表的相应实例不同。 Functor instances map over the resulting final value, not over the intermediate values which are yielded. To map over the yielded values, they provide separate functions. And The Monad instances sequence effectful lists, instead of trying all combinations. To try all combinations, they provide separate functions.

使用 streaming 库,我们可以将 buildHelper 修改为如下所示:

import Streaming
import qualified Streaming.Prelude as S

buildHelper :: CrawlState -> Stream (Of WArticle) IO ()
buildHelper !cs@(CrawlState _ queue) = 
  if null queue
    then return []
    else do (article, cs') <- liftIO (runStateT getNextNode cs)
            S.yield article
            buildHelper cs'

然后我们可以使用像 mapM_ 这样的函数(来自 Streaming.Prelude,而不是来自 Control.Monad 的函数!)在文章生成时一篇接一篇地处理它们。

在 danidiaz 的回答的基础上添加进一步的解释和代码。这是最终代码:

import Streaming
import qualified Streaming.Prelude as S
import System.IO (IOMode (WriteMode), hClose, openFile)

buildHelper :: CrawlState -> Stream (Of WArticle) IO ()
buildHelper cs@( CrawlState _ queue ) = 
  if null queue
    then return ()
    else do
      (article, cs') <- liftIO (runStateT getNextNode cs)
      S.yield article
      buildHelper cs'

main :: IO ()
main = do outFileHandle <- openFile filename WriteMode
          S.toHandle outFileHandle  . S.show . buildHelper $
              CrawlState [] [(startUrl, recursionDepth)]
          hClose outFileHandle

outFileHandle 是一个常用的文件输出句柄。

S.toHandle 获取字符串流并将它们写入指定的句柄。

S.showshow :: WArticle -> String 映射到流上。

一个优雅的解决方案,即使它是由一系列 IO 操作(即下载网站)产生的,也可以创建惰性流,并在结果可用时将其写入文件。在我的机器上,它在执行期间仍然使用大量内存(相对于任务),但从未超过 450 MB。