hozugawa.net · A neutral informational page

InitializeCriticalSectionとスピンロックの使い方

Windows APIで複数スレッドが同じデータを扱う場合、排他制御の選択が性能と安全性を左右します。InitializeCriticalSectionで初期化するクリティカルセクションは、ユーザーモードで利用できる代表的な同期オブジェクトです。ミューテックスより軽量で、同一プロセス内の短時間の排他処理に適しています。

一方、スピンロックはロックを取得できるまでスレッドを待機状態にせず、短時間のループで再試行します。待機によるコンテキストスイッチを避けられる反面、ロックが長引くとCPU時間を消費します。そのため、両者は単純な優劣ではなく、競合時間やスレッド数に応じて使い分ける必要があります。

クリティカルセクションを使うには、構造体の初期化、ロック取得、共有データの操作、ロック解放、最後の削除という流れを守ります。初期化前の EnterCriticalSection や、取得していないスレッドによる LeaveCriticalSection は未定義動作につながります。

Windows APIの関数仕様を確認するときは、関連するWindows API資料も参照しながら、対象OSで利用できる関数とエラー処理を確認すると安全です。特に古いWindows向けコードでは、後から追加された拡張関数との違いに注意が必要です。

クリティカルセクションの基本動作

CRITICAL_SECTION は通常、グローバル変数、オブジェクトのメンバー、または管理構造体の一部として保持します。初期化は共有データを使い始める前に一度だけ行い、処理が完全に終わった段階で DeleteCriticalSection を呼びます。

CRITICAL_SECTION cs;
int sharedValue = 0;

InitializeCriticalSection(&cs);

EnterCriticalSection(&cs);
++sharedValue;
LeaveCriticalSection(&cs);

DeleteCriticalSection(&cs);

EnterCriticalSection は、別のスレッドがロックを保持していれば、その解放まで待機します。ロック範囲は必要最小限に絞り、ファイルI/O、ネットワーク通信、ユーザー通知、長い計算処理を内側に置かないことが重要です。例外や早期リターンがある場合は、C++のRAIIラッパーを使うと解放漏れを防げます。

初期化関数とスピン回数

単純な初期化には InitializeCriticalSection を使います。スピン回数を指定したい場合は InitializeCriticalSectionAndSpinCount、より細かいフラグ指定が必要な環境では InitializeCriticalSectionEx を検討します。スピン回数は、待機へ移る前に取得を試す回数を示す値です。

スピンはマルチプロセッサ環境で、保持側スレッドが別CPU上ですぐにロックを解放するケースに効果があります。しかし、シングルプロセッサ環境や高い競合状態では、回し続けるだけで得られる利益が小さくなります。値を大きくすれば必ず速くなるわけではなく、実測に基づいて調整します。

InitializeCriticalSection の失敗可能性や例外の扱いは、対象となるWindowsのバージョンによって考慮点が異なります。互換性を重視するプログラムでは、初期化をアプリケーション起動時にまとめ、失敗時に共有データを使わない経路を用意すると管理しやすくなります。

スピンロックを自作する方法

ユーザーモードの簡単なスピンロックは、InterlockedCompareExchange を使って実装できます。値が0なら1へ変更して取得成功、すでに1なら再試行します。再試行の間に YieldProcessor や _mm_pause 相当の命令を入れると、CPUパイプラインや電力への負担を抑えられる場合があります。

struct SpinLock {
    volatile LONG value = 0;

    void lock() {
        while (InterlockedCompareExchange(&value, 1, 0) != 0) {
            YieldProcessor();
        }
    }

    void unlock() {
        InterlockedExchange(&value, 0);
    }
};

この例は概念を示す最小構成です。実際のコードでは、スレッドがロックを二重取得しないこと、例外時にも必ず解放すること、ロック保持中にスレッドがプリエンプトされる可能性を考慮します。スピンロックは待機時間の上限を持たないため、デバッグ時には取得開始時刻を記録し、異常な保持時間を検出すると原因を追いやすくなります。

用途別の選択基準

クリティカルセクションは、競合時にOSの待機機構へ移れるため、ロック保持時間が読みにくい処理にも対応しやすい同期手段です。スピンロックは、共有変数の更新など数命令程度の処理で、ロック保持時間がほぼ一定の場合に候補になります。

比較項目 クリティカルセクション スピンロック
待機方法 必要に応じてスレッドを待機 CPU上で再試行
得意な処理 保持時間が短〜中程度の処理 ごく短い処理
CPU消費 競合が長いと抑えやすい 競合中も消費しやすい
実装の安全性 Windows APIで管理しやすい 解放漏れや無限ループに注意
主な設定 スピン回数を調整可能 再試行と退避処理を設計
適用範囲 一般的なユーザーモード処理 時間制約が明確な小規模処理

例えば、共有キャッシュのインデックスを短時間更新するだけならスピンロックが候補になります。反対に、ロック内でメモリ確保、ログ出力、外部API呼び出しを行うなら、処理設計を見直したうえでクリティカルセクションを使うほうが現実的です。オンラインサービスの同時処理を考える場合も、共有状態の保護と外部処理の分離が基本であり、関連分野のオンライン処理の解説を見る際にも同じ観点を応用できます。

デッドロックと性能低下を防ぐ

複数のクリティカルセクションを使う場合は、取得順序を全スレッドで統一します。スレッドAがロック1を取得してからロック2を待ち、スレッドBがロック2を取得してからロック1を待つと、双方が永久に進めなくなります。関数ごとのロック階層を決め、逆順取得を禁止すると予防になります。

再帰的に同じスレッドが同じクリティカルセクションへ入る必要がある場合、通常のクリティカルセクションは再入を許します。ただし、再入した回数と同じ回数だけ LeaveCriticalSection が必要です。この性質は便利である一方、意図しない再帰を隠すことがあるため、設計上の依存関係を明確にしておきます。

スピンロックでは、所有者が停止したり優先度の低いスレッドになったりすると、他のCPUが長時間ループする危険があります。ロック保持中のページフォルトや割り込み、スレッド切り替えも性能に影響するため、ベンチマークでは平均値だけでなく最大待機時間も測定します。

実装時の確認項目

実際に導入する際は、共有データの寿命とロックの寿命を一致させることが大切です。DLLのアンロード中やワーカースレッド終了前に DeleteCriticalSection を実行すると、解放後のアクセスが発生する可能性があります。終了処理では、利用者が残っていないことを確認してから破棄します。

最初から自作スピンロックを選ぶのではなく、まずクリティカルセクションで正しい排他を実装し、プロファイリングでボトルネックを特定する方法が堅実です。短いアトミック操作で置き換えられる部分には、ロックフリー設計や Interlocked 系関数が適する場合もあります。

覚えておくべき点は、InitializeCriticalSection は安全な既定の選択肢であり、スピンロックは保持時間が極端に短い場合に限定して検討する仕組みだということです。性能はスピン回数や命令数だけでなく、競合、CPU数、スレッドのスケジューリング、ロックの寿命まで含めて判断します。