スキップしてメイン コンテンツに移動

Windows C++ マルチスレッドアプリケーション デバッグ法

1. はじめに
2. Windowsのマルチスレッド設備
3. 同期オブジェクトの保護対象
4. マルチスレッドの病理
5. マルチスレッドデバッギング - 実行時テストによる
6. マルチスレッドデバッギング - クラッシュダンプの分析
7. 広義のカーネルオブジェクトとしてのCRITICAL_SECTION
8 . おわりに

はじめに

Intel Corp.によるHyper-Threading Technology導入により、マルチスレッドアプリケーションがMicrosoft Windows®上で効力を発揮する機会がさらに増えることが予想される。Windowsにおける従来のメインストリームとしての、デスクトップでの個人利用を前提としたGUIアプリケーションでは、マルチスレッドといってもせいぜいワーカスレッドを作業の度に生成してシングルユーザに対するユーザイン ターフェイスの応答性を担保するといったきわめて局所的な利用に留まる場合が多かった。こうした、共有メモリの中での割り込みを主目的とするような限定的で単純な利用では、マルチスレッド固有の問題が生起する確率は当然低い。

それに対し、Windows上でも、サーバアプリケーションが多数の外部からのリクエストを処理すべく多数のスレッドを生成したり、またスレッドの個性を無視して一定数のスレッドをプーリングするような手法が用いられる頻度が増すと、メモリ空間を共有するマルチスレッドプログラミング固有の問題がプログラマを悩ませる確率もまた高まる。対策として、例えばJohn RobbinsDebugging Applicationsでは終盤の1章をマルチスレッド下でのデッドロッ ク対策に充てており、そこでは精緻な機構によるデッドロック検出法が解説されている。しかしその検出法とは、関連APIをフックするためのDLLを作ると いう面倒なやり方で、応用性が高いとはいえない。そもそもなぜそのような機構が必要になるかというと、マルチスレッドにおける問題は、デバッガをアタッチ して一々チェックしていたのではなかなか再現しないという極めて不確実で再現性の低いものになる場合が多いからである。アプリケーションの複雑性が増せば 増すほど指数関数的にこうした問題は解決が困難となる。

そこで本稿では、Windowsネイティブのマルチスレッドアプリケーションにおける同期機構の効率的利用を目的として、普段からのコーディング時に実践できる簡潔で軽量な手段を考察する。その上で、メインの開発環境以外のテストマシン上でDebugging Tools for Windowsを利用して問題を捕捉する方法を取り上げる。つまり、単体テストを行う開発環境のデバッガやprintfデバッグで再現するのが難しいならば、テスト環境でのテストの際に問題を発見するという次善の策である。

デバッグについて扱うとはいえ、マルチスレッドに起因するバグは一般的に追跡困難でコストが高く付くことからすれば、そもそも実装の際に十二分に注意し、リファクタリングなどの後手間を可能な限り省くべきである。一旦暴走すると混沌として打開の糸口が失われてしまいがちなのがマルチス レッドに起因するバグであり、そうなる前にマルチスレッドを扱う部分を封じ込める作法を確立しなければならない。なお以下の記述はWindows NT系プラットフォーム上でのみ検証されているため、原則としてその使用を前提とする。

Windowsのマルチスレッド設備

WindowsでVisual C++を利用してマルチスレッドのアプリケーションを作成する場合には、Windows APIか、Cランタイムライブラリ(CRT)の関数か、あるいはMFCを用いてスレッドを生成することになる。それぞれについて一長一短有るので注意されたい。もっとも、CRTやMFCの関数はアプリケーションの他の部分との兼ね合いでどうしても使用しなければならない局面以外では使用しないのが賢明だろう。また、Windowsにおける非同期プログラミングモデルを支えるAPIを利用する場合は、コールバック関数を処理するスレッドがOSによって非同期で生成/復帰/実行されるため、暗黙のうちにマルチスレッドアプリケーションとなる。従ってそれらのコールバック関数はスレッドセーフでなければならな い。

同時実行される複数のスレッドの同期を行いデータの破壊を防ぐためにWindowsが提供する同期オブジェクトには、クリティカルセクションとミューテックスの2種類があり、前者がスレッド間での同期にのみ使用できるのに対し後者はプロセス間でも使用できる。前者の方がコストが低いので、マルチスレッ ドアプリケーションでは主に前者を利用することになる。

クリティカルセクションを扱うAPI群は他のAPIと同じくCのAPIなので、これをより効率よく、かつ例外に対する耐性を持たせながら使いたい。そこで、MSDN Magazine 2001年8月号掲載のCritSecクラス(コンストラクタでクリティカルセクションを生成、デストラクタで破棄)と、テンプレートクラスLockKeeper(コンストラクタで引数に取ったオブジェクトの特定メソッド - CritSecクラスのクリティカルセクション獲得メソッド - を呼び、デストラクタでそのオブジェクトの別の特定メソッド - CritSecクラスのクリティカルセクション解放メソッド - を呼ぶ)を組み合わせて用いることにより、スコープを抜けたときにクリティカルセクションを自動的に解放できる。

void SomeClass::doSomething()
{
// lock_はSomeClassクラスのデータメンバである
// CritSecクラスのインスタンス。
// SomeClassのコンストラクタで(1度だけ)初期化済
typedef LockKeeper<CritSec> Lock;
Lock cs(lock_);
/* 処理 */
/* ここで自動解放 */
}


同期を行うスコープの最上部に、CritSecクラスで特殊化されたLockKeeperクラスのオブジェクトを作成するだけである。あらかじめ保護する場所の種類の数だけそのクラスのデータメンバとしてCritSecクラスのオブジェクトを入れておく必要がある。(なお、WindowsXP以前ではEnterCriticalSectionが例外を放出する恐れがあるのでその点を対処すべきである。またInitializeCriticalSectionではなくInitializeCriticalSectionAndSpinCountを使用するとマルチプロセッサ下でスピンカウントが有効になりCPU時間を多く消費する代わりに、WaitForSingleObject()呼出によるシグナル待ちに伴うユーザモード/カーネルモード移行が減少し、反応が良くなる場合がある。)

少なくとも、APIを直接用いるのではなくここで挙げたような薄いラッパーを用いて実装すれば、さしあたって同期オブジェクトの解放の問題を気にする必要はない。さらに重要なのは、同期という振る舞いの層がラッパークラス中にカプセル化され、同期から生ずると思われる問題を調査する際にこれらのクラスを対象にしてログ関数などを挿入するだけでアプリケーション全域での同期の状況について把握する足がかり(あくまで足がかりでありそれ以上ではないが)を作れるということである。後に紹介するデバッグ法も、同期を行うべきポイントでのこうしたラッパークラスの全面的な使用を前提としている。

同期オブジェクトの保護対象

アプリケーションのマルチスレッド化に失敗したことから生起する現象としてWindowsプラットフォームに限らず一般的に論じられるのは、レースコンディションとデッドロックの2種類である。これらの問題は双方とも、同期のスポットを不適切に設定したことによって起きる。

加えて、C/C++固有の問題として、同期が不十分なために起こる、境界を越えてデータを書き込むアクセス違反例外や、さらに潜在的で発見しにくい、意図しないデータの上書きがある。たとえば、同期がきちんとなされていれば書き込み対象アドレスが適切に設定される筈のところで同期がなされておらず、あるオブジェクトのデータメンバのアドレスへ境界を越えて書き込んでしまったという場合、その隣にデータが詰まっているとそこまで書込がなされ、すぐには問題が発現しない。こうした境界を超えたアクセスによるデータの破壊については、何についていつ同期を行うかというガイ ドラインがあれば、アプリケーションの作成中は恐れるに足らない。大体、シングルスレッドだろうが境界を越えた書き込みはC/C++のコードで最も危険かつ一般的なミスなので、その問題特有の注意を払っておく必要がある。問題なのは、それがマルチスレッドと組み合わされると、スレッドの稼働状態によって問題が起こったり起こらなかったりというマルチスレッドの性質により、不定状態の不定度が増幅されることである。事後的に、アプリケーションの規模が大きくなってから何かの拍子に問題が発覚し、その問題が誤ったマルチスレッド化によって起こっているのか、あるいはその他の不定な状態を生ぜしめる類の誤り によって引き起こされていながらデバッグ時に何らかの再現条件が欠けているためになかなか再現しないのか、突き止める手段が無いことによる精神的負担は計り知れない。

まず同期による保護が必要なのは、要素が書込操作により削除される可能性のある動的なコレクションである。全ての読出/書込操作について直列化を行わなければならない。ただしポインタによるリンクリストのように要素の挿入削除時に対象要素前後の要素オブジェクトの持つポインタを変更すればよいというように実装の詳細が判明している場合は、操作によっては部分的な同期で済む場合もある。さらに、既存要素のアドレスが変化せず、新しい要素の追加のみが行われ削除は無いというコレクションの場合だと、たとえ書込が同じタイミングで発生する場合があったとしても読出を保護する必要がない。たとえば、領域確保済みのCスタイル配列とその中で使用されている要素数とからなる単純なコレクションクラスを考えてみよう。常に配列の低い添え字要素から順にデータが書き込まれ、使用要素数は最大値に達するまで増え続ける場合、書込相互で使用要素数の変更を排他する必要はあるものの、書込を1. 新要素の書き込み 2. 使用要素数のインクリメント という順番で行えば、要素の読出がどんなタイミングで行われたとしてもまだ使用されていない要素にアクセスしてしまうことはない(読み出しは常にロックフリーとなる)。しかし、こうした特殊な場合と、ある時点以降は読み出し操作しか行われないことがそのコレクションを利用するアプリケーションの特性上保証される場合などを除いて、インターフェ イスの乏しい抽象化されたコレクションについてはあらゆる操作を直列化する必要がある。

ではintやDWORDのようなPODデータ型はどうか。PODデータ型については、占めるメモリ領域にランダムに書込/読出を行ったとしても、サイズの決まっている書込が範囲外アクセスを生じることはない。せいぜいvolatileキーワードによってレジスタ割付を防止する程度の保護で問題ない。とはいえ、画面のピクセルのような、完全に他の要素から独立しているローカルなデータは稀であり、別のデータへの影響を意識した場合にはほとんどの場合データの一貫性を保つために適切な同期を行う必要がある。例えばCOMオブジェクトの参照カウントは、ただ一回のみ行われなければならない資源の解放のためのトリガーとして用いられるので、マルチスレッド下ではInterlock系APIによってアトミックな操作となるように保護されている。

マルチスレッドの病理

要は、C++のソースコードレベルでの同期スコープの配置は、性質上同期を必要とすると思われる部分から、安全性と性能のトレードオフを考慮しつつ決定される。そして、性能の善し悪しについては柔軟で多様な選択が可能であるとしても、安全性については、常に最低限の基準を守る必要がある。これらを踏まえた上で、アプリケーションの性能を高めるには、

1. 同期ポイントを絶対的に少数にすること
2. 同期スコープは最短にして粒度を小さくすること
3. 同期スコープを異なる個々の同期オブジェクトの管理下へ分割すること

という行動を取ることができる。最も重要なのが1、次に重要なのが2で、3は場合によってはパフォーマンスを落とすのでケースバイケースで判断することになる。

何十個ものプロセッサが利用できる時代はまだ来そうにもない現状を鑑みると、物理的に利用できるプロセッサ数以上のスレッドの生成やそれに伴う同期ポイントの増加はオーバーヘッドを増大させるだけで、高性能化というマルチスレッドプログラミングの目的をスポイルしかねない。また、ローカ ル変数の初期化など無用な処理を同期スコープから出してやり同期スコープをできるだけ短くするべきである。一方で、3はあくまで2に寄与するための方策であり、同期のポイントが増え同期オブジェクトを待つ場合が頻繁になってしまうのであればかえって1に反し本末転倒となるため、同期スコープの分割は、分割により同期待ちの競争相手が減ることがアプリケーションの処理の性質上明かである場合に限って有効であることに留意すべきだろう。問題は、オブジェクト指向のための抽象化と並行して、パフォーマンスを高めるためにこれらの同期に関する方針に従ってコードを書いた場合に、自ら陥穽に落ちることがあるということだ。

オブジェクト指向ではメソッドを非常に重んじるので、単にデータメンバひとつを扱うような短い処理についても、セマンティックな表現を用いたがる。たとえば、


if (someFlag && anotherFlag)
{
/* 処理 */
}


より、


if (something->isInSomeState())
{
/* 処理 */
}


という形式を読みやすいと感じる人は多いと思われる。実際、後者の方が見通しは良く、リファクタリングに関する書物でもそう勧められているので、セマンティックに共通化できる部分を見つけ次第メンバ関数化するようになる。そして、C++では関数をインライン化できるのでオーバーヘッドも気にならない。この後付けのメソッド抽出の作業中に、同期オブジェクトを使用している(純粋関数ではない)メソッドを作ることもあるだ ろう。そして後日無意識のうちにこれらの人間に優しいメソッドを混在させて使うことによって、恐るべきデッドロックの罠に陥ることになる。

アプリケーションがデッドロックに陥ったかどうかは状態を見れば判定できる。スピンカウント使用時でない限りスレッドは休止状態になるので、CPU使用率が上昇せずにアプリケーションがフリーズするのである。これに対し、無限ループに陥るとCPUを全て消費してしまいシステム全体が影響を受ける。システム全体に与える影響は後者が高いものの、デバッグについては困難ではない。発見は比較的容易であるし、修正も容易であることが多い。対してデッドロックは、マルチスレッドによる開発が進み、アプリケーション内部が複雑になった段階で現れてくると致命的である。デバッガさえテス トマシンにセットしてあれば問題ないにせよ、インターネットを越えたリモートデバッグが必要な構成など、そうもいかない場合が多い。そこで、もっと簡単な方法を検討する。

上記のようにメソッドを分解していくと、たとえば

// ケースA
void SomeClass::method1()
{
LockKeeper<CritSec> cs(lock_);
method2();
}

void SomeClass::method2()
{
LockKeeper<CritSec> cs(lock2_);
/* 処理 */
}

// ケースB
void SomeClass::method1()
{
LockKeeper<CritSec> cs(lock_);
method2();
}

void SomeClass::method2()
{
LockKeeper<CritSec> cs(lock_);
/* 処理 */
}


という呼出関係をいつの間にか作り出してしまうことがある。ケースAについては、同期オブジェクトが異なっているので単独では問題な い。2種のコレクションがアプリケーションの性質上依存性を持ちながら変更されなければならないというとき、それを表現するためには、片方のコレクション の更新中にそのコレクションをロックしたまま別のコレクションのロックと更新を行わなければならないという交叉が生まれる。それに対し、ケースBでは同じ同期オブジェクトに重ねて操作を行っているので明らかに問題含みである。またケースAでも、method2の中でさらにmethod1を呼び出してしまっ ているかもしれない。Windowsのクリティカルセクションでは、 既に獲得した同期オブジェクトを同じスレッドで再獲得しようとした場合競合状態とはならず、何も起こらないことが仕様で決められている。しかし二重解放は未定義である。1回目の解放直後に別のスレッドがそのクリティカルセクションを獲得し、遅れて到達した2回目の解放によって別スレッドが獲得し たオブジェクトが勝手に解放されてしまうかも知れない。こうして予定されていたフローが崩れ、多くの場合レースコンディションやデッドロックが起こることになる(または厄介なことに全く起こらないこともある)。

ケースAの発展系として以下のケースCが考えられる。

// ケースC
void SomeClass::method1()
{
LockKeeper<CritSec> cs(lock_);
method2();
}

void SomeClass::method2()
{
LockKeeper<CritSec> cs(lock2_);
/* 処理 */
}

void SomeClass::method3()
{
LockKeeper<CritSec> cs(lock2_);
method4();
}

void SomeClass::method4()
{
LockKeeper<CritSec> cs(lock_);
/* 処理 */
}


このケースCではよりわかりやすい問題が生まれる。2つのスレッドが同時に各々method1とmethod3を呼び出した場合、互いにlock_とlock2_の解放を待ち合うという状態になる可能性がある。これは典型的なデッドロックである。

マルチスレッドデバッギング - 実行時テストによる

以下では、デバッガを待機させるのではなく、ソースコードにデバッグ用コードを埋め込む事により、結合テスト時にデッドロックの検出を行う。

先に出したケースAは、問題が無い場合もあるが、ケースBについては明らかに問題があり、ケースBのような二重呼出は全て排除する必要がある。そして、ケースAについても、アプリケーションロジックを変更することにより依存関係を解消可能な場合にはできるだけそうすることが望ましい。依存関係の解消によって、後々ケースBに発展しうる見通しの悪い同期ポイントを排除できる。さらに、ケースCは2つのスレッドに2つの処理の流れが別々に動 く場合であるところ、それをケースAに分解して片方または両方の依存を解消すればデッドロックは起こらない。他に、デッドロックを防ぐためにグローバルに同期オブジェクト獲得の順番を保持するという手法があるが、ここではアプリケーションの性能を低下させることを極力避けるために消極的な手法を追及するこ とにする。

ここで、先に利用を推奨した同期オブジェクトラッパークラスが生きてくる。デッドロックの最も危険なところは、一見してどこの部分が問題なのかわからないところである。アプリケーションの複雑度が増すのに比例して不定なエラーが起こりやすくなる。そのため、ピンポイントで問題箇所をつきとめることはテスト環境にデバッガを仕掛けない限り無理である。従って、開発マシン上で簡単な総当たりテストを行うことにより、デッドロックに繋がる可能性のある部分を一つ一つ潰していくのである。どのみち後日やらなければならないことなら早いに越したことはない。

まず簡単なのは、ケースBの排除である。同じスレッドで2回以上、先に獲得したクリティカルセクションを離さないまま同じクリティカルセクションを獲得している場合を見つければよい。しかしそのような場所を見つけたからといって、同じ関数はアプリケーションコードの様々な場所で使われているはずなので、どこでの呼出が問題の原因なのか呼出コンテクストについて知ることができなければならない。また、非同期で暗黙にスレッドを起動している時はtry...catchブロックも適切な場所に設定できないので、コンテクスト情報を含む例外を投げる場合、アプリケーションでキャッチできない例外を捉えられるようにすることが必須である。そのために、WindowsのPlatform SDKのデバッグAPIやstackwalk用のAPIを用いて例外時のコールスタックやソースコード行などコンテクスト情報をシンボル付きで報告できるクラッシュハンドラを用意しアプリケーションに組み込むべきである。これについては本稿の範囲を超えるので自作するなりネットで探すなりして頂きたい。クラッシュハンドラを組み込んだ上で、ケースBを排除するには、CritSecクラスにLONG型のデータメンバenterCounter_を追加し、コンストラクタで0に初期化する。そして、

// コード1
if (InterlockedIncrement(&enterCounter_) == 2)
throw 1;

// コード2
InterlockedDecrement(&enterCounter_);


コード1をクリティカルセクション獲得メソッドに、コード2をクリティカルセクション解放メソッドに入れる。カ ウンタが2になるとキャッチされない例外(unhandled exception)を送出するので、スタックトレースとシンボル情報を解析できるクラッシュハンドラ(とデバッグ情報を含む.pdbファイル)があれば、その時点での呼出状態が掴める(あるいは、クラッシュハンドラをプログラム内に埋め込まなくとも、後述するようにデバッガを仕掛けて例外時にダンプファイルを生成させるようにすれば、デバッガ内でコールスタック/ローカル変数/自動変数などが把握できる)。あとはひたすらアプリケーションの操作を 行ってこの仕組みによるアプリケーションのクラッシュを待ち受けるだけである。

次にケースAの場合である。今度はCritSecクラスのstaticデータメンバとしてchar型の配列 enterCounterPerThread_[128000]を加える。そして適切なstaticデータメンバの初期化コードを加え、 enterCounterPerThread_の要素を全て0で埋める。それから

// コード3
if (++enterCounterPerThread_[
GetCurrentThreadId() % 128000] == 2)
throw 1;

// コード4
--enterCounterPerThread_[
GetCurrentThreadId() % 128000];


コード3を例によってCritSecのクリティカルセクション獲得メソッドに、コード4をクリティカルセクション解放メソッドに入れ る。128000という数字はそれほどの多数のスレッドを生成することはまずないだろうという意味での数値で、単に十分な数値であれば問題ない。ここでは大した意味はないが同一スレッドでの二重呼出を検知すればよいのでInterlock演算は必要ない。ここでもケースBと同じくカウンタが2になると例外が投げられる。ただし、上述のようにコレクションの依存関係からどうしても排除できない場合がケースAにはあるので、デバッグを先に進めるために、 CritSecにもう一つフラッグとしてのデータメンバを加えて初期化時に報告不要フラッグを立てられるようにしておくとよい。そうすれば途中で見つかったケースAの部分をマークした状態でさらに次のケースA部分の探索ができる。

以上の方法は非常に単純ではあるが実装にも影響する効果の高い方法である。もちろん、実装時に正しく同期について配慮しておけば、こうしたテストの出番が極めて低くなることも容易に予想できるであろう。たとえば、直交するコレクションの依存関係が本質的ではなく解消可能な場合には、第一のコレクションに対する処理の結果をコンテクスト情報として変数へ格納し、クリティカルセクションによる保護スコープを抜けた後でも保持し、それを第二のコレクションに対する処理のための別のクリティカルセクションによる保護下へ渡すことにより、同期スコープ分割という前出の方針が実行できる。

マルチスレッドデバッギング - クラッシュダンプの分析

開発マシンで完全にテストできるアプリケーションの場合もしくは非常に再現性の高いバグの場合は、Visual C++.NETなら実行されているプロセスにアタッチして実行時テストを行い、問題が起こったときにデバッガの方でブレークするかクラッシュダイアログか らデバッガに移るかすればよい。ところがテスト環境が特殊かつ大規模で、開発マシンでは環境を再現できず、テストマシンと開発マシンが分かれている場合はどうするか。また、年中稼働しているサーバマシン上のサーバアプリケーションで、3日に1回程度の度合いでしか問題が現れないといった事態もありうる。

テストマシンのためにVisual C++のライセンスをもう一つ購入するわけにはいかないので、別のデバッガが要る。また、今回の問題には直接影響しないが、VC++はサイドバイサイド構成を変更するなどシステムに対する影響もあるので、テストマシン上にはそもそもインストールされていないのが望ましい。なお、以下でのデバッガによるデバッギングは、マルチスレッドにおけるデッドロックその他の問題に限らず、データ破壊も含みスレッドモデルの別も問わないあらゆる問題に対処できる一般的手段である。デバッガはその為に用意さ れているのだから当然ではある。ただしここでは、この文の冒頭で強調したように、できるだけ楽をするための簡潔な道を探る。

この点Windows XPではクラッシュ時にミニダンプファイルを生成できるAPIが追加されたので、クラッシュについてはそれが利用できる。しかし一般的な解とはいえない。そこで、Visual C++デバッガ以外の、それなりに使えるデバッガが欲しい。そのために推すのがDebugging Tools for Windowsで、Microsoftが配布しているデバッガツール群のパッケージである。Windows 自体にも「ワトソン博士(Dr. Watson)」というダンプを取るためのツールが添付されている。しかし「ワトソン博士」ではダンプが上書きされる恐れがあるなどやや不便である。そこ で、テストマシンにこのDebugging Tools for Windowsをインストールしてやることにする。そしてアプリケーションをデバッグ情報付きでビルドし、シンボル情報が入ったプログラムデータベース(.pdb)を生成し、実行ファイル本体と同じディレクトリに置いてアプリケーションを実行する。

デッドロックの場合CPU使用率が上昇せずにアプリケーションがフリーズするので、落ち着いてDebugging Tools for Windowsの中のGUIデバッガWinDbgを起動する。アプリケーションプロセスにアタッチして、コマンドを実行すればメモリダンプが取れる。それを開発マシンへ送ってVC++のデバッガでロードすれば良い。ただしダンプにも二種類(ミニダンプとフルダンプ)有り、WinDbgで取るときに種類を選択できる。ミニダンプの方はローカルスタック上の変数のみダンプに記録される。デバッガにロードすると、例えばスレッドが10本あった場合はその全ての状態が記録されており見ることができる一方で、ローカルスタックの変数しか見られない場合はメンバ変数などの状況はわからない。そこで、一旦問題箇所がある程度掴めたあとで再度のチェックが必要な場合は、わざとローカル変数にメンバ変数などの値を保存するコードを含めておく のも良いだろう。フルダンプの場合は、アプリケーションが利用していたメモリ空間がほぼそのまま保存され、ダンプファイルは依存関係のあるオブジェクトグラフを全て含むので当然大きくなる。筆者の環境では300MBのフルダンプファイルをVisual C++ 7へロードしようとしたところIDEがクラッシュしてしまいどうしてもロードできなかった。そこでWinDbgの方で同じ巨大ダンプのロードを試みたとこ ろそちらでは成功した。ただ、WinDbgは、コールスタックを見ることができるのは当然として、対応ソースコードにジャンプできる機能まで付いているにせよ、最高のデバッガと評されるVC++と比べられると如何せんメモリのウォッチなどが不便である。従ってなるべくミニダンプで済ませてVC++で 扱うことをお勧めする。

ともかくもそうしてデバッガでメモリダンプをロードすれば、デッドロックの場合にはどの同期オブジェクトでプログラムが停止しているのかがわかるだろう。そうすれば後は一本道だ。しかし、マルチスレッド下の問題としてさらに深刻なのはデータ破壊の方である。単にハンドラを用意していなかったことによってアクセス違反などのシステム例外が起こり、クラッシュダイアログが表示されたという場合ならまだ救いようがある。しかし何のエラー表示もなく突然アプリケーションが落ち、もしくはクラッシュハンドラの報告が運良く取れた場合でもシステムのdllやその他のC/C++ライブラリdllの内部でエラーが起こっているといった事態に至っては、デッドロックのために講じた手段では悠長すぎる。第一、クラッシュハンドラが反応せずに突然落ちる瞬間をどうやってデバッガで捉えるのだろうか。

鍵は、またしてもDebugging Tools for Windowsの中にある。"adplus.vbs"というスクリプトがそれだ。このスクリプトは Autodump+という名で、IISにホストさせるサーバ用アプリケーションのデバッグに主に用いられる。一般のサーバアプリケーションのデバッグにも非常に役立つツールである。適切な設定を行ってこのスクリプトを実行すると、アプリケーションのクラッシュ/突然の停止時などにダンプを生成させることが できる。実行中コンソールが出てくるので、そこでホットキーを押すことにより、任意の時点でのダンプも取れる。このスクリプトは実行したときにバッチスクリプトを生成し、同じくDebugging Tools for Windowsに含まれるコマンドラインデバッガcdbにそれを実行させ、アプリケーションにアタッチさせた状態でバックグラウンドに置く。ダンプの他に例外の詳細なログも記録してくれるが、シンボルを表示するためにプログラムデータベースは実行ファイルの隣に置く必要がある。

Autodump+を使ったログには、1st chance exceptionと2nd chance exceptionが記録される。2nd chanceの方は、デバッガでなければ扱えない、アプリケーションを終了させる回復不能な例外であり、ログの最後はこれで終わる。対して1st chanceはWindows構造化例外など、アプリケーションでハンドルできる比較的重大でない例外を指す。しかし、例外機構をほとんど使用していないにもかかわらずこうした1st chanceの例外が記録され、そこから時間を置かずに2nd chanceの例外と異常終了に至っている場合は、1st chance exceptionも十分嫌疑に値する。具体的には、その発生部分に至るパスでデータ破壊が起こっていないか詳しくチェックすべきだ。絶対にロジック上破綻がないと信じられるような場合でも、1%でも疑いがあるならばそこに対策を施して再テストしてみるとよい。マルチスレッド下でのデータ破壊の兆候を読みとって修正するためには必要な作業である。

広義のカーネルオブジェクトとしてのCRITICAL_SECTION

WindowsプラットフォームでのCRITICAL_SECTIONは、(他の類似の用途に使用されるセマフォやミューテックスのような)純粋なカーネルオブジェクトではない。しかしこのような書き方は語弊があるのであって、Platform SDKのWinNT.hを参照すればわかるように、CRITICAL_SECTION構造体は、純粋なカーネルオブジェクトであるところのセマフォをデータメンバとして含む、広義のカーネルオブジェクトである。

typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
//
// The following three fields control entering and exiting the critical
// section for the resource
//
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread; // from the thread's ClientId->UniqueThread
HANDLE LockSemaphore;
ULONG_PTR SpinCount; // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;


これは、クリティカルセクションが厳密にはカーネルオブジェクトではない点のみを強調しカーネルオブジェクトとしての特性を看過すると、意図しない問題に悩まされる可能性があるということを意味する。

まず上でInitializeCriticalSectionAndSpinCountについて述べたように、カーネルモード移行によるコストはアプリケーションのパフォーマンスに影響を及ぼす。ミューテックスよりはクリティカルセクションの方がコストは低いとは言え、保護しなければならない箇所が極めて多数に上る場合は考慮すべき点である。

さらに、クリティカルセクションが占有するメモリ空間が問題となる場合がある。まず第一に、クリティカルセクションオブジェクトそのものがユーザメモリ空間に占有するメモリ量が問題となる。この問題は、アプリケーションの構造を工夫することによりある程度緩和可能である。

しかし、Windowsマシンで大規模サーバを構築する場合に深刻な問題となりうるのは、広義のカーネルオブジェクトであるところのク リティカルセクションが、Windowsのカーネルオブジェクト用メモリ空間である非ページ化メモリ(non-paged pool)の枯渇によって、カーネルモードへの移行に失敗するという事態である。この非ページ化メモリは、物理メモリの量によってサイズが固定的に決定され、物理メモリの量が一定以上になるとレジストリ操作により値を変更しない限りその時点で増加を止める。ここで、アプリケーションのバグまたはOSのバグにより、ソケットその他のカーネルオブジェクトについてリークが発生していた場合、サイズが固定である非ページ化メモリ空間がリークしたオブジェクトに よって先に占有され、後から追加的にカーネルオブジェクトを生成できなくなる。クリティカルセクションもその例外ではなく、処理の線形化を期待して保護を行ったはずの部分が保護されず崩壊する。この場合、クラッシュダンプをアプリケーションクラッシュ後(post-mortem)に分析しディスアセンブリを眺めても、排他されているはずの部分でアクセス違反が起こっているので、通常の論理破綻と混同し、非常に原因を掴みにくい。また原因が掴めたからといって、OSのバグや既存アプリケーション/ライブラリのバグの場合、リークを修正できる場合は稀であり、何らかの迂回手段により問題を回避しなければならない。非ページ化メモリの状態は、タスクマネージャ、パフォーマンスモニタ、WinDbgなどで確認できる。

おわりに

この短い文章で述べられていることは、例えばスマートポインタなどの高度な抽象化機構を用いた際に混入する可能性のある危険な論理上のバグに比べれば、非常に原始的な問題である。何にも増して、先にしっかりと実装についての規約を敷いて予防線を張っておけば後々気に掛けることもないであろう問題が大半である。しかし実装を終えてしまった後では場所を突き止めるのが困難なデッドロック他の問題は、デバッグを行う上で最悪の部類に属するバグの代表格に他ならない。また単純故に発生箇所/確率も高くなる可能性がある。その意味で、正しく動作するマルチスレッドアプリケーションを低コストで作成できるかどうかは、早期の正しい知識の教育、フレームワークやコーディング規約の徹底にかかっていると言えよう。

コメント