並行処理の存在は,プログラムやそれを記述するための言語仕様に対して,良くも悪くも影響を及ぼします。FFIも例外ではありません。FFIの仕様やFFIを使ったプログラムを並行処理しようとする場合,きちんと考慮しなければうまく動作しない可能性があります。
第12回で説明したように,GHCの拡張機能や次期標準Haskell'で追加される新機能という形で,Haskellにもスレッドを使用した並行処理の仕組みが存在します。今や,実用的なプログラムを作るうえで,FFIとスレッドとの関連を避けて通ることはできません。
FFIをスレッドに対応させるための追加仕様は,「Extending the Haskell Foreign Function Interface with Concurrency」という論文,およびそれを要約した「The Concurrent Haskell Foreign Function Interface 1.0: An Addendum to the Haskell FFI 98 Report」という文書の草案としてまとまっています。しかし,この文書は,GHCがSMPに対応する前,Haskell'について議論が始まるより前のGHCの実装を元に書かれているため,記述が古くなっている部分があります。そこで今回は,Haskell'のConcurrencyの章の草案や2008年9月現在のGHCの実装を基に説明します。
FFIとスレッドを併用する際の注意点
FFIとスレッドを組み合わせるうえで何が問題になるのでしょうか?
スレッドを使ったコードでFFIを使用する場合,Haskellの内部と外部での動作のすり合わせが問題になります。FFIを使って呼び出した関数の動作がブロックされても,その関数を呼び出していない他のスレッドは止まらずに動作し続けなければなりません。これを「公平性の保証」(fairness guarantee)といいます。Haskell'およびGHCでは,こうした保証を行うようFFIの仕様を拡張しています。
実例を見てみましょう。以下のコードは,FFIによる公平性の保証を試すためのものです。
thread.h(Cのヘッダ)
#include "windows.h" void init (); void blockedFunc (); void unblock ();
thread.c(Cのコード)
#include "thread.h" static HANDLE hEvent; void init () { hEvent = CreateEvent(NULL,TRUE,FALSE,NULL); }; void blockedFunc () { WaitForSingleObject(hEvent,INFINITE); }; void unblock () { SetEvent(hEvent); };
Concurrent.hs(Haskellのコード)
{-# INCLUDE "thread.h" #-} {-# LANGUAGE ForeignFunctionInterface #-} module Main where import Control.Concurrent import Control.Concurrent.STM main = do m <- newEmptyTMVarIO c_init tid <- forkIO $ do c_blockedFunc tid' <- myThreadId print $ "Now, " ++ show tid' ++ " works." atomically $ putTMVar m () forkIO $ do print $ "Unblock " ++ show tid ++ "." c_unblock atomically $ takeTMVar m return () foreign import ccall "init" c_init :: IO () foreign import ccall "blockedFunc" c_blockedFunc :: IO () foreign import ccall "unblock" c_unblock :: IO ()
C側のコードでWindows APIを呼び出しています。stdcallを使わずにCの関数に包んでccallを使っているのは,Windows APIを説明する手間を省くためで,他意はありません。initは必要な初期化を行う関数,blockedFuncがブロックされている関数,unblockがblockedFuncへのブロックを解除するための関数です。
次にHaskell側のコードを見てみましょう。ブロックされた処理であるc_blockedFuncが呼ばれる前にプログラムが終了してしまわないよう,takeTMVarを使ってメイン・スレッドにロックをかけています。c_unblockが呼ばれたら,c_blockedFuncに対するブロックは解除されます。つまり,このプログラムはメイン・スレッドに対するロックを解除して終了します。
途中にあるmyThreadIdは実行中のスレッドIDを返すもので,forkIOの返すスレッドIDと同様に,動作状況を示すメッセージの作成に使います。
Prelude Control.Concurrent> :t myThreadId myThreadId :: IO ThreadId Prelude Control.Concurrent> :i ThreadId data ThreadId = GHC.Conc.ThreadId GHC.Prim.ThreadId# -- Defined in GHC.Conc instance Eq ThreadId -- Defined in GHC.Conc instance Ord ThreadId -- Defined in GHC.Conc instance Show ThreadId -- Defined in GHC.Conc
実行結果は以下の通りです。
$ ghc --make Concurrent.hs thread.c -threaded [1 of 1] Compiling Main ( Concurrent.hs, Concurrent.o ) Linking Concurrent.exe ... $ ./Concurrent "Unblock ThreadId 4." "Now, ThreadId 4 works."
c_blockedFuncによって処理がブロックされていても,c_unblockを呼び出すスレッドは動作しているのがわかります。
これがFFIによる公平性の保証の結果であるかどうかを確かめるために,FFIを使った外部関数の呼び出しからこの保証を取り除いてみましょう。foreign宣言では,関数呼び出しに対しunsafeという修飾を付けることで,こうした余計なコードの生成を防ぐことができます。ちなみに,unsafe呼び出しではコールバック関数をうまく扱うための保証も一緒に取り除かれてしまいます。このため,Haskell'では公平性保証だけ取り除くnonconcurrentという修飾子が別途提供される予定です(参考リンク1,参考リンク2)。
実際にunsafe付きで実行してみましょう。
~ 略 ~ foreign import ccall unsafe "init" c_init :: IO () foreign import ccall unsafe "blockedFunc" c_blockedFunc :: IO () foreign import ccall unsafe "unblock" c_unblock :: IO ()
公平性の保証が取り除かれた結果,c_blockedFuncで処理が止まるのがわかります。
$ ghc --make Concurrent.hs thread.c -threaded [1 of 1] Compiling Main ( Concurrent.hs, Concurrent.o ) Linking Concurrent.exe ... $ ./Concurrent
GHCの「マルチスレッド版実行時システム」を使わない場合も,公平性の保証がないので同じ結果になります。
$ ghc --make Concurrent.hs thread.c Linking Concurrent.exe ... $ ./Concurrent
なお,修飾を付けない呼び出しは,safe呼び出しと同じ意味になります。
~ 略 ~ foreign import ccall safe "init" c_init :: IO () foreign import ccall safe "blockedFunc" c_blockedFunc :: IO () foreign import ccall safe "unblock" c_unblock :: IO ()
$ ghc --make Concurrent.hs thread.c -threaded [1 of 1] Compiling Main ( Concurrent.hs, Concurrent.o ) Linking Concurrent.exe ... $ ./Concurrent "Unblock ThreadId 4." "Now, ThreadId 4 works."
FFIを使ってHaskellの関数や関数ポインタを外部に公開する場合,Haskellの外部からHaskell関数をスレッドを使って並行に呼び出すのなら,呼び出される側のHaskellの関数も並行に動作しなければなりません。そうでなければ,Haskellの関数を利用するプログラムが並行に動作しなくなってしいます。これを「外部使用時の並行性の保証」といいます。こうした保証は大切ですが,Haskell関数を他の言語から利用する場合にのみ必要になるものなので,今回は説明を省略します。