懒惰 I/O 有什么不好之处?

92

我通常听说生产代码应避免使用惰性I/O。我的问题是,为什么?除了玩耍以外,是否可以在生产环境中使用惰性I/O?那么,什么使替代方案(例如枚举器)更好呢?

6个回答

82

懒惰的IO存在一个问题,即释放所获取的任何资源有点不可预测,因为它取决于程序如何使用数据 - 其“需求模式”。一旦程序删除对资源的最后引用,GC将最终运行并释放该资源。

懒惰流是一种非常方便的编程风格。这就是为什么shell管道如此有趣和受欢迎的原因。

然而,如果资源受限(例如高性能场景或期望扩展到机器极限的生产环境),仅仅依赖GC来清理可能是不够的保证。

有时您需要及早释放资源,以提高可扩展性。

那么,有哪些替代懒惰IO的方法,不会放弃增量处理(反过来会消耗过多的资源)?好吧,我们有基于foldl的处理方式,也就是迭代器或枚举器,由Oleg Kiselyov在2000年代后期引入,并由许多基于网络的项目普及。

我们不再像处理懒惰流或一次处理大批量数据那样处理数据,而是采用基于块的严格处理进行抽象,保证一旦读取最后一个块就会释放资源。这就是迭代器编程的本质,它提供了非常好的资源约束。

使用迭代器(或枚举器)方法的缺点是它具有有些笨拙的编程模型(类似于事件驱动编程,与良好的基于线程的控制相对)。对于任何编程语言来说,它绝对是一种高级技术。对于绝大多数编程问题来说,懒惰IO完全令人满意。然而,如果您将打开许多文件、或使用许多套接字,或否则使用许多同时资源,则迭代器(或枚举器)方法可能是有意义的选择。


23
我刚刚从一个懒I/O的讨论链接进入这个旧问题,我想补充一下,自那时以来,像pipesconduit这样的流式库已经取代了迭代器的笨拙。 - Ørjan Johansen

41

Dons提供了一个非常好的答案,但他忽略了(对我来说)迭代器最具吸引力的特性之一:它们使空间管理更容易,因为旧数据必须明确保留。考虑以下情况:

average :: [Float] -> Float
average xs = sum xs / length xs

这是一个众所周知的空间泄漏问题,因为必须将整个列表 xs 保留在内存中才能计算出 sumlength。可以通过创建一个叠加器(fold)来创建一个高效的消费者:

average2 :: [Float] -> Float
average2 xs = uncurry (/) <$> foldl (\(sumT, n) x -> (sumT+x, n+1)) (0,0) xs
-- N.B. this will build up thunks as written, use a strict pair and foldl'

但是为每个流处理器都这样做有点不方便。有一些概括性的方法(Conal Elliott - Beautiful Fold Zipping),但它们似乎并没有被广泛采用。然而,迭代器可以帮助您达到类似的表达水平。

aveIter = uncurry (/) <$> I.zip I.sum I.length

这不如使用 fold 那么高效,因为列表仍然被多次迭代,但是它是分块收集的,因此旧数据可以被高效地垃圾回收。为了打破这个属性,必须显式地保留整个输入,例如使用 stream2list:

badAveIter = (\xs -> sum xs / length xs) <$> I.stream2list

迭代器作为一种编程模型仍在不断发展中,但比一年前要好得多。我们正在学习哪些组合子是有用的(例如zipbreakEenumWith),哪些不是那么有用,其结果是内置的迭代器和组合子提供了越来越多的表现力。

尽管如此,Dons正确地指出它们是高级技术;对于每个I/O问题,我肯定不会都使用它们。


26

我在生产代码中经常使用延迟I/O技术,只有像Don提到的某些情况下才会出现问题。但是对于仅读取几个文件的情况,它能够正常工作。


我也使用惰性 I/O。当我需要更多地控制资源管理时,我会转向迭代器。 - John L

21

更新: 最近在 haskell-cafe 上,Oleg Kiseljov 表示unsafeInterleaveST(用于在 ST monad 中实现惰性 IO)非常不安全 - 它会破坏等式推理。他展示了它允许构造 bad_ctx :: ((Bool,Bool) -> Bool) -> Bool,使之成立。

> bad_ctx (\(x,y) -> x == y)
True
> bad_ctx (\(x,y) -> y == x)
False

尽管 == 是可交换的,但问题在于懒惰IO:实际的IO操作可能会被推迟到太晚的时候,例如在文件关闭之后。引用自Haskell Wiki - Problems with lazy IO:

例如,一个常见的初学者错误是在完成读取之前关闭文件:

wrong = do
    fileData <- withFile "test.txt" ReadMode hGetContents
    putStr fileData

问题在于 withFile 关闭句柄之前没有强制执行 fileData。正确的方法是将所有代码传递给 withFile:

right = withFile "test.txt" ReadMode $ \handle -> do
    fileData <- hGetContents handle
    putStr fileData

在这里,数据在withFile完成之前被消耗。


另请参阅:有关延迟IO问题的三个示例


1
实际上,将hGetContentswithFile结合起来是没有意义的,因为前者会将句柄置于“伪关闭”状态,并会懒惰地为您处理关闭,所以代码与readFile或甚至没有hCloseopenFile完全等效。这基本上就是懒惰I/O的含义。如果您不使用readFilegetContentshGetContents,那么您就没有使用懒惰I/O。例如,line <- withFile "test.txt" ReadMode hGetLine可以正常工作。 - Dag
1
@Dag:虽然 hGetContents 会为您处理文件关闭,但也可以提前自行关闭它,并有助于确保资源可预测地释放。 - Ben Millwood

18

懒惰IO的另一个问题是它有令人惊讶的行为。在正常的Haskell程序中,有时很难预测程序的每个部分何时被评估,但幸运的是由于纯度,除非存在性能问题,否则这并不重要。当引入懒惰IO时,你的代码的评估顺序实际上会影响其含义,因此你习惯认为是无害的更改可能会导致真正的问题。

例如,这里有一个关于代码的问题,看起来合理,但由于延迟IO而变得更加困惑:withFile vs. openFile

这些问题并非总是致命的,但也是需要考虑的另一件事情,对我个人而言,只有在必须处理所有工作的情况下才使用懒惰IO。


1
懒惰的I/O最糟糕的地方在于,作为程序员,你必须微观管理某些资源,而不是让实现来处理。例如,以下哪个函数与众不同?
- freeSTRef :: STRef s a -> ST s () - closeIORef :: IORef a -> IO () - endMVar :: MVar a -> IO () - discardTVar :: TVar -> STM () - hClose :: Handle -> IO () - finalizeForeignPtr :: ForeignPtr a -> IO () 在这些轻蔑的定义中,最后两个函数hClosefinalizeForeignPtr确实存在。至于其余的函数,在语言中提供的服务,实际上更可靠地由实现执行!
因此,如果文件句柄和外部引用等资源的释放也留给实现来处理,那么懒惰的I/O可能并不比惰性求值更糟糕。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接