GHCのIOマネージャの歴史と僕の苦悩

これは、Haskell Advent Calendar 2021 の8日目の記事です。

Haskellのコンパイラとして事実上一択となったGHCには、「軽量スレッド」が実装されています。軽量スレッドは、ネイティブスレッドよりも軽量なスレッドで、他の言語では「グリーンスレッド」とも呼ばれています。Haskellerが並行プログラミングをするときは、軽量スレッドを息を吸うかのように使います。

複数の軽量スレッドの入出力を束ねるのが、IOマネージャです。IOマネージャも単なる軽量スレッドであり、OSから入出力のイベントを受け取り、それぞれの軽量スレッドにイベントを通知します。

軽量スレッド(っぽい)機能を提供する他の言語では、GHCのIOマネージャを参考にしているようです。僕はIOマネージャの開発に深く関わっています。この記事ではIOマネージャの歴史をまとめるとともに、主にmacOSでの実装に関する苦悩を備忘録として残します。

第1世代

  • è«–æ–‡ "Extending the Haskell Foreign Function Interface with Concurrency" で解説されています。
  • 2005å¹´3月にリリースされたGHC 6.4で導入されました。

pollシステムコールを使って実装されています。pollはイベントの登録と監視が一体となったシステムコールです。このため、ある軽量スレッドが新たに入出力の登録を依頼する場合は、ブロックされているIOマネージャを一旦起こさないといけません(pollをやめさせなければなりません)。

これを実現するための画期的なアイディアが、wakeupパイプです。IOマネージャは、監視対象としてwakeupパイプも指定して、pollを呼びます。軽量スレッドがwakeupパイプにバイト列を書き込めば、IOマネージャはpollから抜けることができ、新たなイベントを加えて再度pollを呼び出します。

pollシステムコールの弱点は以下の通りです。

  • 登録できるイベントの個数に制限がある。
  • 受け取ったイベントを線形探索する必要があるので、イベントの個数が多くなるとスケールしない。

第2世代

  • è«–æ–‡ "Scalable Event Handling for GHC" で解説されています。
  • 2010å¹´11月にリリースされたGHC 7.0で導入されました。

pollの問題点を克服するために、Linuxではepoll、BSDではkqueueを使うようになりました。epollやkqueueは、pollのスーパーセットという限定的な利用方法が用いられています。

第2世代のIOマネージャの欠点は、マルチコア環境で性能が出ないことです。この理由としては、グローバルロックが使われていたことなどが挙げられます。

第3世代

  • è«–æ–‡ "Mio: A High-Performance Multicore IO Manager for GHC" で解説されいます。
  • 2014å¹´4月にリリースされたGHC 7.8で導入されました。

マルチコア環境ででスケールさせるために、グローバルロックが分割されると共に、コアごとにIOマネージャが起動されることになりました。

また、デフォルトではwakeupパイプは利用されなくなりました。これが実現できるのは、epollやkqueueが、イベントの登録と監視を独立させているからです。たとえば、epollでは登録がepoll_ctlで、監視はepoll_waitを使います。IOマネージャがepoll_waitでブロックされていても、別のスレッドはepoll_ctlでイベントを登録できるのです。登録されたイベントは削除しないといけませんが、削除の手間を省くために、使われたら削除される「one shot」というモードが利用されています。

Windows

WindowsのIOマネージャに関しては詳しくないので、New Windows I/O manager in GHC 8.12を参照してください。GHC 8.12は、GHC 9.0に置き換えて読んでください。

嗚呼、macOS

第3世代のIOマネージャは、Andreas Voellmyさんがepoll版を開発し、僕がkqueueに移植しました。この移植後、macOS上でGHCの並列ビルドが失敗するようになるという問題が発生しました。BSDでは問題ないのに、macOSでは問題が生じます。この問題は結局解決できずに、macOS上ではone shotモードを諦めて、wakeupパイプを使い続けるという方法で回避されました。(FreeBSDなどでは、one shotモードが使われています。)

その後、kqueueのIOマネージャに書き込みイベントの登録が失敗するというバグが発見されます。epoll_ctlの EPOLLIN や EPOLLOUT はフラグ(ビットマスク)ですが、kqueue の EVFILT_READ と EVFILT_WRITE はフラグではありません。kqueueで読み書き両方を登録するには、読み込みイベントと書き込みイベントの2つを登録する必要があります。移植の際に、この事実に気づかずバグを入れ込んでいました。one shot用のコードでも、wakeupパイプ用のコードでも、このバグは直されました。GHC 8.4から恩恵にあずかれます。

これでmacOSの並列ビルドの問題も解決したと勘違いして、macOSでone shotモードを使おうという提案をしました。しかし、macOSでone shotを使うようになったGHC 9.0.1をmacOSで使ってみると、ネットワーク関係のライブラリでテストが通らなくなっていました。

結局、macOSのkqueueには、one shotにバグがあるというのが僕の結論です。GHC 9.0.2では、再びwakeupパイプが利用されるようになります。