ホーム  ざれごと  ワシントン州  ツール  NT豆知識  Win32プログラミングノート  私的用語  ジョーク  いろいろ  ゲーム雑記  Favorites  掲示板   Mail

コーディングテクニックその2・マルチスレッド編

Last modified: Wed May 17 23:25:41 2006 PDT

一つ上へ


マルチスレッド・マルチプロセッサ関係の小手先テクニック。

マルチスレッド、マルチプロセス

マルチプロセス・マルチスレッドでも実行単位の中では逐次実行に変わりありませんが、他との協調を考えずにプログラミングしていると、起きたり起きなかったりする嫌なバグが簡単に生じます。 対称型マルチ CPU をサポートした OS が主流に躍り出て、さらにはハイパースレッディングなどの技術によって本当のマルチスレッドがより一般的なものとなりつつあります。 もしマルチスレッド対応の正しいコーディングがなされていなかった場合、本当のマルチ化によりこれまで以上に問題が顕在化することが予想されます(シングル CPU での擬似マルチスレッドでももちろん問題になっていた のですが起きる頻度は相対的に低かった)。

とあるサウンドカードのドライバは、シングル CPU のみ対応をうたっていました(現在の状況は知りませんが)。 想像するに、ドライバ内部できちんと排他処理をせず適当にごまかしていたのかもしれません。 たとえば割り込みを禁止するとか、IRQL をあげておくとかで CPU の実行を奪われないようにすれば問題は顕在化しないでしょう。 しかし、本当のマルチプロセッサ環境ではそのようなセコい逃げは使えません。

とはいえ、マルチスレッド対応のために特に高度なアルゴリズムが必要なわけではありません。 かなり泥臭く地道な、それこそ本当に小手先の技術が必要になります。 ただし、ある意味発想の転換が必要になるかもしれません。

これからの時代に避けて通れないこの落とし穴について、少し見てみましょう。

オブジェクトを保護しよう

複数のスレッドやプロセスから読み書きアクセスするオブジェクトには、何らかの保護を考えましょう。 クリティカルセクション・ミューテックス・セマフォなど、保護に使えるさまざまな機構が用意されています。 またファイルの一部をロックする機能も OS によっては提供されています。

ここでいうオブジェクトとは、ファイル・メモリ(マルチスレッドのケース)・シェアードメモリ(またはマップドファイル・マルチプロセスのケース)・I/O ポート(あるいはソケット)などが考えられます。

要は、読まれるだけの静的オブジェクトなら保護は不要。 オブジェクトに同時にアクセスするのが設計上一人だけなら、これまた保護は不要。 しかし、同時にアクセスする可能性のあるスレッド・プロセスのうち、たった一人でも書き込む人がいるのなら(外部からの書き込みも含む --- I/O ポートなど)、そのオブジェクトのアクセスは保護する必要があります。

デッドロック

同期オブジェクトを使う場合に気をつけなければならないのが、デッドロックです。

デッドロックを避けるための指針がいくつかあります。

ロックをかける順番を統一する

同期オブジェクト A と B があったら、常に A を先にロックし、次に B をロックする、というようにロックする順番を統一することで、デッドロックが防げます。 デバッグ用ビルドではアサーションを使ってチェックするのもいいでしょう。

ロックは完全にネストする

複数の同期オブジェクトを完全にネストしないで利用しても必ずしもデッドロックは生じませんが、混乱の元・バグの温床となりやすいものです。 完全にネストするという原則を守ることで、その可能性が減ります。 アサーションも書きやすくなるでしょう。

アトミックオペレーション

アクセスの中でも、アトミックなオペレーションそのものは同期を取って保護する必要はありません。 もちろん、他のオブジェクトと依存関係によりますが。

「アトミック」というのは、これ以上分割できないなどという意味ですが、ここでは「他の CPU から割り込まれない、結果が不定ではない(あるいは最後の人が常に勝つ)」ぐらいの意味と考えてください。

「アトミック」かどうかは、C/C++ コード上で一文に収まるかではなく、どうコンパイルされるか、CPU がどう扱うかで決まります。

x86 の場合、たとえば単なるワード値の読み出しはアトミックオペレーションにあたります。 あるいはメモリ上のワードを 0 に初期化することもそうです。 しかし、メモリの内容をインクリメントするのはアトミックではありません。 メモリのインクリメントは read-modify-write オペレーションに分類されます。 結果は不定になります。

アセンブリレベルでいろいろといじることが出来る場合もあるのですが(x86 の lock プレフィクスなど)、ここでは触れません。

CPU のアーキテクチャによっては単純な読み出し・書き込みがアトミックでない場合があります。 通常は問題にならない、C コンパイラによる最適化。

BOOL locked;

for (;;) {
 if (!locked) {
  break;
 }

あざとい例ですが(笑)、locked が FALSE になるまでループする、という擬似ロックなコードです。 コンパイラによっては、locked をループの外へ追い出してしまい単なる無限ループになることがあります。 この場合、コンパイラに「現在実行中のこの関数以外により locked が変更されることがある」ということを通知するため、volatile キーワードをつける必要があります。

volatile BOOL locked;
for (;;) {
 if (!locked) {
  break;
 }
}

volatile を使うことで状況が好転しますが(というかつけないと明らかにまずい)、今度はソフトウェア・ハードウェアによるリオーダリングの問題に直面します。 メモリフェンスを意識する必要があるなど厄介です。 NT における Interlocked API のように、OS が CPU に依存しないアトミックオペレーションな API を用意している場合があります。 できるだけそちらを使うようにしましょう。
非常に簡単な例を挙げておきます。

static LONG counter;
void foo()
{
    ++counter;  // 非アトミックオペレーション
}

これはだめですね。 マルチスレッドセーフではありません。

上記のコードを微視的に直すなら、

static LONG counter;
void foo()
{
    InterlockedIncrement(&counter);
}

となります。
もうひとつだけ例を見てみましょう。

static LONG flag;
if (flag == FALSE) {
    flag = TRUE;
    ...
    flag = FALSE;
}

このコードがまずいことはもうお分かりですね。 NT にはフラグのチェックとセットをアトミックに行える InterlockedCompareExchange がありますから、そちらを使いましょう。

if (InterlockedCompareExchange(&flag, TRUE, FALSE) == FALSE) {
    ...
    InterlockedExchange(&flag, FALSE);
}

アトミックな処理をうまく使って明示的な同期オブジェクトを使わずに済めば、処理も軽くなりコードも小さくて済むなどいいことずくめかもしれません。 正しく書ければ、ですが(笑)。 ここであげたものはごく単純なケースに過ぎません。 実際にプログラムを書く段になると、かなり頭をひねる必要があったり潜在的バグの温床になったりします。 そのときは素直に同期オブジェクトを使いましょう。

余談ですが、明示的に初期化していない静的変数は、ホステッド環境では 0 に初期化されることになっています。 実装としては BSS に置かれて、初めてのアクセスでページが用意されゼロクリアされたりします。 セコいテクですが、バイナリの大きさ、実行速度とワーキングセットの縮小に貢献することがあります。 まぁ塵も積もれば…の程度ですが。

マルチプロセッサ

シングルプロセッサにおけるマルチスレッドとマルチプロセッサにおけるそれでは、さらに後者のほうが注意を要する点があります。 キャッシュ、コヒーレンシー、コンパイラの最適化・リオーダリング、スーパースケーラハードウェアによるリオーダリング。 さらに、真のマルチプロセッサではシングルプロセッサの場合よりもこれらの問題が顕在化しやすくなります。

キャッシュ

マルチプロセッサ環境では、それぞれの CPU ユニットがキャッシュを持っています。 キャッシュは通常「キャッシュライン」という単位で管理され、最低限必要なデータより大きな単位でメモリから読み書きされます。 複数の CPU が近傍のアドレスを同時にアクセスする場合、たとえアドレスが違っていても同じキャッシュラインにデータが載っているかもしれません。 この場合、CPU 1 がメモリに書き込むと、CPU 2 の持っているキャッシュラインが無効になります。 CPU 2 は CPU 1 へ信号を送り、キャッシュラインのコピーが取得できるまでブロックされてしまいます。 CPU 1 と CPU 2 が交互に同じキャッシュラインに書き込むなど、非効率な状況が起こりえます。

コヒーレンシー

コヒーレンシーとは、ひとつの CPU で起こったメモリに対する変更が、他の CPU にも透過的に見えることです。

コンパイラ

最近のコンパイラは高度なデータフロー解析を含めた最適化を行い、無駄なコードは排除されてしまいます。 また、CPU の実行速度が稼げるよう、逐次的にソースコードを翻訳せず命令の順序を入れ替えます。

volatile キーワードによって、ある程度は防げるのですが、特にマルチプロセッサ環境では十分でないことがあります。

volatile

変数を volatile で修飾すると、その変数がコンパイラのあずかり知らぬところで変化する可能性があることをコンパイラへ伝えます。 volatile キーワードをつけた変数に対するアクセスは、コンパイラが最適化・リオーダリングの対象からはずすはずです(すでにレジスタへロードしてあっても再度メモリから読み直すなど)。

volatile の問題は、あくまでもコンパイラに対する指示であり volatile はハードウェアリオーダリングによって生じる問題を回避できないこと。 などが挙げられます。

あくまでもコンパイラに対する指示として volatile を認識する必要があります。 実際にマルチプロセッサ環境で意図したとおりに動くコードを書くためには、volatile だけでは不足です。

メモリフェンス

ライブロック


出来るだけシンプルに、しかしシンプル過ぎないように

たしか かのアインシュタイン博士の名言だったと記憶していますが、プログラミングにも通用する至言です。

Since 1996

一つ上へ

ホーム  ざれごと  ワシントン州  ツール  NT豆知識  Win32プログラミングノート  私的用語  ジョーク  いろいろ  ゲーム雑記  Favorites  掲示板   Mail