【问题标题】:When is unsafeInterleaveIO unsafe?unsafeInterleaveIO 什么时候不安全?
【发布时间】:2012-11-07 05:35:57
【问题描述】:

与其他 unsafe* 操作不同,the documentation for 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 的回答以及下面的 cmets,我简要浏览了 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 带有一些真正的含义(它没有),它看起来更像是一个 concurrent IO(或 interleaved IO,正如函数正确所说的)而不是一个 惰性 IO,正如我们通常所说的“惰性状态单子”,其中尽管有惰性,但状态通过关联操作正确地线程化。

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

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

【问题讨论】:

标签: haskell ghc lazy-evaluation


【解决方案1】:

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

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

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

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

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

【讨论】:

  • 我现在可以确认您完全正确。关键是,与状态之间存在函数关系的通常状态 monad 不同,IO monad 中的“状态”(表示为State# RealWorld)实际上是由 evaluation 线程化的,而不是由任何函数关系。
  • unsafeInterleaveIO (a &gt;&gt;= b) &gt;&gt;= c 确实保留了 a 和 b 之间的显式顺序,但不保留 b 和 c 之间的顺序。
【解决方案2】:

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

看看这个函数:

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

【讨论】:

  • 我认为解决方案是同时删除 unsafeInterleaveIO 并将它们移动到函数的开头,所以我们得到 goodLazyReadLines h = unsafeInterleaveIO $ do { l &lt;- hGetLine h; r &lt;- goodLazyReadLines h; return (l:r)}?这种遍历列表的方式也会强制列表中的每个元素。
【解决方案3】:

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

v1 = do !a <- x
        y

v2 = do !a <- unsafeInterleaveIO x
        y

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

一般

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

关于 Lazy IO 的现代观点

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

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

Lazy IO 的解决方案是在 IO monad 之外进行 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 会使其工作。

链接

来自 Haskell 邮件列表中的 Oleg Kiselyov 的 Lazy IO breaks purity

我们演示了惰性 IO 如何破坏引用透明性。一个纯粹的 Int-&gt;Int-&gt;Int 类型的函数给出不同的整数,具体取决于 根据对其论点的评估顺序。我们的 Haskell98 代码使用 只有标准输入。我们得出结论,颂扬纯洁 Haskell 和广告惰性 IO 不一致。

...

懒惰的 IO 不应该被认为是好的风格。常见的一种 纯度的定义是纯表达式应该计算为 无论评估顺序如何,结果都相同,或者等于可以 替换为等号。如果 Int 类型的表达式计算为 1,我们应该能够用 1 不改变结果和其他可观察的。

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

毕竟,还有什么比这更反对的 Haskell 的精神胜过具有可观察性的“纯”函数 效果。使用 Lazy IO,确实必须在正确性之间做出选择 和性能。这种代码的出现特别奇怪 在使用 Lazy IO 出现死锁的证据之后,出现在此列表中 不到一个月前。更不用说不可预测的资源使用和 依赖终结器来关闭文件(忘记 GHC 不 保证终结器将完全运行)。

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

【讨论】:

  • 怎么样? unsafeInterleaveIO x 的类型为 IO a,应该能够产生不同的结果(例如,评估的当前时间)。
  • “怎么会这样?”对不起,但请更具体,或使用引号。我不知道你在说什么。
  • 对不起,我的意思是这部分:“那么你必须证明 x 可以随时调用并且仍然产生相同的输出。”
  • 我在 90% 的代码中都使用了惰性 IO,而且效果很好。这完全取决于您的用例。
  • 我对惰性IO 的经验法则是想象每个惰性IO 函数都以unsafe 为前缀并相应地编程。
【解决方案4】:

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

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

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

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

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

顺便说一句,在 GHC.Prim 以及其他几个 GHC. 模块的每个函数前面加上 unsafe 可能是公平的。它们当然不是普通的 Haskell。

【讨论】:

  • 我知道我仍然是一个菜鸟和天真,但我不能抗拒这样说:那些“语义上”的东西只对一种纯粹的、引用透明的语言才有意义,其中 Church-Rosser成立,(GHC 的)Haskell 不成立。 unsafe* 是不安全的,只是因为它们泄露了这个小秘密,而不是它们的固有性质。他们为什么不将IO 实现为原语,将原语函数实现为IO a
  • @mnish:你认为什么是 Haskell 的一部分,什么不是,这是一个角度问题。我认为说 GHC 内部模块是实现细节而不是语言的一部分是合理的——在某种意义上它们是非 Haskell 代码,但使用 Haskell 语法编写并使用 Haskell 编译器编译。不管有什么小秘密,语义都是完全可以的:如果您将任何秘密泄露声明为在语言之外,那么在语言内部,语义仍然成立。
  • 但是@Ben,问题在于不安全性可能是病毒式的——整个演算的正确性可能会被下面的一些看似无辜的操作破坏。谁知道标准的“lazy IO”函数永远不会破坏 GHC 的操作语义?
  • 好吧...如果他们这样做了,那就是一个错误,就像如果 GHC 的语义实现的任何其他方面不正确,这将是一个错误。我不明白您将IO 实现为原语是什么意思-它原语,但它只是用“伪Haskell”编写的原语,所有GHC。*模块都是书面。 (哪些函数是“原始函数”?)
  • 我知道我晚了几年,但爆炸模式没有使用seq实现;事实上,反之亦然。 Bang 模式在 GHC Core 中被转换为 case 形式,而 Core 中的 case 总是严格的。例如,let !x = foo in bar 将被直接翻译成类似case foo of x {DEFAULT -&gt; bar}
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2013-01-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-04-13
相关资源
最近更新 更多