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

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

6 ответов

Решение

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

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

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

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

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

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

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

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

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

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

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

Обновление: недавно на 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

Проблема в том, что File закрывает дескриптор перед принудительным использованием fileData. Правильный путь - передать весь код в withFile:

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

Здесь данные потребляются до завершения withFile.

Это часто неожиданно и легко сделать ошибку.


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

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

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

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

Что плохого в ленивом вводе-выводе, так это то, что вам , программисту, приходится микроуправлять определенными ресурсами, а не реализацией. Например, что из следующего является «другим»?

  • freeSTRef :: STRef s a -> ST s ()
  • closeIORef :: IORef a -> IO ()
  • endMVar :: MVar a -> IO ()
  • discardTVar :: TVar -> STM ()
  • hClose :: Handle -> IO ()
  • finalizeForeignPtr :: ForeignPtr a -> IO ()

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

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

Другие вопросы по тегам