【问题标题】:What is pipes/conduit trying to solve什么是管道/导管试图解决
【发布时间】:2014-05-09 15:16:53
【问题描述】:

我看到有人推荐管道/导管库用于各种惰性 IO 相关任务。这些库究竟解决了什么问题?

另外,当我尝试使用一些与 hackage 相关的库时,很可能存在三个不同的版本。示例:

这让我很困惑。对于我的解析任务,我应该使用 attoparsec 还是 pipe-attoparsec/attoparsec-conduit?与普通的 attoparsec 相比,管道/导管版本给我带来了什么好处?

【问题讨论】:

    标签: haskell pipe conduit haskell-pipes


    【解决方案1】:

    延迟 IO

    Lazy IO 是这样工作的

    readFile :: FilePath -> IO ByteString
    

    ByteString 保证只能逐块读取。为此,我们可以(几乎)编写

    -- given `readChunk` which reads a chunk beginning at n
    readChunk :: FilePath -> Int -> IO (Int, ByteString)
    
    readFile fp = readChunks 0 where
      readChunks n = do
        (n', chunk) <- readChunk fp n
        chunks      <- readChunks n'
        return (chunk <> chunks)
    

    但这里我们注意到 IO 操作 readChunks n' 是在返回之前执行的,即使是作为 chunk 可用的部分结果。这意味着我们一点也不懒惰。为了解决这个问题,我们使用unsafeInterleaveIO

    readFile fp = readChunks 0 where
      readChunks n = do
        (n', chunk) <- readChunk fp n
        chunks      <- unsafeInterleaveIO (readChunks n')
        return (chunk <> chunks)
    

    这会导致readChunks n' 立即返回,只在强制执行该thunk 时才执行IO 动作

    这是危险的部分:通过使用 unsafeInterleaveIO,我们将一堆 IO 操作延迟到未来的不确定点,这取决于我们如何使用我们的 ByteString 块。

    解决协程问题

    我们想做的是在对readChunk 的调用和对readChunks 的递归之间滑动一个块处理步骤。

    readFileCo :: Monoid a => FilePath -> (ByteString -> IO a) -> IO a
    readFileCo fp action = readChunks 0 where
      readChunks n = do
        (n', chunk) <- readChunk fp n
        a           <- action chunk
        as          <- readChunks n'
        return (a <> as)
    

    现在我们有机会在加载每个小块后执行任意IO 操作。这让我们可以增量地做更多的工作,而无需将ByteString 完全加载到内存中。不幸的是,它的组合性并不好——我们需要构建我们的消费 action 并将其传递给我们的 ByteString 生产者才能运行。

    基于管道的 IO

    这本质上是pipes 解决的问题——它允许我们轻松地编写有效的协同程序。例如,我们现在将文件阅读器编写为 Producer,当其效果最终运行时,可以将其视为“流式传输”文件的块。

    produceFile :: FilePath -> Producer ByteString IO ()
    produceFile fp = produce 0 where
      produce n = do
        (n', chunk) <- liftIO (readChunk fp n)
        yield chunk
        produce n'
    

    请注意这段代码和上面的readFileCo 之间的相似之处——我们只是用yield 替换了对协程操作的调用,而chunk 是我们迄今为止生成的。对yield 的调用构建了一个Producer 类型,而不是一个原始的IO 操作,我们可以将其与其他Pipes 类型组合,以构建一个称为Effect IO () 的良好消费管道。

    所有这些管道构建都是静态完成的,无需实际调用任何IO 操作。这就是pipes 让您更轻松地编写协程的方式。当我们在 main IO 操作中调用 runEffect 时,所有效果都会立即触发。

    runEffect :: Effect IO () -> IO ()
    

    Attoparsec

    那么为什么要将attoparsec 插入pipes 呢?好吧,attoparsec 针对惰性解析进行了优化。如果您以有效的方式生成馈送到attoparsec 解析器的块,那么您将陷入僵局。你可以

    1. 使用严格的 IO 并将整个字符串加载到内存中,以便您的解析器懒惰地使用它。这简单、可预测,但效率低下。
    2. 使用惰性 IO 并失去推断生产 IO 效果何时实际运行的能力,从而导致可能的资源泄漏或根据已解析项目的消耗计划关闭句柄异常。这比(1)更有效,但很容易变得不可预测;或者,
    3. 使用pipes(或conduit)构建一个协程系统,其中包括你的惰性attoparsec解析器,允许它在尽可能少的输入上进行操作,同时在整个过程中尽可能惰性地生成解析值流。

    【讨论】:

    • 谢谢,readChunks 函数中的&lt;&gt; 运算符是什么?胡格尔没有帮助。
    • 对不起,那个懒惰的ByteString的Monoid。这个想法应该是惰性字节字符串只是严格字节字符串的惰性列表,所以(&lt;&gt;)在这些列表中只不过是(++)
    • @Sibi 可能是中缀mappend。喂!可以找到:holumbus.fh-wedel.de/hayoo/hayoo.html?query=%3C%3E
    • 关于 attoparsec 部分的第 1 点,那里的严格 IO 是什么意思?这是否指的是没有 unsafeInterleaveIO 的原始 readFile 函数,它将整个文件读入内存?
    • @Sibi 没错。要对此进行更多探索,请考虑Joachim Breitner's "How to Construct a List in a Monad"。特别是,检查他概述的问题(并且没有提供明确的解决方案)是否正是 pipesconduit 解决的那种想法。
    【解决方案2】:

    如果要使用 attoparsec,请使用 attoparsec

    对于我的解析任务,我应该使用 attoparsec 还是 pipe-attoparsec/attoparsec-conduit?

    pipes-attoparsecattoparsec-conduit 都将给定的 attoparsec Parser 转换为水槽/导管或管道。因此,无论哪种方式,您都必须使用attoparsec

    与普通的 attoparsec 相比,管道/导管版本给我带来了什么好处?

    它们与管道和导管一起使用,而香草则不会(至少不是开箱即用的)。

    如果您不使用管道或管道,并且您对惰性 IO 的当前性能感到满意,则无需更改当前流程,尤其是在您不编写大型应用程序或处理大型文件时.你可以简单地使用attoparsec

    但是,这假设您知道惰性 IO 的缺点。

    惰性 IO 是怎么回事? (问题研究withFile

    别忘了你的第一个问题:

    这些库究竟解决了什么问题?

    它们解决了流数据问题(参见 13),这种问题发生在具有惰性 IO 的函数式语言中。惰性 IO 有时给你的不是你想要的(见下面的例子),有时很难确定特定惰性操作所需的实际系统资源(是以块/字节/缓冲/onclose/onopen…的形式读取/写入的数据) .

    过度懒惰的例子

    import System.IO
    main = withFile "myfile" ReadMode hGetContents
           >>= return . (take 5)
           >>= putStrLn
    

    这不会打印任何内容,因为数据的评估发生在putStrLn,但此时句柄已经关闭。

    用毒酸灭火

    虽然下面的 sn-p 解决了这个问题,但它还有另一个令人讨厌的功能:

    main = withFile "myfile" ReadMode $ \handle -> 
               hGetContents handle
           >>= return . (take 5)
           >>= putStrLn
    

    在这种情况下,hGetContents 将读取所有文件,这是您一开始没想到的。如果您只想检查可能有几 GB 大小的文件的魔术字节,那么这不是要走的路。

    正确使用withFile

    显然,解决方案是take withFile 上下文中的东西:

    main = withFile "myfile" ReadMode $ \handle -> 
               fmap (take 5) (hGetContents handle)
           >>= putStrLn
    

    顺便说一下,也是解决办法mentioned by the author of pipes

    这个 [..] 回答了人们有时会问我的关于 pipes 的问题,我将在此进行过渡:

    如果资源管理不是pipes的核心关注点,我为什么要使用pipes而不是惰性IO?

    许多提出这个问题的人都是通过 Oleg 发现流式编程的,Oleg 将惰性 IO 问题从资源管理的角度提出来。然而,我从来没有发现这个论点孤立地令人信服。您可以通过将资源获取与惰性 IO 分开来解决大多数资源管理问题,如下所示:[参见上面的最后一个示例]

    这让我们回到我之前的陈述:

    您可以简单地使用 attoparsec [...][带有惰性 IO,假设]您知道惰性 IO 的缺点。

    参考文献

    【讨论】:

    • 如果您将惰性 I/O 视为一种透明的并发,则更容易考虑可能出现问题的地方:如果 hGetContents 分叉了一个线程,很明显从 withFile 立即返回之后可能不会让线程有机会在文件句柄关闭之前完成读取。
    【解决方案3】:

    这是两个库的作者的精彩播客:

    http://www.haskellcast.com/episode/006-gabriel-gonzalez-and-michael-snoyman-on-pipes-and-conduit/

    它将回答您的大部分问题。


    简而言之,这两个库都解决了流式处理问题,这在处理 IO 时非常重要。本质上,它们管理数据块的传输, 从而允许您例如在服务器和客户端上传输一个仅占用 64KB RAM 的 1GB 文件。如果没有流式传输,您将不得不在两端分配尽可能多的内存。

    这些库的旧替代方案是惰性 IO,但它充满了问题并且使应用程序容易出错。播客中讨论了这些问题。

    关于使用哪一个库,更多的是个人喜好问题。我更喜欢“管道”。播客中也讨论了详细的差异。

    【讨论】:

    • 谢谢,我已经看到了。但他们没有讨论我在上述问题上概述的问题(至少以明确的方式)。
    猜你喜欢
    • 1970-01-01
    • 2021-09-13
    • 2014-11-04
    • 2010-09-15
    • 1970-01-01
    • 2018-07-18
    • 2017-02-25
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多