Когда небезопасно InterleaveIO небезопасно?
В отличие от других небезопасных * операций, документация для unsafeInterleaveIO
не очень ясно о его возможных подводных камнях. Так когда именно это небезопасно? Я хотел бы знать условие для параллельного / параллельного и однопоточного использования.
В частности, являются ли две функции в следующем коде семантически эквивалентными? Если нет, то когда и как?
joinIO :: IO a -> (a -> IO b) -> IO b
joinIO a f = do !x <- a
!x' <- f x
return x'
joinIO':: IO a -> (a -> IO b) -> IO b
joinIO' a f = do !x <- unsafeInterleaveIO a
!x' <- unsafeInterleaveIO $ f x
return x'
Вот как я бы использовал это на практике:
data LIO a = LIO {runLIO :: IO a}
instance Functor LIO where
fmap f (LIO a) = LIO (fmap f a)
instance Monad LIO where
return x = LIO $ return x
a >>= f = LIO $ lazily a >>= lazily . f
where
lazily = unsafeInterleaveIO . runLIO
iterateLIO :: (a -> LIO a) -> a -> LIO [a]
iterateLIO f x = do
x' <- f x
xs <- iterateLIO f x' -- IO monad would diverge here
return $ x:xs
limitLIO :: (a -> LIO a) -> a -> (a -> a -> Bool) -> LIO a
limitLIO f a converged = do
xs <- iterateLIO f a
return . snd . head . filter (uncurry converged) $ zip xs (tail xs)
root2 = runLIO $ limitLIO newtonLIO 1 converged
where
newtonLIO x = do () <- LIO $ print x
LIO $ print "lazy io"
return $ x - f x / f' x
f x = x^2 -2
f' x = 2 * x
converged x x' = abs (x-x') < 1E-15
Хотя я бы предпочел не использовать этот код в серьезных приложениях из-за ужасающих unsafe*
В общем, я мог бы быть, по крайней мере, более ленивым, чем это было бы возможно с более строгой монадой ввода-вывода в принятии решения о том, что означает "конвергенция", что привело бы к (как я думаю, более) идиоматическому Haskell. И это поднимает другой вопрос: почему это не семантика по умолчанию для монады Haskell (или GHC?) IO? Я слышал о некоторых проблемах управления ресурсами для ленивого ввода-вывода (которые GHC предоставляет только с помощью небольшого фиксированного набора команд), но примеры, которые, как правило, приводятся, напоминают неработающий make-файл: ресурс X зависит от ресурса Y, но в случае неудачи чтобы указать зависимость, вы получите неопределенный статус для X. Действительно ли ленивый ввод-вывод является виновником этой проблемы? (С другой стороны, если в приведенном выше коде есть небольшая ошибка параллелизма, такая как взаимоблокировки, я бы воспринял это как более фундаментальную проблему.)
Обновить
Читая ответ Бена и Дитриха и его комментарии ниже, я кратко просмотрел исходный код ghc, чтобы увидеть, как реализована монада IO в GHC. Здесь я подвожу итог моих немногих открытий.
GHC реализует Haskell как нечистый, нереференциально прозрачный язык. Среда выполнения GHC работает, последовательно оценивая нечистые функции с побочными эффектами, как и любые другие функциональные языки. Вот почему порядок оценки имеет значение.
unsafeInterleaveIO
небезопасно, потому что может вносить любые ошибки параллелизма даже в однопоточной программе, выставляя (обычно) скрытую нечистоту Haskell GHC. (iteratee
кажется хорошим и элегантным решением для этого, и я непременно научусь его использовать.)Монада IO должна быть строгой, потому что безопасная, ленивая монада IO потребует точного (поднятого) представления RealWorld, что кажется невозможным.
Это не только монада IO и
unsafe
небезопасные функции. Весь Haskell (реализованный GHC) потенциально небезопасен, и "чистые" функции в (GHC) Haskell являются чистыми только по соглашению и доброй воле людей. Типы никогда не могут быть доказательством чистоты.
Чтобы увидеть это, я продемонстрирую, как Haskell GHC не является ссылочно прозрачным независимо от монады ввода-вывода, независимо от unsafe*
функции и т.д..
-- An evil example of a function whose result depends on a particular
-- evaluation order without reference to unsafe* functions or even
-- the IO monad.
{-# LANGUAGE MagicHash #-}
{-# LANGUAGE UnboxedTuples #-}
{-# LANGUAGE BangPatterns #-}
import GHC.Prim
f :: Int -> Int
f x = let v = myVar 1
-- removing the strictness in the following changes the result
!x' = h v x
in g v x'
g :: MutVar# RealWorld Int -> Int -> Int
g v x = let !y = addMyVar v 1
in x * y
h :: MutVar# RealWorld Int -> Int -> Int
h v x = let !y = readMyVar v
in x + y
myVar :: Int -> MutVar# (RealWorld) Int
myVar x =
case newMutVar# x realWorld# of
(# _ , v #) -> v
readMyVar :: MutVar# (RealWorld) Int -> Int
readMyVar v =
case readMutVar# v realWorld# of
(# _ , x #) -> x
addMyVar :: MutVar# (RealWorld) Int -> Int -> Int
addMyVar v x =
case readMutVar# v realWorld# of
(# s , y #) ->
case writeMutVar# v (x+y) s of
s' -> x + y
main = print $ f 1
Просто для удобства я собрал некоторые соответствующие определения для монады ввода-вывода, реализованные GHC. (Все приведенные ниже пути относятся к верхнему каталогу исходного репозитория ghc.)
-- Firstly, according to "libraries/base/GHC/IO.hs",
{-
The IO Monad is just an instance of the ST monad, where the state is
the real world. We use the exception mechanism (in GHC.Exception) to
implement IO exceptions.
...
-}
-- And indeed in "libraries/ghc-prim/GHC/Types.hs", We have
newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))
-- And in "libraries/base/GHC/Base.lhs", we have the Monad instance for IO:
data RealWorld
instance Functor IO where
fmap f x = x >>= (return . f)
instance Monad IO where
m >> k = m >>= \ _ -> k
return = returnIO
(>>=) = bindIO
fail s = failIO s
returnIO :: a -> IO a
returnIO x = IO $ \ s -> (# s, x #)
bindIO :: IO a -> (a -> IO b) -> IO b
bindIO (IO m) k = IO $ \ s -> case m s of (# new_s, a #) -> unIO (k a) new_s
unIO :: IO a -> (State# RealWorld -> (# State# RealWorld, a #))
unIO (IO a) = a
-- Many of the unsafe* functions are defined in "libraries/base/GHC/IO.hs":
unsafePerformIO :: IO a -> a
unsafePerformIO m = unsafeDupablePerformIO (noDuplicate >> m)
unsafeDupablePerformIO :: IO a -> a
unsafeDupablePerformIO (IO m) = lazy (case m realWorld# of (# _, r #) -> r)
unsafeInterleaveIO :: IO a -> IO a
unsafeInterleaveIO m = unsafeDupableInterleaveIO (noDuplicate >> m)
unsafeDupableInterleaveIO :: IO a -> IO a
unsafeDupableInterleaveIO (IO m)
= IO ( \ s -> let
r = case m s of (# _, res #) -> res
in
(# s, r #))
noDuplicate :: IO ()
noDuplicate = IO $ \s -> case noDuplicate# s of s' -> (# s', () #)
-- The auto-generated file "libraries/ghc-prim/dist-install/build/autogen/GHC/Prim.hs"
-- list types of all the primitive impure functions. For example,
data MutVar# s a
data State# s
newMutVar# :: a -> State# s -> (# State# s,MutVar# s a #)
-- The actual implementations are found in "rts/PrimOps.cmm".
Так, например, игнорируя конструктор и предполагая ссылочную прозрачность, мы имеем
unsafeDupableInterleaveIO m >>= f
==> (let u = unsafeDupableInterleaveIO)
u m >>= f
==> (definition of (>>=) and ignore the constructor)
\s -> case u m s of
(# s',a' #) -> f a' s'
==> (definition of u and let snd# x = case x of (# _,r #) -> r)
\s -> case (let r = snd# (m s)
in (# s,r #)
) of
(# s',a' #) -> f a' s'
==>
\s -> let r = snd# (m s)
in
case (# s, r #) of
(# s', a' #) -> f a' s'
==>
\s -> f (snd# (m s)) s
Это не то, что мы обычно получаем, связывая обычные ленивые государственные монады. Предполагая переменную состояния s
несет в себе некоторое реальное значение (а это не так), оно больше похоже на параллельный ввод-вывод (или чередующийся ввод-вывод, как правильно говорит функция), чем на ленивый ввод-вывод, который мы обычно подразумевали бы под "монадой ленивых состояний", в которой, несмотря на лени правильно пронизан ассоциативной операцией.
Я попытался реализовать действительно ленивую монаду IO, но вскоре понял, что для определения ленивой монадической композиции для типа данных IO нам нужно иметь возможность поднимать / снимать RealWorld
, Однако это кажется невозможным, потому что нет конструктора для обоих State# s
а также RealWorld
, И даже если бы это было возможно, мне бы пришлось представлять точное, функциональное представление нашего реального мира, что тоже невозможно.
Но я все еще не уверен, нарушает ли стандарт Haskell 2010 ссылочную прозрачность или ленивый ввод-вывод сам по себе плох. По крайней мере, представляется вполне возможным построить небольшую модель RealWorld, в которой ленивый ввод-вывод является абсолютно безопасным и предсказуемым. И может быть достаточно хорошее приближение, которое служит многим практическим целям без нарушения ссылочной прозрачности.
4 ответа
Вверху две функции у вас всегда идентичны.
v1 = do !a <- x
y
v2 = do !a <- unsafeInterleaveIO x
y
Помни что unsafeInterleaveIO
откладывает IO
операция до тех пор, пока результат не будет принудительно принудительным, но вы немедленно форсируете его, используя строгое соответствие !a
, поэтому операция вообще не откладывается. Так v1
а также v2
точно так же.
В общем
В общем, это зависит от вас, чтобы доказать, что ваше использование unsafeInterleaveIO
безопасно. Если вы позвоните unsafeInterleaveIO x
тогда вы должны доказать, что x
может вызываться в любое время и при этом производить тот же вывод.
Современные настроения о Ленивых И.О.
... что Lazy IO опасен и плохая идея в 99% случаев.
Главная проблема, которую он пытается решить, заключается в том, что IO должен быть сделан в IO
монад, но вы хотите иметь возможность делать инкрементный ввод-вывод и не хотите переписывать все свои чистые функции для вызова обратных вызовов ввода-вывода, чтобы получить больше данных. Инкрементный ввод-вывод важен, потому что он использует меньше памяти, позволяя вам работать с наборами данных, которые не помещаются в памяти, не слишком сильно изменяя ваши алгоритмы.
Решение для Lazy IO - это IO вне IO
монада. Это не всегда безопасно.
Сегодня люди решают проблему инкрементного ввода-вывода различными способами, используя такие библиотеки, как Conduit или Pipes. Conduit и Pipes гораздо более детерминированы и хорошо себя ведут, чем Lazy IO, решают те же проблемы и не требуют небезопасных конструкций.
Помни что unsafeInterleaveIO
действительно просто unsafePerformIO
с другим типом.
пример
Вот пример программы, которая ломается из-за ленивого ввода-вывода:
rot13 :: Char -> Char
rot13 x
| (x >= 'a' && x <= 'm') || (x >= 'A' && x <= 'M') = toEnum (fromEnum x + 13)
| (x >= 'n' && x <= 'z') || (x >= 'N' && x <= 'Z') = toEnum (fromEnum x - 13)
| otherwise = x
rot13file :: FilePath -> IO ()
rot13file path = do
x <- readFile path
let y = map rot13 x
writeFile path y
main = rot13file "test.txt"
Эта программа не будет работать. Замена ленивого ввода-вывода на строгий ввод-вывод заставит его работать.
связи
От Lazy IO нарушает чистоту Олега Киселева в списке рассылки Haskell:
Мы демонстрируем, как ленивый ввод-вывод нарушает прозрачность ссылок. Чистая функция типа
Int->Int->Int
дает разные целые числа в зависимости от порядка вычисления его аргументов. Наш код на Haskell98 использует только стандартный ввод. Мы заключаем, что восхищение чистотой Haskell и рекламой ленивых операций ввода-вывода противоречивы....
Ленивый IO не должен считаться хорошим стилем. Одним из распространенных определений чистоты является то, что чистые выражения должны давать одинаковые результаты независимо от порядка вычисления, или что равные могут быть заменены равными. Если выражение типа Int оценивается как 1, мы должны иметь возможность заменить каждое вхождение выражения на 1 без изменения результатов и других наблюдаемых.
От Lazy vs правильного ввода-вывода Олега Киселева в списке рассылки Haskell:
В конце концов, что может быть больше против духа Хаскелла, чем "чистая" функция с заметными побочными эффектами. С Lazy IO действительно нужно выбирать между правильностью и производительностью. Появление такого кода особенно странно после свидетельств тупиков с Lazy IO, представленных в этом списке менее месяца назад. Не говоря уже о непредсказуемом использовании ресурсов и использовании финализаторов для закрытия файлов (забывая, что GHC не гарантирует, что финализаторы будут работать вообще).
Киселев написал библиотеку Iteratee, которая была первой реальной альтернативой ленивому вводу-выводу.
Лень означает, что когда (и будет ли) фактически выполнено вычисление, зависит от того, когда (и будет ли) реализация времени выполнения решит, что ей нужно значение. Как программист на Haskell, вы полностью отказываетесь от контроля над порядком оценки (за исключением зависимостей данных, присущих вашему коду, и когда вы начинаете играть со строгостью, чтобы заставить среду выполнения делать определенные выборы).
Это отлично подходит для чистых вычислений, потому что результат чистых вычислений будет точно таким же, когда вы делаете это (за исключением того, что если вы выполняете вычисления, которые вам на самом деле не нужны, вы можете столкнуться с ошибками или не завершиться при другой оценке порядок может позволить программе успешно завершиться, но все не нижние значения, вычисленные любым порядком оценки, будут одинаковыми).
Но когда вы пишете IO-зависимый код, порядок оценки имеет значение. Весь смысл IO
заключается в предоставлении механизма для построения вычислений, этапы которых зависят от мира за пределами программы и влияют на него, и важной частью этого является то, что эти этапы четко упорядочены. С помощью unsafeInterleaveIO
отбрасывает эту явную последовательность и отказывается от контроля, когда (и ли) IO
Операция на самом деле выполняется во время выполнения системы.
В целом это небезопасно для операций ввода-вывода, потому что между их побочными эффектами могут быть зависимости, которые не могут быть выведены из зависимостей данных внутри программы. Например, один IO
действие может создать файл с некоторыми данными в нем, а другой IO
действие может прочитать тот же файл. Если они оба выполняются "лениво", то они будут запускаться только тогда, когда потребуется полученное значение Haskell. Создание файла возможно IO ()
хотя, и вполне возможно, что ()
никогда не нужен. Это может означать, что сначала выполняется операция чтения, либо происходит сбой, либо выполняется чтение данных, которые уже были в файле, но не данных, которые должны были быть помещены другой операцией. Нет гарантии, что система во время выполнения выполнит их в правильном порядке. Чтобы правильно программировать с системой, которая всегда делала это для IO
Вы должны быть в состоянии точно предсказать порядок, в котором среда выполнения Haskell выберет для выполнения различных IO
действия.
Лечить unsafeInterlaveIO
как обещание компилятору (которое он не может проверить, он просто будет вам доверять), что не имеет значения, когда IO
действие выполнено, или оно полностью исключено. Это действительно то, что все unsafe*
функции есть; они предоставляют средства, которые в целом не безопасны, и для которых безопасность не может быть автоматически проверена, но которые могут быть безопасными в конкретных случаях. На вас лежит ответственность за обеспечение их безопасного использования. Но если вы даете обещание компилятору, а ваше обещание ложное, то результатом могут быть неприятные ошибки. "Небезопасное" в названии состоит в том, чтобы напугать вас до мысли о вашем конкретном случае и решить, действительно ли вы можете дать обещание компилятору.
По сути, все в разделе "Обновление" в этом вопросе настолько запутано, что даже не ошибается, поэтому, пожалуйста, постарайтесь забыть об этом, когда пытаетесь понять мой ответ.
Посмотрите на эту функцию:
badLazyReadlines :: Handle -> IO [String]
badLazyReadlines h = do
l <- unsafeInterleaveIO $ hGetLine h
r <- unsafeInterleaveIO $ badLazyReadlines h
return (l:r)
В дополнение к тому, что я пытаюсь проиллюстрировать: вышеуказанная функция также не обрабатывает достижение конца файла. Но проигнорируйте это сейчас.
main = do
h <- openFile "example.txt" ReadMode
lns <- badLazyReadlines h
putStrLn $ lns ! 4
Это напечатает первую строку "example.txt", потому что 5-й элемент в списке на самом деле является первой строкой, которая читается из файла.
Ваш joinIO
а также joinIO'
не являются семантически эквивалентными. Они обычно будут одинаковыми, но здесь есть тонкость: шаблон взрыва делает значение строгим, но это все, что он делает. Образцы взрыва реализованы с использованием seq
и это не приводит в исполнение конкретный порядок оценки, в частности следующие два семантически эквивалентны:
a `seq` b `seq` c
b `seq` a `seq` c
GHC может оценить b или первый, прежде чем вернуть c. Действительно, он может сначала вычислить c, затем a и b, а затем вернуть c. Или, если он может статически доказать, что a или b не снизу, или что c снизу, ему вообще не нужно оценивать a или b. Некоторые оптимизации действительно используют этот факт, но на практике это не часто встречается.
unsafeInterleaveIO
напротив, чувствителен ко всем или к любым из этих изменений - это зависит не от семантического свойства строгости какой-либо функции, а от операционного свойства оценки чего-либо. Таким образом, все вышеперечисленные преобразования видимы для него, поэтому целесообразно просматривать unsafeInterleaveIO
как выполнение своего ввода-вывода недетерминированно, более или менее, когда это необходимо.
Это, по сути, почему unsafeInterleaveIO
небезопасно - это единственный механизм в обычном использовании, который может обнаруживать преобразования, которые должны сохранять значение. Это единственный способ определить оценку, которая по праву должна быть невозможной.
Кроме того, это, вероятно, справедливо мысленно подготовить unsafe
к каждой функции от GHC.Prim
и, возможно, несколько других GHC.
модули также. Они, конечно, не обычные Хаскелл.