Что плохого в Lazy I / O?

89

Обычно я слышал, что производственный код не должен использовать ленивый ввод-вывод. У меня вопрос, почему? Можно ли когда-нибудь использовать ленивый ввод-вывод вне игры? И что делает альтернативы (например, счетчики) лучше?

Дэн Бертон
источник

Ответы:

81

У ленивого ввода-вывода есть проблема, заключающаяся в том, что высвобождение любого полученного вами ресурса в некоторой степени непредсказуемо, так как это зависит от того, как ваша программа потребляет данные - от ее «модели спроса». Как только ваша программа удалит последнюю ссылку на ресурс, сборщик мусора в конечном итоге запустит и освободит этот ресурс.

Ленивые потоки - очень удобный стиль для программирования. Вот почему оболочки-оболочки так интересны и популярны.

Однако, если ресурсы ограничены (как в сценариях с высокой производительностью или в производственных средах, которые предполагают масштабирование до пределов машины), использование GC для очистки может быть недостаточной гарантией.

Иногда вам нужно быстро высвободить ресурсы, чтобы улучшить масштабируемость.

Так каковы же альтернативы ленивому вводу-выводу, которые не означают отказа от инкрементной обработки (которая, в свою очередь, потребляет слишком много ресурсов)? Итак, у нас есть foldlобработка на основе, также известная как итераторы или счетчики, введенные Олегом Киселевым в конце 2000-х годов и с тех пор популяризированные рядом сетевых проектов.

Вместо того, чтобы обрабатывать данные как ленивые потоки или в одном большом пакете, мы вместо этого абстрагируемся от строгой обработки на основе фрагментов с гарантированным завершением ресурса после прочтения последнего фрагмента. Это суть программирования на основе итераций, которое предлагает очень хорошие ограничения ресурсов.

Обратной стороной IO на основе итераций является то, что он имеет несколько неудобную модель программирования (примерно аналогичную программированию на основе событий, а не хорошему управлению на основе потоков). Это определенно продвинутая техника для любого языка программирования. И для подавляющего большинства проблем программирования ленивый ввод-вывод вполне удовлетворителен. Однако, если вы будете открывать много файлов или разговаривать по множеству сокетов, или иным образом использовать множество одновременных ресурсов, подход итеративного (или перечислителя) может иметь смысл.

Дон Стюарт
источник
22
Поскольку я просто перешел по ссылке на этот старый вопрос из обсуждения ленивого ввода-вывода, я подумал, что добавлю примечание о том, что с тех пор большая часть неудобства итераций была заменена новыми потоковыми библиотеками, такими как каналы и канал .
Ørjan Johansen
40

Донс дал очень хороший ответ, но он упустил из виду то, что (для меня) является одной из самых убедительных особенностей итераций: они упрощают рассуждение об управлении пространством, поскольку старые данные должны явно сохраняться. Рассмотреть возможность:

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) , и которые в меньшей степени, в результате чего встроенный iteratees и комбинаторы обеспечивают постоянно больше выразительности.

Тем не менее, Донс прав в том, что это продвинутая техника; Я бы точно не стал использовать их для решения каждой проблемы ввода-вывода.

Джон Л
источник
25

Я все время использую ленивый ввод-вывод в производственном коде. Как сказал Дон, это проблема только при определенных обстоятельствах. Но для чтения нескольких файлов он отлично работает.

август
источник
Я тоже использую ленивый ввод-вывод. Я обращаюсь к итерациям, когда мне нужен больший контроль над управлением ресурсами.
Джон Л.
20

Обновление: недавно в haskell-cafe Олег Киселёв показал, что unsafeInterleaveST(который используется для реализации ленивого ввода-вывода в монаде ST) очень небезопасен - он нарушает эквациональные рассуждения. Он показывает, что это позволяет построить bad_ctx :: ((Bool,Bool) -> Bool) -> Bool такое, что

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

хотя ==и коммутативен.


Еще одна проблема с отложенным вводом-выводом: фактическую операцию ввода-вывода можно отложить до тех пор, пока не станет слишком поздно, например, после закрытия файла. Цитата из Haskell Wiki - Проблемы с ленивым вводом-выводом :

Например, распространенная ошибка новичков - закрыть файл до того, как его прочитали:

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.

Это часто неожиданная ошибка, которую легко исправить.


См. Также: Три примера проблем с отложенным вводом-выводом .

Петр
источник
На самом деле комбинирование hGetContentsи withFileбессмысленно, потому что первое переводит дескриптор в «псевдозакрытое» состояние и будет обрабатывать закрытие за вас (лениво), поэтому код в точности эквивалентен readFileили даже openFileбез него hClose. Это в основном то , что ленив I / O является . Если вы не используете readFile, getContentsили hGetContentsвы не используете ленивое I / O. Например line <- withFile "test.txt" ReadMode hGetLineотлично работает.
Dag
1
@Dag: хотя и hGetContentsбудет обрабатывать закрытие файла за вас, также допустимо закрывать его самостоятельно «раньше» и помогает обеспечить предсказуемое высвобождение ресурсов.
Бен Миллвуд
17

Еще одна проблема с ленивым вводом-выводом, о которой до сих пор не упоминалось, заключается в ее удивительном поведении. В обычной программе на Haskell иногда бывает трудно предсказать, когда будет оцениваться каждая часть вашей программы, но, к счастью, из-за чистоты это действительно не имеет значения, если у вас нет проблем с производительностью. Когда вводится ленивый ввод-вывод, порядок оценки вашего кода фактически влияет на его значение, поэтому изменения, которые вы привыкли считать безобидными, могут вызвать у вас настоящие проблемы.

В качестве примера, вот вопрос о коде, который выглядит разумным, но становится более запутанным из-за отложенного ввода-вывода: withFile vs. openFile

Эти проблемы не всегда фатальны, но об этом стоит подумать и о достаточно серьезной головной боли, поэтому я лично избегаю ленивого ввода-вывода, если нет реальной проблемы с выполнением всей работы заранее.

Бен Миллвуд
источник