Что плохого в ленивом вводе-выводе?
Обычно я слышал, что производственный код должен избегать использования Lazy I/O. Мой вопрос: почему? Хорошо ли использовать Lazy I/O за пределами того, что вы просто играете? И что делает альтернативы (например, счетчики) лучше?
Ответы
Ответ 1
У Lazy IO есть проблема, заключающаяся в том, что освобождение любого ресурса, который вы приобрели, несколько непредсказуемо, так как это зависит от того, как ваша программа потребляет данные - ее "шаблон спроса". Как только ваша программа отбрасывает последнюю ссылку на ресурс, GC в конечном итоге запускает и освобождает этот ресурс.
Ленивые потоки - очень удобный стиль для программирования. Вот почему оболочки корпуса настолько забавны и популярны.
Однако, если ресурсы ограничены (как в высокопроизводительных сценариях, так и в производственных средах, которые ожидают масштабирования до пределов машины), полагаясь на GC для очистки, может быть недостаточной гарантией.
Иногда вам нужно выделять ресурсы с нетерпением, чтобы улучшить масштабируемость.
Итак, каковы альтернативы ленивому IO, которые не означают отказ от инкрементной обработки (что, в свою очередь, будет потреблять слишком много ресурсов)? Ну, у нас есть обработка на основе foldl
, так называемая итерация или перечисление, введенная Олегом Киселевым в конце 2000-х годов, и так популяризирована количество сетевых проектов.
Вместо обработки данных как ленивых потоков или в одной огромной партии мы вместо этого абстрагируемся над строгой обработкой на основе блоков, с гарантированной финализацией ресурса после чтения последнего фрагмента. Это суть программирования на основе итераций, а также тот, который предлагает очень хорошие ограничения ресурсов.
Недостатком IO на основе итерации является то, что он имеет несколько неудобную модель программирования (примерно аналогичную программированию на основе событий, по сравнению с красивым управлением на основе потоков). Это определенно передовая техника на любом языке программирования. И для подавляющего большинства проблем программирования ленивый IO полностью удовлетворительный. Однако, если вы откроете много файлов или поговорите во многих сокетах или иным образом используете много одновременных ресурсов, может возникнуть смысл итеративный (или перечислительный) подход.
Ответ 2
Dons предоставил очень хороший ответ, но он оставил то, что (для меня) является одной из наиболее неотразимых особенностей итераций: они упрощают рассуждение о управлении пространством, потому что старые данные должны быть явно сохранены. Рассмотрим:
average :: [Float] -> Float
average xs = sum xs / length xs
Это хорошо известная утечка пространства, потому что весь список xs
должен сохраняться в памяти для вычисления как sum
, так и length
. Можно создать эффективного потребителя, создав складку:
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
Это не так эффективно, как сгиб, потому что список по-прежнему повторяется несколько раз, однако он собирает куски, поэтому старые данные могут быть эффективно собраны в мусор. Чтобы разбить это свойство, необходимо явно сохранить весь ввод, например, с помощью stream2list:
badAveIter = (\xs -> sum xs / length xs) <$> I.stream2list
Состояние итераций в качестве модели программирования - это работа, однако она намного лучше, чем год назад. Мы изучаем, какие комбинаторы полезны (например, zip
, breakE
, enumWith
) и, тем более, результат, который встроенные итерации и комбинаторы обеспечивают постоянную выразительность.
Тем не менее, Донс прав, что они являются передовой техникой; Я бы не использовал их для каждой проблемы ввода-вывода.
Ответ 3
Я использую ленивый ввод-вывод в производственном коде все время. Это только проблема в определенных обстоятельствах, как сказал Дон. Но для простого чтения нескольких файлов он отлично работает.
Ответ 4
Еще одна проблема с ленивым IO, о которой не упоминалось до сих пор, заключается в том, что у нее удивительное поведение. В обычной программе Haskell иногда бывает сложно предсказать, когда оценивается каждая часть вашей программы, но, к счастью, из-за чистоты это действительно не имеет значения, если у вас нет проблем с производительностью. Когда вводится ленивый ввод-вывод, порядок оценки вашего кода фактически влияет на его значение, поэтому изменения, которые вы привыкли считать безвредными, могут вызвать у вас настоящие проблемы.
В качестве примера, здесь возникает вопрос о коде, который выглядит разумным, но более запутанным отложенным IO: withFile vs. openFile
Эти проблемы не всегда являются фатальными, но это еще одна вещь, о которой нужно подумать, и достаточно сильная головная боль, которую я лично избегаю ленивого ИО, если нет реальной проблемы с выполнением всей работы.
Ответ 5
Обновление: Недавно на haskell-cafe Олег Киселев показал, что unsafeInterleaveST
(который используется для реализации ленивого ввода-вывода в монаде ST ) очень небезопасно - это нарушает эквациональные рассуждения. Он показывает, что он позволяет построить bad_ctx :: ((Bool,Bool) -> Bool) -> Bool
такой, что
> bad_ctx (\(x,y) -> x == y)
True
> bad_ctx (\(x,y) -> y == x)
False
хотя ==
является коммутативным.
Другая проблема с ленивым IO: фактическая операция ввода-вывода может быть отложена до тех пор, пока она не станет слишком поздней, например, после закрытия файла. Цитата из Haskell Wiki - проблемы с ленивым IO:
Например, общая ошибка начинающего заключается в том, чтобы закрыть файл, прежде чем он его прочитает:
wrong = do
fileData <- withFile "test.txt" ReadMode hGetContents
putStr fileData
Проблема заключается в том, что withFile закрывает дескриптор до принудительного форматирования fileData. Правильный способ - передать весь код с помощью .File:
right = withFile "test.txt" ReadMode $ \handle -> do
fileData <- hGetContents handle
putStr fileData
Здесь данные расходуются до завершения финиша.
Это часто бывает неожиданным и простой в использовании ошибкой.
См. также: Три примера проблем с Lazy I/O.