19

与其他 unsafe* 操作不同,文档对于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*东西,我至少可以比更严格的 IO monad 在决定“收敛”的含义时更懒惰,导致(我认为是)更惯用的 Haskell。这带来了另一个问题:为什么它不是 Haskell(或 GHC?)IO monad 的默认语义?我听说过一些惰性 IO 的资源管理问题(GHC 仅通过一小部分固定命令提供),但通常给出的示例有点类似于损坏的 makefile:资源 X 依赖于资源 Y,但如果你失败了要指定依赖关系,您会得到 X 的未定义状态。惰性 IO 真的是这个问题的罪魁祸首吗?(另一方面,如果上述代码中存在细微的并发错误,例如死锁,我会将其视为更根本的问题。)

更新

阅读 Ben 和 Dietrich 的回答以及他在下面的评论,我简要浏览了 ghc 源代码以了解 IO monad 是如何在 GHC 中实现的。在这里,我总结了我的一些发现。

  1. GHC 将 Haskell 实现为一种不纯的、非引用透明的语言。GHC 的运行时通过连续评估具有副作用的不纯函数来运行,就像任何其他函数式语言一样。这就是评估顺序很重要的原因。

  2. unsafeInterleaveIO是不安全的,因为即使在单线程程序中,它也会通过暴露 GHC 的 Haskell 的(通常)隐藏杂质来引入任何类型的并发错误。(iteratee这似乎是一个不错且优雅的解决方案,我一定会学习如何使用它。)

  3. IO monad 必须是严格的,因为安全、惰性的 IO monad 需要对 RealWorld 的精确(提升)表示,这似乎是不可能的。

  4. 不安全的不仅仅是 IO monad 和unsafe函数。整个 Haskell(由 GHC 实现)可能是不安全的,并且(GHC 的)Haskell 中的“纯”功能只是按照惯例和人们的善意纯粹的。类型永远不能证明纯度。

为了看到这一点,我演示了 GHC 的 Haskell 如何具有引用透明性,而不管 IO monad、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 实现的 IO monad 的相关定义。(以下所有路径都是相对于 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 )而不是我们通常所说的“惰性状态单子”的惰性 IO ,其中尽管懒惰状态通过关联操作适当地线程化。

我试图实现一个真正惰性的 IO monad,但很快意识到为了为 IO 数据类型定义一个惰性 monadic 组合,我们需要能够提升/取消提升RealWorld. 然而,这似乎是不可能的,因为State# s和都没有构造函数RealWorld。即使这是可能的,我也必须代表我们真实世界的精确、功能表示,这也是不可能的。

但我仍然不确定标准的 Haskell 2010 是否破坏了引用透明度或惰性 IO 本身是坏的。至少似乎完全有可能构建一个 RealWorld 的小型模型,在该模型上,惰性 IO 是完全安全和可预测的。并且可能有一个足够好的近似值,可以在不破坏引用透明度的情况下服务于许多实际目的。

4

4 回答 4

18

在顶部,您拥有的两个功能始终相同。

v1 = do !a <- x
        y

v2 = do !a <- unsafeInterleaveIO x
        y

请记住,将操作unsafeInterleaveIO推迟IO到其结果被强制执行——但您通过使用严格的模式匹配立即强制执行!a,因此操作根本不会被推迟。所以v1v2完全一样。

一般来说

一般来说,由您来证明您的使用unsafeInterleaveIO是安全的。如果你调用unsafeInterleaveIO x,那么你必须证明可以x随时调用并且仍然产生相同的输出。

关于 Lazy IO 的现代情感

...是 Lazy IO 在 99% 的情况下都是危险的,而且是个坏主意。

它试图解决的主要问题是 IO 必须在IOmonad 中完成,但是您希望能够进行增量 IO,并且您不想重写所有纯函数来调用 IO 回调以获得更多数据。增量 IO 很重要,因为它使用的内存更少,允许您对不适合内存的数据集进行操作,而无需过多地更改算法。

Lazy IO 的解决方案是在IOmonad 之外做 IO。这通常是不安全的。

今天,人们通过使用ConduitPipes等库以不同方式解决增量 IO 问题。Conduit 和 Pipes 比 Lazy IO 更具确定性和良好行为,解决了相同的问题,并且不需要不安全的构造。

请记住,这unsafeInterleaveIO实际上只是unsafePerformIO一种不同的类型。

例子

下面是一个由于惰性 IO 而损坏的程序示例:

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"

该程序将不起作用。 将惰性 IO 替换为严格 IO 将使其工作。

链接

来自Lazy IO的 Oleg Kiselyov 在 Haskell 邮件列表中打破了纯度:

我们演示了惰性 IO 如何破坏引用透明性。该类型的纯函数Int->Int->Int根据其参数的评估顺序给出不同的整数。我们的 Haskell98 代码只使用标准输入。我们得出的结论是,颂扬 Haskell 的纯洁性和宣传惰性 IO 是不一致的。

...

Lazy IO 不应该被认为是好的风格。纯度的常见定义之一是纯表达式应该计算相同的结果,而不管计算顺序如何,或者等于可以代替等于。如果 Int 类型的表达式计算结果为 1,我们应该能够用 1 替换表达式的每个出现,而不会更改结果和其他可观察值。

来自Haskell 邮件列表中 Oleg Kiselyov 的Lazy vs correct IO

毕竟,有什么比一个带有明显副作用的“纯”函数更违背 Haskell 精神的了。使用 Lazy IO,确实需要在正确性和性能之间做出选择。在不到一个月前出现在这个列表中的 Lazy IO 死锁的证据之后,这种代码的出现尤其奇怪。更不用说不可预测的资源使用和依赖终结器来关闭文件(忘记 GHC 并不能保证终结器将完全运行)。

Kiselyov 编写了Iteratee库,它是惰性 IO 的第一个真正替代品。

于 2012-11-07T05:39:58.157 回答
11

惰性意味着何时(以及是否)实际执行计算取决于运行时实现何时(以及是否)决定它需要该值。作为一名 Haskell 程序员,您完全放弃了对评估顺序的控制(除了代码中固有的数据依赖关系,以及当您开始严格要求运行时做出某些选择时)。

这对于计算非常有用,因为无论何时执行纯计算,其结果都将完全相同(除非您执行实际上不需要的计算,否则在进行另一次评估时,您可能会遇到错误或无法终止order 可能允许程序成功终止;但任何评估 order 计算的所有非底部值都将相同)。

但是当您编写依赖于 IO 的代码时,评估顺序很重要。的全部意义IO在于提供一种机制来构建计算,其步骤依赖并影响程序外部的世界,而这样做的一个重要部分是这些步骤是明确排序的。UsingunsafeInterleaveIO抛弃了显式排序,并放弃IO了对运行时系统何时(以及是否)实际执行操作的控制。

这对于 IO 操作通常是不安全的,因为它们的副作用之间可能存在依赖关系,而这些依赖关系无法从程序内部的数据依赖关系中推断出来。例如,一个IO操作可能会创建一个包含一些数据的文件,而另一个IO操作可能会读取同一个文件。如果它们都“懒惰地”执行,那么它们只会在需要生成的 Haskell 值时运行。创建文件可能IO ()是,而且很有可能()永远不会需要。这可能意味着首先执行读取操作,失败或读取文件中已经存在的数据,而不是其他操作应该放置的数据。无法保证运行时系统会以正确的顺序执行它们。要使用始终IO您执行此操作的系统正确编程,您必须能够准确预测 Haskell 运行时选择执行各种IO操作的顺序。

将其视为unsafeInterlaveIO对编译器的承诺(它无法验证,它只会信任您)何时执行该操作或是否完全忽略它并不重要。IO这就是所有unsafe*功能的真正含义;它们提供的设施一般不安全,安全性不能自动检查,但在特定情况下可以安全。您有责任确保您对它们的使用实际上是安全的。但是,如果您向编译器做出承诺,而您的承诺是错误的,那么结果可能是令人不快的错误。名称中的“不安全”是为了吓唬您考虑您的特定情况并决定您是否真的可以向编译器做出承诺。

于 2012-11-07T06:01:04.833 回答
3

基本上问题中“更新”下的所有内容都非常混乱,甚至没有错,所以当你试图理解我的答案时,请尽量忘记它。

看看这个函数:

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 个元素实际上是从文件中读取的第一行。

于 2015-01-19T10:25:33.733 回答
2

你的joinIOand在语义joinIO'等价。它们通常是相同的,但有一个微妙之处:爆炸模式使值变得严格,但仅此而已。Bang 模式是使用 实现的seq,并且不强制执行特定的评估顺序,特别是以下两个在语义上是等效的:

a `seq` b `seq` c
b `seq` a `seq` c

GHC 可以在返回 c 之前评估 b 或 a first。实际上,它可以先计算 c,然后计算 a 和 b,然后返回 c。或者,如果它可以静态地证明 a 或 b 是非底部的,或者 c底部的,那么它根本不需要评估 a 或 b。一些优化确实利用了这一事实,但在实践中并不经常出现。

unsafeInterleaveIO相比之下,它对所有或任何这些变化都很敏感——它不依赖于某些功能的严格程度的语义属性,而是依赖于何时评估某物的操作属性。因此,所有上述转换对它都是可见的,这就是为什么只有unsafeInterleaveIO在感觉合适时或多或少地认为非确定性地执行其 IO 才是合理的。

从本质上讲,这unsafeInterleaveIO就是不安全的原因——它是正常使用中唯一可以检测到应该保持意义的转换的机制。这是您可以检测评估的唯一方法,按权利来说这应该是不可能的。

顺便说一句,在心理上预先unsafe考虑来自 的每个功能可能是公平的GHC.Prim,也可能还有其他几个GHC.模块。他们当然不是普通的 Haskell。

于 2012-11-09T21:56:11.987 回答