safe-exceptions の仕組みとその限界
この記事を書いている時点での最新バージョン:
- 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ハンドラで非同期例外を飲み込んでしまう
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
版はIO
をMonadThrow
に一般化していますが、これはそのまま置き換えて問題なく動きます。
着目する点は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
IO
がMonadCatch
に一般化されていますがやはり置き換えて動きます。
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
, fromException
はException
クラスのメソッドです。また、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で実装されていたので当初はその話を書こうと思っていたけど、現在はそうなっていなかったので実装に沿った話になりました。
-
一部の関数の取り扱いが変わっています(
throw
など) ↩︎ -
GHCバージョンと基本ライブラリバージョンは連動しています: https://gitlab.haskell.org/ghc/ghc/-/wikis/commentary/libraries/version-history ↩︎
Discussion