これまで説明してきたGHCの並列化機能は,いずれもスレッドやプロセスのような並行実行の仕組みを抽象化の内側に隠ぺいしたものでした。しかし,このようにユーザーの目から詳細を隠してくれる特徴が,かえってわずらわしく思えることもあります。

 GHCの並列化機能は,「ある一つの計算の処理速度を向上させる」という目的を直接的に反映したものです。このため,処理速度の向上以外の部分には,十分に手が届かないこともあります。例えば,グラフィカル・ユーザー・インタフェース(GUI)などを実現するための対話的なプログラムでは,「ある一つの計算の速度を向上させる」だけでは,プログラム全体の反応速度を思うように上げられません。プログラムをうまく制御して全体での速度向上を目指すことが重要だからです。しかし,これまで説明してきた並列化機能には,こうした制御を実現するのに必要なセマンティックス(意味論)は特に規定されていません。

 この問題を解決するには,二通りの方法があります。一つは,並列化機能に対して制御のためのセマンティックスを追加で持たせることです。もう一つは,「あるプログラムの反応速度を向上させる」ことを目的とする並行/並列プログラミングのための機能を別途用意し,二つの並列化機能が役割を分担する方法です。GHCはもともと「並行Haskell(Concurrent Haskell)」という並行プログラミングのための機能を備えていました。並行Haskellでは並列プログラミングも可能です。このため,現在では後者の解決策が採られています。

 そこで今回からは,並行Haskellについて説明していきます。並行HaskellはGHCで利用できますが,他のいくつかの処理系でも機能の一部がすでに実装されています(参考リンク1参考リンク2)。また,次期標準であるHaskell'でも追加が予定されています(参考リンク1参考リンク2)。

並行プログラミングの必要性

 単純に「ある一つの計算の処理速度を向上させる」のではうまくいかない場合について,もう少し詳しく見てみましょう。

 GUIなどの対話的なプログラムは,ボタンのクリックや画面の再描画などのイベントが生じたとき,それに対する反応として処理を行う「イベント駆動方式(event-driven style)」で扱うのが一般的です。しかし,イベントに対して実行される処理がとても重いものだったらどうでしょうか? あるいは,処理のボトルネックが計算速度とは別のところ,例えば通信などにあるとしたら? プログラム全体の動作が重くなったり,実行している処理が終わるまで何も反応を返さない,いわゆるビジー状態(busy state)になってしまいます。並列化により,単純に「ある一つの計算の処理速度を向上させる」だけでは,全体の処理の負荷を軽減することはできません。

 このような場合には,第7回で説明した遅延I/Oのように「処理を本当に必要になるまで遅らせたり,処理を少しずつ実行させることで負荷を大きく見せない」といった制御の工夫を行ったほうが,単純な並列化を行うよりもプログラムの反応速度の向上に貢献するでしょう。しかし,このやり方にも問題があります。処理をある単位ごとに区切ってしまうと,全体の処理のストリーム,あるいは,計算を続行するための情報(継続,continuation)を意識してプログラミングを行わなければならなくなってしまうのです。

 こうした「プログラムの反応速度と複雑さのトレードオフの問題」に対処するための解決策はいくつかあります。中でも一般的なのが,プロセスやスレッドといった仕組みです。プロセスやスレッドを利用することで,制御上の工夫を背後に隠し,あたかも複数のI/Oを並行に動作させているかのようにプログラミングを行うことができます。並行Haskellでも,スレッドと呼ばれる仕組みを利用しています。一方,プロセスは第5回で少しだけ触れたSystem.Processモジュールを使って利用できます。

 プロセスとスレッドは何が違うのでしょうか? 簡単にいうと,プロセスでは個々のプログラムがそれ自身の中で完結しているのに対し,スレッドは複数のプログラム(や計算資源)の間でデータを共有するという違いがあります。一般に,プロセスではメモリー内容を共有せずにメッセージ通信(message passing)の形でデータをやり取りします。このため,スレッドに比べて動作が遅くなりがちです(参考リンク)。これに対し,スレッドでは複数のI/Oがメモリーを共有するので,データに対して高速にアクセスできます。反面,複数のI/Oによって非同期(asynchronous)にメモリー内容が書き換えられてしまうため,このことがバグの温床になります。

 スレッドやプロセスが複数タスクを実現するための仕組みとしては,「同時に実行する処理をスケジューラが管理し,適宜切り替えていく」という方式が現在では一般的です。実行している処理に割り込む形でタスクを切り替えるので,このような形のマルチタスクを「割り込み型(preemptive,プリエンプティブ)マルチタスク」と呼びます。これに対して,スケジューラではなく実行する処理の側で明示的にタスクを切り替える形のマルチタスクを,「非割り込み型(non-preemptive,ノンプリエンプティブ)マルチタスク」あるいは「協調的(co-operative)マルチタスク」と呼びます。ただし,切り替えを完全に実行する処理の側に任せてしまうと,タスクの開放し忘れ等の問題が生じる可能性があります。そこで実際には,処理が最後まで実行されると暗黙のうちにタスクが切り替えられるようにスケジューラをデザインするのが一般的です。

 協調的マルチタスクには,うまくプログラミングすればI/O処理の非決定性を排除できるという利点があります。スケジューラの側で勝手に現在のタスクを切り替える割り込み型マルチタスクとは異なり,共有メモリーの書き換えの競合状態(race condition)が起こるのを防げるためです。しかし,現在の多くのOSは割り込み型マルチタスクを採用しているため,言語処理系が提供するような汎用的な協調型スレッドは,OSのネイティブ・スレッドを利用するようには実装されていません。この結果,協調的スレッドは,プログラムを並列化させにくいという欠点を抱えています。

 今のところ,並行Haskellがどちらのモデルを採用するかは,言語処理系での実装に依存しています。Hugsでは,I/Oアクションのyieldを呼び出すことで他のスレッドに実行を譲るという協調的スレッドを採用しています。一方,GHCやHaskell'では割り込み型スレッドを採用しています。

 読者の中には,並列化しづらくHugsでしか利用されていない協調的スレッドのことを知っていても仕方がない,と考える人がいるかもしれません。しかし,そうではありません。スレッド処理を,実行時システムのスケジューラによって駆動される組み込み(primitive)のI/Oアクションではなく,より低レベルの組み込み操作によって実現されるライブラリとして提供するなら,上述したような協調型スレッドの問題点を回避できます。

 例えば,外部インタフェースの協調的スレッドにはI/O処理の予約だけを担当させ,実際の実行は自作のスケジューラによって行うようにすることで,協調的スレッドと内部的な並列化という一見矛盾しそうな二者の両立が可能になります(もちろん,ネイティブ・スレッドを扱うための操作を,別途組み込みのものとして用意しておく必要がありますが)。

 また,それぞれのスレッドのスケジューラが階層構造を持つように定義されていれば,特に使用上の規範を設けなくても,既存の並行Haskellのスレッドと新たに作ったスレッドを同時に利用できます。もともと,協調的スレッドと割り込み型スレッドという異なるスレッド・モデルを併用するのが難しいのは,それぞれのスレッドを同一のレベルで動作させようとするためです。協調的スレッドのはずなのに,別の割り込み型スレッドによって割り込み処理が行われるような状況は,誰も望まないでしょう。しかし,もしそれぞれのスレッドを動かすスケジューラ間に階層構造のある,いわゆる「階層化スケジューリング(hierarchical scheduling)」を行っているならば,そのようなスレッド・モデル間の処理の干渉を防げます。他方のスレッドを司るスケジューラが分割したプロセサ処理時間を,もう一方のスレッドを司る子スケジューラがさらに分割してスレッドを動かす,という形でそれぞれのスレッドが別の単位で動作することになるからです。例えば,仮想マシン(VM:Virtual Machine)上で動くプログラムのユーザー・レベル・スレッドを考えてみてください。仮想マシンを動かすプロセス,仮想マシン上での起動OSの制御,OS上でのプロセス,プロセス内部のプログラムのユーザー・レベル・スレッド,これらはそれぞれ別のモデルを持ち得ますが,それぞれの動作する層が異なるため,互いが干渉し合うことはありません。

 こうした「内部と外部,あるいは階層といった層によってスレッド・モデルを分割するというアイデア」に基づき,GHCのスレッドをより低レベルの組み込み操作によって作成されたライブラリ(Concurrency as a library)として再実装する,という計画が進められています((参考リンク1参考リンク2)。

 この新たなスレッド・ライブラリは,まだGHCの開発版にも取り込まれていません。そのため詳しい説明は省きますが,将来的には協調的スレッドも利用できるようになることを覚えておいて損はないでしょう(もちろん,現在の並行Haskellのような充実した機能を必要としないなら,今でも十分に効率の良いスレッドを自作することはできます,参考リンク1参考リンク2)。