🛡️

safe-exceptions の仕組みとその限界

2022/12/01に公開約6,100字

この記事を書いている時点での最新バージョン:

  • ghc-9.4.3
  • safe-exceptions-0.1.7.3

safe-exceptionsパッケージについて書かれている記事はいくつかありますが、その仕組みや限界について書かれている日本語記事はない様なので書いてみます。

safe-exceptionsの動機

Haskellでは例外のためのsyntaxは存在していません。例外用の特別なprimitive operations(raise#, catch#等)が存在し、そのwrapper関数がbaseパッケージ(Control.Exceptionモジュール)に用意されているため、ユーザーはそれを用います。

しかしこのControl.Exceptionには問題がありました。同期例外と非同期例外の区別がなされていないことです。
同期例外とは実行しているスレッドでthrowされ、そのスレッドのコールスタックを駆け上がり、必要であればcatchされてプログラム処理を復帰させることが出来るといった仕組みです。この仕組みは他の言語でも広く採用されています。
非同期例外とは実行しているスレッドとは別のスレッドから投げられる例外のことです。基本的に他スレッドを止めるために用います。

この同期例外と非同期例外の区別がなされていないということはどういうことでしょうか。
この区別がないために、開発者がついやってしまう間違いとしては以下の様なものがあります:

  • catch系関数で非同期例外を補足して飲み込んでしまう
  • 非同期例外を間違って同期例外として投げてしまう
  • 同期例外を間違って非同期例外として投げてしまう
  • onException系のcleanupハンドラで非同期例外を飲み込んでしまう

https://hackage.haskell.org/package/safe-exceptions-0.1.7.3#goals

GHCの例外の詳しい挙動について知りたい時はHaskellによる並列・並行プログラミング(オライリー社)がお勧めです。並行プログラミングでは非同期例外の挙動についての理解が必須であり、この本には例外や例外ハンドラ、マスクの挙動について詳しく書かれています。

safe-exceptionsの方針

safe-exceptionsの方針は単純で、Control.Exceptionで提供している関数と同じインターフェースで同期例外と非同期例外を区別するバージョンを提供する、というものです。
単純とは言えこの方針は例外の仕組みが単なる関数で提供されていることと、型クラスによって型の形を一般化出来るために可能となっているため、Haskellならではの手法かもしれません。

ユーザーはimport Control.Exceptionと書いてあるところを機械的にimport Control.Exception.Safeと書き換えるだけで大体[1]うまく動いてくれます。よって自分のコード内のimportを全て置き換えさえすれば、この同期例外非同期例外の区別がつかない問題はまあ大体は...解決します。残念ながら完璧ではありません。この記事ではその限界がどこにあるかを探っていきます。一般に、ライブラリ/フレームワークが解決する問題の限界を見極めておくことは重要ですから。

Control.ExceptionとControl.Exception.Safeの比較

例外に関係する主な登場人物はおおよそ4種類います。

  • 例外を投げる系関数(例えばthrowIO)
  • 例外を補足する系関数(例えばcatch)
  • 非同期例外をマスクする系関数(例えばmask)
  • 例外の型(Exceptionクラスのインスタンス)

safe-exceptionsではこのうち例外を投げる系関数(throwIO等)と補足する系関数(catch等)について、同じインターフェースで同期例外と非同期例外を区別するものを提供しています。
例えば同期例外を投げる関数throwIOを比較してみます。

-- Control.Exception
throwIO :: Exception e => e -> IO a
throwIO = ... -- 実装は略

-- Control.Exception.Safe
throwIO :: (C.MonadThrow m, Exception e) => e -> m a
throwIO = C.throwM . toSyncException

Control.Exception.Safe版はIOMonadThrowに一般化していますが、これはそのまま置き換えて問題なく動きます。
着目する点はtoSyncExceptionで例外を同期例外に変換する処理を挟んでいる点です。 
toSyncExceptionでは非同期例外の場合は同期例外(SyncExceptionWrapper)でwrapするといった処理をしています:

-- Control.Exception.Safe
toSyncException :: Exception e => e -> SomeException
toSyncException e =
    case fromException se of
        Just (SomeAsyncException _) -> toException (SyncExceptionWrapper e)
        Nothing -> se
  where
    se = toException e

throwIOは非同期例外を投げる目的では使わないため、非同期例外(として一般に扱われる例外)を引数に取ったとしても同期例外(として区別される型)として扱うということです。

同様に非同期例外を投げる関数throwToを比較します。

-- GHC.Conc.Sync
throwTo :: Exception e => ThreadId -> e -> IO ()
throwTo = ... -- 実装は略

-- Control.Exception.Safe
throwTo :: (Exception e, MonadIO m) => ThreadId -> e -> m ()
throwTo tid = liftIO . E.throwTo tid . toAsyncException

こちらも型はMonadIOに一般化してますがSafe版にそのまま置き換えることが可能です。
また、Control.Exception.Safe版ではtoAsyncException関数を挟んで非同期例外に明示的に変換をしています。
throwToもまた、同期例外(として一般に扱われる例外)を引数に取ったとしても、強制的に非同期例外(として区別される型)に変換します。

今度は補足する系関数catchを比較します。

-- GHC.IO
catch   :: Exception e
        => IO a         -- ^ The computation to run
        -> (e -> IO a)  -- ^ Handler to invoke if an exception is raised
        -> IO a
catch = ... -- 実装は略

-- Control.Exception.Safe
catch :: (C.MonadCatch m, Exception e) => m a -> (e -> m a) -> m a
catch f g = f `C.catch` \e ->
    if isSyncException e
        then g e
        -- intentionally rethrowing an async exception synchronously,
        -- since we want to preserve async behavior
        else C.throwM e

IOMonadCatchに一般化されていますがやはり置き換えて動きます。
Safe版は対象の型の例外をとりあえずcatchしてから、isSyncExceptionによって同期例外か非同期例外かを判定し、もし非同期例外ならばthrowMで投げ直しています。
このSafe版catchを使うことによって非同期例外の場合を間違えて補足することが無くなるというわけです。

safe-exceptionsの限界

ここまでの説明を聞くと人によっては心がざわざわして「自分のコードだけじゃなくて、ライブラリを含めて全ての例外系関数をsafe-exceptionsの関数throw系とcatch系に変換しないと意味ないじゃん?」という疑問が湧いてくるかもしれません。そのあたりを確認していきましょう。

結論を言うとおおよそ問題ないはずです。つまりsafe-exceptionsライブラリは、それを使っていないライブラリの例外を正しく扱えます。おおよそ。
鍵になるのは判定関数isSyncExceptionです:

-- Control.Exception.Safe
isSyncException :: Exception e => e -> Bool
isSyncException e =
    case fromException (toException e) of
        Just (SomeAsyncException _) -> False
        Nothing -> True

この関数のtoException, fromExceptionExceptionクラスのメソッドです。また、SomeAsyncExceptionはbase-4.7.0.0、つまりGHC-7.8.1[2]からControl.Exceptionで提供されています。
つまりisSyncException関数はsafe-exceptionsに依存しない関数であり、現在Control.Exceptionで提供されている例外においても正しく動く様になっています。

GHC-7.8.1(Apr 2014)は十分に古いバージョンなので、それ以前のことは恐らく考えなくても良いでしょう。

しかし同期非同期の判定が正しく出来るとは言え、非同期例外をthrowIOしたり、同期例外をthrowToすることは出来てしまいます。その様な行儀が悪いコードがもしライブラリに含まれていたならば問題となるでしょう。もしくはライブラリ内部でスレッドを生成して、そのスレッドが非同期例外を考慮せずにcatchしていたりすると、そのスレッドは非同期例外を飲み込むかもしれません。
そこがsafe-exceptionsの限界です。

言い換えれば、使用するライブラリが非同期例外についてきちんと理解して書かれているコードであるならば、safe-exceptionsパッケージを使っていなくてもsafe-exceptionsのコードは正しく動作するということです。

ユーザー定義例外

開発者が作った例外はisSyncExceptionでどう判定されるでしょうか。もし何も知らずにExceptionインスタンスを実装すればそのデフォルト実装によりisSyncExceptionはTrueを返します。つまり同期例外と判定されます。
同様にユーザー定義非同期例外も自分で作ることが出来ます。その際には上記isSyncExceptionが正しく動く様に実装する必要があるでしょう。そうでなければやはり問題が起こり得ます。非同期例外を作るためのヘルパー関数が用意されているのでそれを使うのがいいでしょう。

-- GHC.IO.Exception
instance Exception AsyncException where
  toException = asyncExceptionToException
  fromException = asyncExceptionFromException

余談

safe-exceptionsは昔Overlapping Instancesで実装されていたので当初はその話を書こうと思っていたけど、現在はそうなっていなかったので実装に沿った話になりました。

脚注
  1. 一部の関数の取り扱いが変わっています(throwなど) ↩︎

  2. GHCバージョンと基本ライブラリバージョンは連動しています: https://gitlab.haskell.org/ghc/ghc/-/wikis/commentary/libraries/version-history ↩︎

Discussion

ログインするとコメントできます