マルチスレッドはクールだ

タイトルが「マルチスレッドは」であり、「マルチスレッド プログラミングは」でないことに気が付けば、これから何を話そうとしているか推測できるかと思います。アプリケーションやライブラリを作っているときに、ふと、マルチスレッドにしたいと思ったことはないでしょうか。私としては、

「その動作が、マルチスレッドでなければ不可能であるか、可能であっても非現実的である場合」

に限って導入します。なるべくならシングルスレッドで済まそうとします。 前者の具体例は、音楽を再生する機能です。Play という関数を実行したら、音楽の再生が終了するか、何かエラーが発生するまで制御を返さないのでは、全く使い物になりません。再生が始まったら即座に次の命令に移るべきですが、これはシングルスレッドでは不可能です。
後者については、何か時間のかかる操作をしつつ、ユーザーからの入力も処理する場合などが該当します。例えば巨大なデータをファイルに書き込む際に、Write( bigData ) という関数を実行したら、書き込みが終了するまで Write は制御を返しません。ユーザーからはフリーズしたように見えます。これを解決するには、データを小分けにして書き込みつつ、同じスレッドでユーザー イベントを処理する方法もありますが、書き込み先がローカルファイルだったらともかく、リモートに存在する場合は、書き込みが不安定になり、長時間、Write から制御が返らない可能性があります。そうなるとユーザー イベントの処理が途切れ、やはりフリーズしたように見えます。マルチスレッドにして、書き込みをバックグラウンドで行わせれば、ユーザーからの入力をメイン スレッドで即座に処理することができます。

マルチスレッドは、マルチ プロセッサのコンピュータではパフォーマンスが向上するし、シングル プロセッサであっても、ユーザーの操作に対するレスポンスの向上を期待できる、非常にクールな技術であると言えます。しかし、私はマルチスレッドの導入に関しては消極的です。クールである反面、巧妙な制御を要求されるからです。

マルチスレッドにおける問題として代表的なのは、データに対するアクセスの競合です。他にもスレッド切り替えのオーバーヘッドや、メモリ参照の局所性のなさから生じるページ違反もありますが、これらはパフォーマンスの問題であり、さほど重要ではありません。
シングル プロセッサのコンピュータでは、処理が進むスレッドは常に一つです。OS は、処理対象のスレッドを高速に切り替えることによって、あたかも複数のスレッドが同時に処理をしているように見せます。どのスレッドを実行すべきかは OS が判断します。プログラムで直接に指定することはできません。そして、実行が他のスレッドに切り替わるタイミングも、直接に指定することはできません。
例えば、あるオブジェクトに 10 個のプロパティがあり、それらを全て更新する関数があったとします。メイン スレッドがそのうち 6 個のプロパティを更新した時点で、他のスレッドに制御が移されたとします。そのスレッドが、オブジェクトのプロパティを参照したらどうなるでしょうか。6 個が新しい値で、4 個が古い値を持つ、整合性がないオブジェクトにアクセスすることになり、恐らく、予測不能な動作をするでしょう。つまり、プロパティを変更する一連の操作をしている間、メイン スレッドはそのオブジェクトに対して排他的にアクセスできるようにして、他のスレッドからは読み取ることも書き込むこともできないようにする必要があります。このことはマルチ プロセッサのコンピュータでも同様です。

オブジェクトに対する排他的なアクセスを実現する方法は、言語やフレームワークによって様々です。ここでは具体的なコードは掲載しませんが、「同期オブジェクト」を使用する場合について説明したいと思います。まず、全てのスレッドで、該当するオブジェクトに対する一連の処理の前に、同期オブジェクトを「取得」するようにします。そして処理が終了したら「解放」します。どこかで同期オブジェクトが取得されたら、解放されるまで、他の場所で取得することはできません。待ち時間を設定していない限り、同期オブジェクトが解放されるのを待ち続けることになります。具体的に言うと、あるスレッドが同期オブジェクトを取得し、排他的にアクセスしたいオブジェクトを操作し、同期オブジェクトを解放するまで、他のスレッドは同期オブジェクトの解放を待ち続けるか、他の処理をすることになります。

この手法には、少なくとも二つの注意点があります。一つは、プログラム上の問題により、同期オブジェクトが解放されない場合があるということです。そのときに、他のスレッドが同期オブジェクトの解放を無制限に待ちつづけていた場合、そのスレッドは永遠に停止したままになります。これを「デッドロック」といいます。もう一つは、排他的に操作している時間をなるべく短くすべきだということです。排他的に操作するということは、実質的にはその部分だけシングルスレッドにしているのと同じことなので、マルチスレッドの利点が失われてしまいます。複数の種類の同期オブジェクトが用意されている場合、要求を満たすものの中から最も高パフォーマンスのものを使用すべきでしょう。マルチスレッドにおいて、パフォーマンスを維持しつつデータの整合性を保つ最善の方法は、同期オブジェクトを使用しなくても整合性を保つことができるような設計をすることです。

最後に Windows 特有の注意点を説明します。DllMain の内部からはスレッドを終了できない仕様になっているため、ライブラリがプロセスに Attach されたときにスレッドを生成し、Detach されるときに終了するという手法は危険です。終了処理は進行こそするものの、完全には終了せず、スレッドがシグナル状態になることはありません。そのため、ライブラリ内部のスレッドの終了を WaitForSingleObject 関数などで待つようなプログラムは、スレッドが終了できないためにデッドロックを起こすことになります(十分な待ち時間を与え、終了したものとみなすこともできますが、かなり危険です)。