複数のスレッドが動いていると、どのような順番でそれぞれの処理が実行されるかという事は、予測が困難なものですね。
先日、DirectSound周りの処理を改良していた時、スレッドを二つ作り、メインスレッドでは次のような処理を行わせていました(話を単純にする為にある程度簡略化しています)。
// メインスレッド(通常の優先度)で private void PlayButton_Click( object sender, EventArgs e ) { // …… // OggVorbisDecoderは、ISoundDecoderを実装するクラス this.sound = new StreamingSound( new OggVorbisDecoder( new FileStream( this.sounds[ 0 ] ) ) ); for ( int i = 1 ; i < sounds ; i++ ) // StreamingSound.AddFollowingDecoderは、 // 部分ループ再生の為に後続のデコーダを追加するメソッド this.sound.AddFollowingDecoder( new OggVorbisDecoder( new FileStream( this.sounds[ i ] ) ) ); // それなりに長い処理(空行抜きで15行分程度) this.sound.Play(); // …… } // メインスレッドから呼び出される、StreamingSoundのコンストラクタ public StreamingSound( ISoundDecoder soundDecoder ) { this.soundDecoder = soundDecoder; // …… this.refreshingThread = new Thread( this.RefreshingThread ); this.refreshingThread.Start(); return; } // メインスレッドから呼び出される、StreamingSoundのメソッド public void Play() { // this.bufferは、Microsoft.DirectX.DirectSound.SecondaryBuffer if ( this.buffer.Status.Playing ) return; // this.decoderLockは、単なるobjectのインスタンス lock ( this.decoderLock ) this.buffer.Play( 0, BufferPlayFlags.Looping ); return; }
そして、セカンダリバッファの内容を更新するRefreshingThreadでは、次のようにしていました。
// バックグラウンドスレッド(通常の優先度)で private void RefreshingThread() { // 変数宣言(代入含めて、5つ程)…… // まだPlayが呼び出されては困る(ゴミが再生される恐れがある) lock ( this.decoderLock ) // バッファの前半にPCMデータを書き込む this.WriteHalfBuffer( 0, 0 ); // 準備完了、もうPlayを呼び出しても大丈夫 // …… }
lock ( this.decoderLock )
というのは、クリティカルセクションを作る為に導入したものです。lock文のブロックの中に何れかのスレッドが進入すると、他のスレッドでは、現在クリティカルセクションに進入しているスレッドがそのオブジェクトの所有権を手放さない限り(つまりlock文を抜けない限り)、同じオブジェクトを使ってlock文の中に入る事ができなくなります。ここでは僕は、二つのスレッドの優先度が同じで、RefreshingThreadをthis.refreshingThread.Start()
で開始させた時からの作業量はどう見てもバックグラウンドスレッドの方が少ないので、WriteHalfBufferが先に呼ばれるものと思っていました。実際Debugビルドではそうだったようで、再生してみても、サウンドの頭から綺麗に再生されていました。
ところが、Releaseビルドで作られた実行ファイルでは、どうも初回の再生だけ汚くなるのです。ノイズが聞こえる訳ではないですが、どうも頭の部分が切れている。初回の再生(初めて「再生」ボタンを押した時)と二回目以降の再生で違うのは、StreamingSoundを作り直すかどうかだけ。つまり、上記のソースコードで一番上の辺りの処理が怪しいのです。色々調べてみましたが、ふと気になって、StreamingSound.Playの中と、RefreshingThreadのWriteHalfBufferの前後で、System.DateTime.Now.Ticksを使って、処理が実行された時間を調べてみました。StreamWriterによって書き出されたログを見てみると……案の定、Playが先に呼ばれています。少し後れてRefreshingThreadのWriteHalfBufferが実行されていたようです。そりゃー、頭が切れる訳ですね。再生開始時点では何もバッファに書き込まれていない訳ですから。雑音が聞こえなかったのは運が良かったのでしょう。
さて、ではどう解消すべきか。lock文は、クリティカルセクションを作る事はできますが、スレッドの処理の実行順序を支配する事はできません。ミューテックス(System.Threading.Mutex)も同様ですし、モニタ(System.Threading.Monitor)はlock文の実体なので、書き換えた所で意味がありません。また、Releaseビルドでは問題が起こるけれどもDebugビルドでは起こらないから、といってReleaseビルドの実行ファイルを使わないようにするのも面白くありません。それに、Debugビルドだって、きちんと動いているように見えるだけで、その動作は「たまたま」上手く行っているのでしょう。確実に実行順序を支配する機構が欲しい所です。
これに打って付けなのは、イベントでしょう。と言ってもC#の言語機構のeventキーワードやdelegateを使う方ではなく、Win32 APIにもあるような、スレッドの同期を実現するイベントの方です。.NET Frameworkでは、System.Threading.AutoResetEventやSystem.Threading.ManualResetEventがそれに当たります。今回は特にマニュアルリセットが必要とされるものでもないので、AutoResetEventを使って同期してみました。主な変更は次の3箇所です。
// StreamingSoundのコンストラクタ public StreamingSound( ISoundDecoder soundDecoder ) { this.soundDecoder = soundDecoder; // …… // 最初は非シグナル状態にするので、falseを渡す this.decoderReady = new AutoResetEvent( false ); // …… this.refreshingThread = new Thread( this.RefreshingThread ); this.refreshingThread.Start(); return; } // StreamingSound.RefreshingThread private void RefreshingThread() { // 変数宣言…… // バッファの前半にPCMデータを書き込む this.WriteHalfBuffer( 0, 0 ); this.decoderReady.Set(); // …… } // StreamingSound.Play public void Play() { if ( this.buffer.Status.Playing ) return; this.decoderReady.WaitOne(); this.buffer.Play( 0, BufferPlayFlags.Looping ); return; }
イベントには、ミューテックスなどと同じように、シグナル状態と非シグナル状態という二つの状態があります。シグナル状態ではWaitOneなどのメソッドは成功し、次に進む事ができます。非シグナル状態ではWaitOneなどのメソッドは成功する事は無く、次に進む事はできません(無理矢理進んだりしても、同期に失敗しているのですから望む結果は得られないでしょう)。シグナル状態には、コンストラクタでtrueを渡す、Setメソッドを呼び出す、などの操作をする事で変化させる事ができます。非シグナル状態には、コンストラクタでfalseを渡す、Resetメソッドを呼び出す、WaitOneなどの待機関数を成功させる、などの操作で変化させられます。
StreamingSound.Playで呼び出しているWaitOne関数は引数がありません。引数無しでこの関数を呼び出すと、そのイベントがシグナル状態になるまで無限に待機します。コンストラクタではこのイベントは非シグナル状態にされているので、バックグラウンドスレッドのRefreshingThreadがSetしない限り、メインスレッドはサウンドの再生を開始しないという訳です。
……久し振りにプログラミングに関する覚え書き
を書きました。