简单的多线程Haskell的大量内存消耗

时间:2014-08-27 20:44:15

标签: multithreading haskell queue ghc

我有一个相对简单的"副本"只复制一个文件的所有行到另一个文件的程序。我正在使用TMQueueSTM来处理Haskell的并发支持,所以我想我会这样尝试:

{-# LANGUAGE BangPatterns #-}

module Main where

import Control.Applicative
import Control.Concurrent.Async              -- from async
import Control.Concurrent.Chan
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TMQueue        -- from stm-chans
import Control.Monad (replicateM, forM_, forever, unless)
import qualified Data.ByteString.Char8 as B
import Data.Function (fix)
import Data.Maybe (catMaybes, maybe)
import System.IO (withFile, IOMode(..), hPutStrLn, hGetLine)
import System.IO.Error (catchIOError)

input  = "data.dat"
output = "out.dat"
batch = 100 :: Int

consumer :: TMQueue B.ByteString -> IO ()
consumer q = withFile output WriteMode $ \fh -> fix $ \loop -> do
  !items <- catMaybes <$> replicateM batch readitem
  forM_ items $ B.hPutStrLn fh
  unless (length items < batch) loop
  where
    readitem = do
      !item <- atomically $ readTMQueue q
      return item

producer :: TMQueue B.ByteString -> IO ()
producer q = withFile input ReadMode $ \fh ->
  (forever (B.hGetLine fh >>= atomically . writeTMQueue q))
  `catchIOError` const (atomically (closeTMQueue q) >> putStrLn "Done")

main :: IO ()
main = do
  q <- atomically newTMQueue
  thread <- async $ consumer q
  producer q
  wait thread

我可以像这样制作一个小测试输入文件

ghc -e 'writeFile "data.dat" (unlines (map show [1..5000000]))'

并像这样构建

ghc --make QueueTest.hs -O2 -prof -auto-all -caf-all -threaded -rtsopts -o q

当我像./q +RTS -s -prof -hc -L60 -N2那样运行时,它表示&#34; 2117 MB总内存正在使用&#34;!但输入文件只有38 MB!

我是剖析分析的新手,但是我已经在图表后生成了图表,但无法确定我的错误。

1 个答案:

答案 0 :(得分:2)

正如OP指出的那样,到现在为止我还可以写一个真正的答案。让我们从内存消耗开始。

两个有用的参考资料是Memory footprint of Haskell data typeshttp://blog.johantibell.com/2011/06/memory-footprints-of-some-common-data.html。我们还需要查看一些结构的定义。

-- from http://hackage.haskell.org/package/stm-chans-3.0.0.2/docs/src/Control-Concurrent-STM-TMQueue.html

data TMQueue a = TMQueue
    {-# UNPACK #-} !(TVar Bool)
    {-# UNPACK #-} !(TQueue a)
    deriving Typeable


-- from http://hackage.haskell.org/package/stm-2.4.3/docs/src/Control-Concurrent-STM-TQueue.html

-- | 'TQueue' is an abstract type representing an unbounded FIFO channel.
data TQueue a = TQueue {-# UNPACK #-} !(TVar [a])
                       {-# UNPACK #-} !(TVar [a])

TQueue实现使用带有读端和写端的标准功能队列。

让我们设置内存使用量的上限,并假设我们在消费者做任何事之前将整个文件读入TMQueue。在这种情况下,我们的TQueue的写端将包含一个列表,每个输入行有一个元素(存储为bytestring)。每个列表节点看起来都像

(:) bytestring tail

取3个字(每个字段1个,构造函数1个)。每个字节串是9个字,所以将两个字加在一起,每行有<12>字 ,不包括实际数据。您的测试数据是500万行,因此整个文件的开销为6000万字(加上一些常量),在64位系统上大约为460MB(假设我的数学运算正确,总是有问题) 。添加40MB的实际数据,我们得到的值非常接近我在系统上看到的值。

那么,为什么我们的内存使用率接近这个上限?我有一个理论(调查留作练习!)。首先,生产者可能比消费者跑得快一点,因为阅读通常比写作更快(我使用旋转磁盘,可能SSD会有所不同)。这是readTQueue的定义:

-- |Read the next value from the 'TQueue'.
readTQueue :: TQueue a -> STM a
readTQueue (TQueue read write) = do
  xs <- readTVar read
  case xs of
    (x:xs') -> do writeTVar read xs'
                  return x
    [] -> do ys <- readTVar write
             case ys of
               [] -> retry
               _  -> case reverse ys of
                       [] -> error "readTQueue"
                       (z:zs) -> do writeTVar write []
                                    writeTVar read zs
                                    return z

首先我们尝试从读取结束读取,如果该结果为空,我们会在反转该列表后尝试从写入结束读取。

我认为发生的事情是:当消费者需要从写端读取时,它需要遍历STM事务中的输入列表 。这需要一些时间,这将导致它与生产者竞争。随着生产者进一步提前,此列表变得更长,导致读取花费更多时间,在此期间生产者能够写入更多值,导致读取失败。重复此过程直到生产者完成,然后消费者才有机会处理大量数据。这不仅会破坏并发性,还会增加CPU开销,因为消费者事务会不断重试并失败。

那么,鳗鱼呢?有几个关键的区别。首先,unagi-chan在内部使用数组而不是列表。这减少了一点开销。大部分开销来自ByteString指针,所以不多,但有点。其次,unagi保留了数组。即使我们悲观地认为制作人总是赢得争论,但在阵列填满之后,它会被推向制作人的频道一侧。现在生产者正在写一个新数组,而消费者从旧数组中读取。这种情况接近理想;由于没有对共享资源的争用,消费者具有良好的参考局部性,并且由于消费者正在处理不同的内存块,因此缓存一致性没有问题。与我对TMQueue的理论描述不同,现在您正在获得并发操作,允许生产者清除一些内存使用情况,因此它永远不会达到上限。

顺便说一句,我认为消费者批处理并不是有益的。句柄已由IO子系统缓冲,所以我不认为这会带来任何好处。对我来说,当我将消费者改为逐行操作时,性能有所改善。

现在,你能对这个问题做些什么?根据我的工作假设TMQueue遇到争用问题和您指定的要求,您只需要使用其他类型的队列。显然unagi工作得很好。我也试过TMChan,它比unagi慢了约25%,但使用的内存减少了45%,所以这也是一个不错的选择。 (这并不令人惊讶,TMChanTMQueue具有不同的结构,因此它具有不同的性能特征)

您还可以尝试更改算法,以便生产者发送多行块。这样可以降低所有ByteStrings的内存开销。

那么,什么时候可以使用TMQueue?如果生产者和消费者的速度大致相同,或者消费者的速度更快,那就应该没问题。此外,如果处理时间不均匀,或者生产者突然运行,您可能会获得良好的摊销绩效。这几乎是最糟糕的情况,也许应该报告为stm的错误?我认为如果读取功能改为

-- |Read the next value from the 'TQueue'.
readTQueue :: TQueue a -> STM a
readTQueue (TQueue read write) = do
  xs <- readTVar read
  case xs of
    (x:xs') -> do writeTVar read xs'
                  return x
    [] -> do ys <- readTVar write
             case ys of
               [] -> retry
               _  -> do writeTVar write []
                        let (z:zs) = reverse ys
                        writeTVar read zs
                        return z

它可以避免这个问题。现在zzs绑定都应该被懒惰地评估,因此列表遍历将在此事务之外发生,允许读取操作有时在争用下成功。当然,假设我首先纠正了这个问题(而且这个定义很懒惰)。可能还有其他意想不到的缺点。