新しいlifted-asyncの使い方
monad-controlが新しくなって、モナド変換子スタックの各層がもつ状態を表すStT
やStM
がassociated data typesからassociated type synonymsに変わった。この変更でmonad-controlに依存するたくさんのパッケージが壊れ、拙作lifted-asyncも漏れなく壊れた。修正がてらいろいろ改善を施したのでその話をしようと思う。
Control.Concurrent.Async.Lifted.Safeモジュール
v0.3.0から新たにControl.Concurrent.Async.Lifted.Safeモジュールが追加された。lifted-asyncをより安全に利用するため、既存・新規のコードにかかわらず使えるところではこちらのSafeモジュールを使い、適用できないところでのみ従来のControl.Concurrent.Async.Liftedを使うべきである。
lifted-asyncとモナドスタックのもつ副作用
そもそも以前のlifted-asyncには重大なデザイン上の欠陥があった。たとえば次のコード片は何を出力すべきだろうか。
import Control.Monad.State import Control.Concurrent.Async.Lifted main :: IO () main = do (r, s) <- flip runStateT 42 $ do a <- async $ modify (+1) >> get modify (+2) wait a print (r, s)
async
中のmodify (+1)
で状態が43になりget
で返ってくる値は43、その後さらにmodify (+2)
で状態は45になり(43, 45)
か?スレッド起動より先にmodify (+2)
が実行され状態が44に、その後modify (+1)
が実行され(45, 45)
か?いやそもそも同期なしに共有状態を書き換えるのはおかしい。ではどうするべきだったのか?
現在のlifted-asyncの実装では常に(43, 43)
が出力される。何故か?実装上の都合でwait
がasync
の中で起こったStateT
上の副作用をすべて親スレッド側で復帰しているからである。つまりmodify (+2)
は暗黙のうちに捨てられる。
どうしてこんな残念なことになっているかを理解するには実装を見てみる必要がある。async
とwait
の実装は次の通り。
async :: MonadBaseControl IO m => m a -> m (Async (StM m a)) async m = liftBaseWith $ \runInIO -> A.async (runInIO m) wait :: MonadBaseControl IO m => Async (StM m a) -> m a wait = liftBase . A.wait >=> restoreM
ここでA.
がついているものはasyncパッケージの関数を使っている。async
はモナドm
上の状態をStM m a
という型でAsyncの中に埋め込む。wait
は結果を取り出す過程でこの状態を現在のm
に復帰する。もし仮にasync
でm (Async (StM m a))
でなくm (Async a)
を返す、またはwait
でrestoreM
を使わずにa
を取り出すことができれば、子スレッド中のm
上の副作用はすべて無視するという戦略がとれるが、これをm
について多相的に実装する方法はないと思う。
Safeモジュール
問題の原点に立ち返ってみると、 並行処理を目的としたライブラリが状態を持つモナド上で無制限に利用可能なのがおかしい。状態を持たないモナド、すなわちStM m a ~ a
を満たすモナドだけで利用できるモジュールを作ろうというのがSafeモジュールのアイデアである。
Safeモジュールではasync
とwait
の型は次のようになっている。
async :: (MonadBaseControl IO m, StM m a ~ a) => m a -> m (Async a) wait :: (MonadBaseControl IO m, StM m a ~ a) => Async a -> m a
制約部にStM m a ~ a
が追加され、この制約からAsync (StM m a) ~ Async a
がいえるため、型シグネチャがasyncパッケージに近い直感的でわかりやすいものになった。実装は一部を除いてControl.Concurrent.Async.Liftedの関数をそのまま使っていて、単に型シグネチャだけ書き直している。
Safeモジュールでは先に示した副作用の問題は起こらない。なぜなら副作用が問題になるStateT
などはStM m a ~ a
を満たさないため、そもそもこのモジュールを利用できないからである。現実のコードでは並行処理が必要かつ共有状態を持つモナド変換子スタックはReaderT
にMVar
などを持たせているはずなのでこの制約は問題にならないことがほとんどではないかと思う。
Concurrently
の実装上の問題
当初Safeモジュールを実装したときにConcurrently
のApplicative
インスタンスを書くのは困難に見えた。理由はStM m a ~ a
という制約はm
が状態を持たないという制約であるだけでなくa
がStM m a
と等しくなるというようにa
に対して言及しているために、a
やb
に何の制約も持たない(<*>) :: Applicative f => f (a -> b) -> f a -> f b
と型が合わないように見えたためである。
この問題は@feuerbachさんがconstraintsパッケージを用いたすばらしい解決策を示してくれた。Concurrentlyの実装にはForall (Pure m)
という制約が出てくる。これは次の擬似コード相当のことをしていると考えられる。
data Concurrently m a where Concurrently :: (forall a. (StM m a ~ a)) => { runConcurrently :: m a } -> Concurrently m a instance (MonadBaseControl IO m, forall a. (StM m a ~ a)) => Applicative (Concurrently m) where ...
つまりここで必要なStM m a ~ a
という制約はm
に対するもので、a
はなんであってもよいはずである。しかし現在のGHCはuniversally quantifiedな制約はサポートしていないのでconstraints
パッケージを使っている。
universally quantified constraintsの使い道やconstraintsパッケージの実装の話はSafe, polykinded universally quantified constraintsというポストが詳しい。
Concurrently b m a
のb
ほかにもマイナーな変更を入れた。
当初lifted-asyncにConcurrenly
を移植した際にUndecidableInstancesを嫌ってb
という何もしない型変数を導入したが、これがConcurrently
の見た目を怖くしていたようで、どうやって使うのかと質問している例をいくつか見たことがあった。
改めて見直してみるとMonadBaseControl
のインスタンスで型検査をループさせることは難しそうであること、仮にそのようなインスタンスを書いてしまっても問題箇所の発見は難しくなさそうであることから、UndecidableInstancesを有効にして型変数b
をなくした。先のconstraintsパッケージの影響でまた別の意味で怖い型になってしまったが、型の読みやすさという点である一定の効果はあったのではないかと思う。
今後
今のところlifted-asyncに大きな変更を加える予定はなく、地道にGHCや依存パッケージの更新に合わせてマイナーアップデートをしていくことになると思う。Haddockコメントを改善したいとは考えているけど、あまりやる気が出ないのでPR歓迎。ともあれStackageにも入っているし少しずつ利用者も出てきたようなので、モナド変換子スタックでasyncを使いたい人は是非使ってみてほしい。