非同期I/O APIの設計がなかなか難しい

yuniで実用的なプログラムを書くためには、どうしても非同期I/Oライブラリが必要になる。というわけで黙々と設計しているけれど、これがなかなか難しい。

非同期I/Oライブラリの難しさ

  • そもそもOS/処理系毎に別物が必要

"非同期I/Oライブラリなんてlibuv一択だろ"という意見も有るかもしれないし、実際、Node.jsはlibuvのデザインの実用性を証明しつづけていると言える(実際には逆で、Node.jsのOS抽象化レイヤとしてlibuvが実装されている)。が、libuvはカーネル機能の抽象化でしかなく、同じデザインがyuniに適用できるとは限らない。yuniは既にKawa(Java上のScheme実装)やIronScheme(.net上のScheme実装)をターゲットしているので、これらでも動作するような配慮が必要になる。
もし、yuniの非同期I/Oライブラリを単なるlibuvのバインディングとするならば、I/Oを使ったスクリプトを実行するだけでFFIが必須になってしまう。libuvは大体のOSで動くし、libtuvのようなマイコン向けの派生( https://github.com/Samsung/libtuv )まで有るが、それなりのサイズのライブラリをhard dependencyにしてしまうのは気が引けるという問題もある。
yuniでは、バックエンドとなる非同期I/O処理系を"カルチャ"と呼び、単一のプログラム内で複数のカルチャを同時に使用できるように配慮することにした。

  • "カルチャ"毎に作法が異なる

この問題はlibuvで既に大体取り組まれているが、OSやライブラリによってイベントのトリガや処理順が大きく異なるため適当な抽象化が必要になる。
例えば、WindowsのIOCP(I/O Completion Port)ではユーザの発行した読み取りリクエストの完了が通知されてくるが、BSD/macOSのkqueueでは、"読み取り可能になった"というイベントが飛んでくるだけで、実際の読み取り要求はその後に発行する必要がある。
異なるカルチャ間で共通のプログラムを使い回すためには、慎重にAPIを設計するしかない。

非同期版が無い場合の配慮

重要なポイントは、非同期I/Oへの配慮が一切ないプラットフォームでも同じスクリプトを動かす必要がある点と言える。

例えば、SDLはスレッドやファイルI/Oを抽象化しているが、そのファイルI/O機能には一切の非同期I/Oインターフェースが用意されていない。(この問題はC++標準ライブラリや、そもそも言語標準にスレッドの概念が無いR6RS/R7RSでも同じことが言える。)
このようなケースではワーカースレッドとキューが使われるが、単純に作ると1ストリーム1スレッドを用意することになってしまう。実際には、同じデバイスで複数のI/Oを併走させても無駄なことが多いため、無駄にスレッドを消費することになる。
中間解としては、I/Oの性質に合わせてスレッドを分割する、つまり:

  • 読み取りの応答性が重要なオーディオは別のスレッドに分離する
  • 読み取りの応答性が比較的どうでも良い画像データはスレッドを1つに集約する

ことで、オーディオデータの読み取りは直ぐできるようにしつつ、必要最低限のスレッド数で対応することができる。(ただし、実際のゲームではもっと真面目にリクエストキューを実装していることが多い)
... このような配慮をどうやってAPIにすれば、他の非同期I/Oを実装したカルチャと共通のプログラムにすることができるだろうか。。
SDL2のように一切非同期I/Oが存在しない環境も有るが、例えばDNS参照は殆どのOSで非同期インターフェースが存在しないため、規模の大小は別にしてどこかでは絶対に必要になる。ちなみにlibuvではライブラリ自体にDNS参照機能を持たせてこの問題を回避している。また、libuvにはスレッドプール機能もあるためイザとなればそちらに逃げることはできる。

マイナーな機能をどうやって抽象化するのか問題

libuvにはファイルコピー機能がAPIとして存在する( https://github.com/libuv/libuv/issues/925 )。実は各OSはファイルコピーを行うAPIをOSレベルで持っていることがあり、専用の最適化も想定されている(例えば、ネットワークファイルシステムであればサーバ上でコピーを行う等)。当然、これらをAPIとして用意しておかないとOSの最適化の恩恵は受けられない。

例えばシリアルポートの入出力等はlibuvには無い。シリアルポートはたぶん有っても良いと思うけど、では電源イベントとかMIDIはどうか。。

SRFI-18がport入出力でブロックする問題

まぁ一旦Schemeにおけるスレッド実装の最大公約数と言えるSRFI-18( https://srfi.schemers.org/srfi-18/srfi-18.html )で試しに実装するかと思っていたが、どうもSRFI-18環境でSchemeのportとスレッドを共存させる方法が無いらしくどうしたもんか。。
例えば、chickenでは明示的に"I/Oによるブロックは全スレッドをブロックさせる"としている:

Blocking I/O will block all threads, except for some socket operations (see the section about the tcp unit). An exception is the read-eval-print loop on UNIX platforms: waiting for input will not block other threads, provided the current input port reads input from a console.

軽く調べてみたところ、SRFI-18を実装している処理系はネイティブスレッドを使用した実装と、いわゆるグリーンスレッド実装の2種類に分けられる:

portでブロックしても他のスレッドの実行を続けられるのはGambitと各ネイティブ実装だった。このため、仮に各処理系のグリーンスレッド実装を活用するにしても、Scheme portではなく処理系に固有のmultiplex手法を使う必要がある。
ちなみに、他の処理系は実装手法がまちまちで、

いずれにせよ、スレッドの無い処理系をサポートするためにはFFI経由での実装がどこかのタイミングでは必要になる。グリーンスレッド実装でFFI必須にするかは微妙なポイントと言える。

次の一手

とりあえず、ビルドトレース( http://d.hatena.ne.jp/mjt/20171204/p1 )で使用しているI/Oプリミティブ、つまり、

  • プロセスとpipe
  • ファイル
  • Socket

を基本セットとして、これらは可能な限り多くの処理系でSchemeオンリーで実装できるように配慮することにする。残念ながらプロセスの起動処理とDNS lookupはどうやってもblockingになるが、今回はblockの期間が上界されるというポイントで妥協することにする。。(DNSタイムアウトが起こるようなケースでは長くなってしまうが。。)
これらに用途を絞れば、スレッドをサポートしていない処理系でも、いわゆるselect()とnon-blocking I/Oさえサポートしていれば良いことになる。