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