DirectSoundとRubyのプログラミング その4

なんとなく続き。どこまで続くのか俺自身にもわからない。

ポーリングとイベント

現状では適当なタイミングでバッファをチェックして、空いた部分に生成したデータを詰め込む、という動作をさせている。これをポーリングと言うが、DXRubyで使う場合は1フレームに1回という感じで処理をすることになるので、最低でも1フレーム分のバッファは確保しておかなければならない。しかし1フレームでメインの処理が終わらないことも考えられるし、バッファ生成時間も無視できないしで、2フレーム分か、余裕を見れば3フレーム分ぐらいはあったほうがよい。
別の切り口で考えた場合、単純な音で1秒分のバッファを生成するのにうちで25msほどかかったので、1フレーム分を生成するのに0.4msかかる。ちょっと複雑な音を生成するのに例えば1msかかるとすると、上記パターンで3フレーム分を生成するのに3msとなって、5つも音を生成して運悪く同じフレームで生成することになるとそれだけでCPUがほぼ100%食われてしまうことになる。これはちょっと嬉しくない話である。
バッファはなるべく細かくして、ちょっとずつ生成することで負荷を分散させたほうがいい。現状では1フレームに1回生成なのでどうしようもないが、これを別スレッドでDirectSoundから通知を受ける形にするとどうにかなる可能性がある。もちろんRubyのスレッド切り替えのオーバーヘッドやタイミングなどの問題でバッファを小さくしすぎるとうまくいかなくなるだろうから、そのへんは実験してバランスを見る必要はある。現実的なレベルで動作可能であればよいのだが。

DirectSoundの通知機能

DirectSuondのデータはバッファオブジェクトが持っていて、再生をするのもバッファオブジェクトである。どこまで進んだかを保持しているのも当然バッファオブジェクトなので、それを監視して通知する機能を持つのもやっぱりバッファオブジェクトとなる。ただし、DirectSoundBufferではなく、そこから取り出したインターフェイスIDirectSoundNotifyが担当する。
COMはC++ベースで設計されていて、多重継承で各種インターフェイスを実装する。DirectSoundBufferクラスはIDirectSoundNotifyも継承しているから、DirectSoundBufferオブジェクトに対してQueryInterfaceを呼び出してやることでDirectSoundNotifyオブジェクトを受け取ることができる。実体は同じだが型が違う、ということだ。以下のコードをinitializeに追加する。

    // イベント作成
    st->event[0] = CreateEvent(NULL, FALSE, FALSE, NULL);
    st->event[1] = CreateEvent(NULL, FALSE, FALSE, NULL);
    st->event[2] = CreateEvent(NULL, FALSE, FALSE, NULL);

    // 再生イベント通知オブジェクト
    hr = st->pDSBuffer8->lpVtbl->QueryInterface(st->pDSBuffer8, &IID_IDirectSoundNotify, (void**)&pDSNotify);
    if (FAILED(hr)) rb_raise(eSoundTestError, "query interface(notify) error");
    pos[0].dwOffset     = (st->bufsize / 2) - 1;
    pos[0].hEventNotify = st->event[0];
    pos[1].dwOffset     = st->bufsize - 1;
    pos[1].hEventNotify = st->event[1];
    hr = pDSNotify->lpVtbl->SetNotificationPositions(pDSNotify, 2, pos);
    if (FAILED(hr)) rb_raise(eSoundTestError, "set notification psitions error");
    pDSNotify->lpVtbl->Release(pDSNotify);

Windowsのイベント

上のコードのCreateEventというのはWin32APIの関数で、イベントオブジェクトを生成してハンドルを返す。イベントというのはプロセス間通信を可能にする仕掛けで、WaitForMultipleObjectsやWaitForSingleObjectなどのAPIにイベントオブジェクトを渡してやることでスレッドが眠りにつき、他がそのイベントを発火した時に起きる。DirectSoundはDirectSoundNotifyオブジェクトにバッファ内の位置とイベントオブジェクトを渡すと、再生カーソルが場所に来たときに渡されたイベントを発火する。
上のコードではイベント2つをバッファの真ん中と最後に設定しているので、これらのイベントにより起こされた場合には約半分が空いた状態となっているはずである。

Rubyのスレッド

Ruby1.8の時はOS的にはシングルスレッド、Ruby上ではグリーンスレッドによるマルチスレッドだった。Ruby自身はシングルスレッドだが、コンテキストを切り替えながら動作することで並行処理が実現される。Ruby1.8のスレッドの問題点は、どこかのスレッドでIOの待ちが発生するとそこで全体が停止してしまうことである。
Ruby1.9になるとOS的にはマルチスレッドとなり、Ruby上はGVL(Global VM Lock)により同時に実行するスレッドを1つに制限した状態での平行処理となる。同時に1スレッドしか動かないのにネイティブスレッドにする理由はIO待ちで、Ruby1.9ではIO待ちが発生する場合にGVLをアンロックして別のスレッドに渡し、Rubyの動作は進めながら、IO待ちも同時に行うことができる。
イメージとしては、Ruby1.8では1人のスレッドさんが帽子を取り替えながら色んな処理を順番に処理していて、IO待ちが発生したらそこで固まってしまうのに対して、Ruby1.9では多人数のスレッドさんがGVLというバトンを受け渡ししながら処理してて、バトンを持っている人だけがRubyの処理を実行できて、IO待ちが発生するときには次のスレッドさんにバトンを渡してから固まる、という感じか。IO待ちしているスレッドさんはIO待ちが終わるとまたバトンをもらうまで待機する。

GVLアンロック

通常、Rubyのコードは必ずRubyの処理なので(当たり前)スレッドはGVLを取得するまで動作しない。Cのコードは例外がありえて、Rubyの関数を呼び出したりオブジェクトを参照したりしなければ、GVLを取得する必要が無い。GVLを取得しないということは、別スレッドで並列に動作することができる。例えばDXRubyのウィンドウメッセージ処理スレッドはCの拡張ライブラリで勝手にスレッドを作って勝手に並列処理している。そこからRubyへアクセスさえしなければ別スレッドを作るのは自由である。
でもIO待ちというのはちょっと事情が違ってて、途中までRubyの処理をしていて、IO待ちの瞬間だけ並列に動作したい。IO待ちの間は当然Rubyの処理は無いっていうかむしろ何もしていないわけで、この瞬間だけはGVLを保持しなくてもいいわけだ。もちろん、GVLを解放するからにはGVLを持っていないと意味がないので、このパターンはRubyから呼び出されるC実装のメソッドということになるが、メインスレッド1つの状態でGVLを解放しても何もしないだけでこれもやっぱり意味が無く、GVLを解放する機能はRubyのThreadで別スレッドを作るからこそ意味がある。

SoundTest#waitの実装

DirectSoundのイベント通知を待つメソッドを作ってみよう。待つ間GVLを解放して、別スレッドがあった場合にそっちに処理を譲るようにする。

// GVLアンロック状態で呼ばれる関数
static void *SoundTest_wait_blocking(void *data)
{
    DWORD result;
    result = WaitForMultipleObjects(3, (HANDLE*)data, 0, INFINITE);
    return (HANDLE *)data + result - WAIT_OBJECT_0;
}

// SoundTest#wait
// 通知を待つメソッド
static VALUE SoundTest_im_wait(VALUE self)
{
    HANDLE *result;

    // selfからSoundTest構造体を取り出す
    struct SoundTest *st = (struct SoundTest *)RTYPEDDATA_DATA(self);

    // GVLをアンロックしてSoundTest_wait_blockingを呼び出す
    result = (HANDLE *)rb_thread_call_without_gvl(SoundTest_wait_blocking, (void*)(st->event), RUBY_UBF_PROCESS, 0);

    // 終了イベントを受けたらtrueを返す
    return result == &st->event[2] ? Qtrue : Qfalse;
}

rb_thread_call_without_gvlというRubyの関数を使うことでGVLを解放することができる。第1引数に渡した関数SoundTest_wait_blockingはGVL解放状態で呼ばれるので、この中でRubyの関数を呼んだりオブジェクトを操作したりしてはいけない。SoundTest_wait_blockingが終了するとGVLを取得してからrb_thread_call_without_gvlが終了する。戻ってきたときにはGVL取得状態なのでRubyの関数を呼んだりオブジェクトを操作したりしてもよい。
rb_thread_call_without_gvlの第2引数はSoundTest_wait_blockingに渡す引数で、SoundTest_wait_blockingの戻り値はrb_thread_call_without_gvlの戻り値となる。型はvoid*なので何かしら変数を作ってポインタを渡すことになるが、今回はイベント配列を渡して、発火したイベントのポインタを返すようにした。イベントは3つ作っていたが、1つ目がバッファの真ん中、2つ目がバッファの末で、3つ目はDirectSoundとは関係なく、再生を停止するときに自分で発火してウェイトを解除するためにある。3つ目のイベントでスレッドが起きた場合にはSoundTest#waitがtrueで返るようになっている。
今回のソースはまたgistに置いておいた。
ただ、rb_thread_call_without_gvl関連はよくわかっていないので(例えば引数の3つ目と4つ目とか理解してない)、使い方が間違ってたりふさわしくなかったりするかもしれない。使う場合はこのコードのコピペじゃなくて自分でちゃんと調べたりするように。
なんせGVLアンロックに関しては今までRuby1.9が出てからチャレンジしては挫折してを繰り返してきて、この間ようやく初めて動いたところなのだ。使い方が完璧と言うほうがおかしい。めでたくはあるんだけども。

Ruby

すごく適当だがこんな感じで、SoundTest#playの中でスレッドを作ってバッファの通知を待つ。

require 'dxruby'
require_relative 'soundtest'

class RectSound < SoundTest
  attr_reader :i, :pos

  def initialize(hz)
    super(44100) # バッファ1秒ぶん
    @i = @pos = 0
    tmp = Math::PI * 2 / 44100 / 1.5
    t = 44100/hz
    @prc = Proc.new do
      @i += 1
      if @i % t > t / 2
        Math.sin(tmp * @i)
      else
        -Math.sin(tmp * @i)
      end
    end
    self.writebuf(0, self.size, &@prc) # とりあえずバッファを埋める
  end

  def play
    self.stop if @th
    super(true) # ループ再生
    @th = Thread.new do
      loop do
        break if self.wait # 通知待ち
        playpos, writepos = self.getpos
        self.writebuf(@pos, @pos < playpos ? playpos - @pos : self.size - @pos + playpos, &@prc)
        @pos = playpos
      end
    end
    @th.priority = 1
  end
end

rs1 = RectSound.new(440)
rs2 = RectSound.new(660)

rs1.play
rs2.play
Window.fps = nil
Window.loop do
  Window.draw_font(0, 0, rs1.i.to_s, Font.default)
  Window.draw_line(0, 100, 639, 100, C_WHITE)

  playpos, writepos = rs1.getpos
  Window.draw_font(668.0 * playpos / rs1.size - 12, 100, '', Font.default, color:C_GREEN)
  Window.draw_font(668.0 * writepos / rs1.size - 12, 100, '', Font.default, color:C_BLUE)
  Window.draw_font(668.0 * rs1.pos / rs1.size - 12, 100, '', Font.default, color:C_RED)
end

バッファ書き込みの位置(赤い矢印)が先頭と真ん中を行ったり来たりするのが目で見てわかると思う。そのタイミングで通知が来てスレッドが起きて、バッファを書き込むと矢印が0.5秒ぶん進み、また眠る。
バッチリ別スレッドで動作しているのならバッファを小さくしてもそれなりに動作するはずだと思うのだが、うちで試した限りではバッファは100ms程度なら大丈夫でも、50ms程度まで縮めると音がおかしくなった。どうもバッファ生成スレッドが起きてもすぐ切り替わるわけではなく、若干の時間がかかるようである。このへんはRubyのスレッドマネジメントによるのかもしれない。
まあ、そもそも打てば即座に響くような高速反応が必要なリアルタイムの制御処理をRubyでやろうと言うのが間違っているとは思う。
ということで今回はここまで。次のネタは決まっていない。