Skip to content

Instantly share code, notes, and snippets.

@poscat0x04
Last active May 21, 2020 09:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save poscat0x04/afc28fc9dde8e6a4277e4a9d8dcab422 to your computer and use it in GitHub Desktop.
Save poscat0x04/afc28fc9dde8e6a4277e4a9d8dcab422 to your computer and use it in GitHub Desktop.

Modern haskell - Concurrency (2)

前言

上篇文章简单的介绍了一下 Haskell (GHC) 提供的 user-level concurrency primitives, 这篇将介绍 Haskell 的异常与资源管理。本文默认读者对其他语言中的异常有一定了解。

异常

众所周知,处理异常是非常 impure 的(这里将 purity 定义为 confluence modulo exceptions and non-terminations), 而且 haskell 已经有处理异常的方式了: EitherExceptT, 为什么还需要加入 exception 呢?

原因大概有三个个, 性能,mtl 的 fundep 与异步异常 (asynchronous exceptions), 我们先讨论一下性能。不像 Rust, 由于 Laziness, haskell 的 ADT 默认是 packed, 即储存的是指向 thunk 的指针而不是直接储存一个值 (虽然有 UNPACK pragma 可以 unpack), Either 也是这样。Packed 的坏处就是每次读取数据时都有一定的开销,由于这些开销 prelude 中可能出错的函数 (比如 readFile) 基本都没有使用 Either

另外一个原因是 mtl (haskell 最基础的 effect system) 不方便处理多个 Exception 同时出现的情况。

class Monad m => MonadError e m | m -> e where
  throwError :: e -> m a
  catchError :: m a -> (e -> m a) -> m a

这是 MonadError 的定义,注意到 me 之间的 functional dependency -- 每个 m 只能有一个对应的 e

异常作为一种高性能的 control structure 自然成为了 haskell 中异常处理的首选(其实如果你不太关心性能的话完全可以使用 EitherExceptT)。

同步异常

不像其他语言的 try keyword,haskell 中抛出异常完全是通过函数完成的 (tbh you really shouldn't be suprised...)。抛出异常本身是 pure 的,因为异常实际上与无限循环一样糟糕,都是一种 partiality effect,在 haskell 中是无法完全避免的(由于 turing-completness) 。抛出的同步异常有两种 1. precise exceptions 2. imprecise exceptions

imprecise exceptions

imprecise exceptions 是通过对 undefinederror 求值而产生的 exception。(这里忽略了 levity polymorphism, 忽略了 constraint, 简化了类型签名)

undefined ::  a. a
error ::  a. String -> a

undefined 用于临时填补未被实现的部分,error 则能立即抛出一个带有额外异常信息的异常。

注意使用这两个函数表示抛出异常是非常不推荐的。undefined 基本可被 typed holes 替代,error 也只应该在错误的原因为库的设计者时被使用。正常的异常请使用 precise excetpions。

precise exceptions

抛出 precise exception 一般通过两个函数来实现(同样简化了签名):

throw ::  e a. (Exception e) => e -> a
throwIO ::  e a. (Exception e) => e -> IO a

注意到 throwthrowIO 都要求一个实现了 Exception 的参数,Prelude 中有一些预定义的 ExceptionIOExceptionArithException. 你也可以定义自己的 Exception:

data MyException
  = Error1
  | Error2
  deriving (Show)

instance Exception MyException

throwthrowIO 的区别在于 throw 只在被 eval 的时候才会抛出异常,而 throwIO 会立即 (eagerly) 地抛出异常 (相当于 evaluate . throw)。

处理同步异常

同步异常的处理也是通过两个函数来实现的, trycatch:

try ::  e a. (Exception e) => IO a -> IO (Either e a)
catch ::  e a. (Exception e) => IO a -> (e -> IO a) -> IO a

catch 会先执行 IO 操作,如果它抛出了一个类型为 e 的异常,则会把这个异常传给 handler 并执行它。如果 IO 操作抛出了不是 e 的异常,这个异常则会被传递下去。trycatch 类似,只不过抛出的异常会被立即抓住并放到 Either 中。

抓住所有异常

显然在释放资源的时候我们希望能抓住所有异常,这可以通过 SomeException 来实现。SomeException 的定义如下:

data SomeException = ∀ e. (Exception e) => SomeException e

注意这里的 e 是被 existentially quantified 了的。Haskell 中的异常形成了一个谱系 (hierarchy),异常之间有 subtyping 关系,只不过 subtyping 是动态的实现,这也是为什么 Exception 会有 Typeable 的要求。SomeException 是所有异常的超类型 (supertype),因此只要抓住 SomeException 就能抓到所有的异常。抓住 SomeException 并忽略它是非常不好的行为,一般会在释放资源后重新抛出这个异常。

doSomething' = doSomething `catch` \(SomeException e) -> do
  logException e
  releaseResource
  throwIO e       -- rethrow the exception

异步异常

异步异常,顾名思义,就是异步的异常。异步异常来源有很多,从用户按下 Ctrl+C (UserInterrupt) 到栈溢出 (StackOverflow) 再到杀死线程 (ThreadKilled)。Haskell 提供了能在任何时候把一个异常从一个线程扔到另一个线程中的 throwTo:

throwTo ::  e. (Exception e) => ThreadId -> e -> IO ()

当异常被传播到某个线程最外层时这个线程就会被杀死。killThread 就被定义为 flip throwTo ThreadKilled

异步异常可能在执行到任何语句时发生,如果在正在更新某些状态时发生则很有可能导致数据不一致。比如如下这个 update 函数试图读取 MVar 中储存的值并将其传给一个 operation 并把这个 operation 的结果放回 MVar 中, 若 operation 抛出了异常则将这个值原样放回 MVar 中并把这个异常传播下去, 不管怎样,这个 MVar 最终都不会空着。

update :: MVar a -> (a -> IO a) -> IO ()
update mv op = do
  a <- takeMVar m                             -- 1
  r <- op a `catch` \(SomeException e) -> do  -- 2
    putMVar m a
    throwIO e
  putMVar m r                                 -- 3

若异常出现在执行语句1与语句2之间或语句2或语句3之间,则不会有值被填入这个 MVar 中。

因此我们需要有能暂时忽略异常的能力。

Masking Exceptions

Haskell 提供了 mask 函数用于暂时忽略异常 (masking):

mask ::  b. (( a. IO a -> IO a) -> IO b) -> IO b

这个高阶多态的 continuation 看起来似乎很吓人,但是实际上很好理解。∀ a. IO a -> IO a 是一个通常被称为 restore 的函数,IO b 中被 restore 包住的部分会受到异常的影响,而其他部分则不会。

update :: MVar a -> (a -> IO a) -> IO ()
update mv op = mask $ \restore -> do
  a <- takeMVar m
  r <- restore (op a) `catch` \(SomeException e) -> do
    putMVar m a
    throwIO e
  putMVar m r

这个是使用 mask 后的在异步异常下安全 (Asynchronous Exception Safe) 的 update。异步异常只能在在 op a 中出现。

bracket pattern

Haskell 中 Exception Safe 的资源管理通常使用一个叫做 bracket 的函数:

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket before after thing =
  mask $ \restore -> do
    a <- before
    r <- restore (thing a) `onException` after a
    _ <- after a
    return r

bracket 的第一个参数为一个分配资源的函数, 第二个参数为回收资源的函数,第三个函数为使用这个资源的操作。bracket 可保证无论是否有异常发生都能安全的分配与回收资源。

总结

Haskell 灵活的异常机制给我们带来了很多好处:

  1. 异步异常是简单符合直觉的抽象
  2. 任何计算可以在任何事件被打断 (with the exception of FFI calls)
  3. mask 保证了线程可以在清理资源后再死去
  4. 高性能,在没有异常出现时不会有 overhead

当然这种错误处理机制也有它的不足之处:

  1. 一个函数可能抛出的异常不会表现在类型上
  2. 异常处理与 IO 绑定过于紧密

这些不足在最近开发出的 Effect Systems 中得到了有效的解决,不过那就是另一篇文章的话题了。

References

  • parallel and concurrent programming in haskell - Simon Marlow
  • A semantics for imprecise exceptions - S.P. Jones et al.
  • An Extensible Dynamically-Typed Hierarchy of Exceptions - S. Marlow
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment