InterlockedIncrementで安全にカウンターを更新する方法
複数のスレッドが同じカウンターを更新するとき、単純な加算処理では値が失われることがあります。counter++ は一つの命令に見えても、実際には値の読み出し、加算、書き戻しという複数の段階で構成されるためです。
Windows APIには、この競合を避けるためのインターロック関数が用意されています。InterlockedIncrement は共有変数をアトミックに1増加させ、更新後の値を返します。ロックを取得して解放する処理より軽量に使えるため、参照数や処理件数の管理に適しています。
ただし、アトミック操作はあらゆるスレッド同期を代替するものではありません。対象変数の型、メモリ順序、オーバーフロー、複数の状態を同時に扱う処理まで確認する必要があります。
| 操作 | 主な用途 | 戻り値 | 適した場面 |
|---|---|---|---|
InterlockedIncrement |
1増加 | 更新後の値 | 件数、参照数 |
InterlockedDecrement |
1減少 | 更新後の値 | 参照数の解放 |
InterlockedExchangeAdd |
任意値を加算 | 加算前の値 | 連番範囲の予約 |
InterlockedCompareExchange |
条件付き更新 | 交換前の値 | ロックフリー構造 |
InterlockedExchange |
値の交換 | 交換前の値 | フラグやポインターの更新 |
基本動作と戻り値
代表的な宣言は次の形です。
LONG InterlockedIncrement(
volatile LONG* Addend
);
引数には、Windows APIで定義されたLONG型の変数のアドレスを渡します。関数は現在値をアトミックに1増加させ、その増加後の値を返します。複数スレッドが同時に呼び出しても、同じ値を二つのスレッドが取得することはありません。
volatile LONG requestCount = 0;
LONG current = InterlockedIncrement(&requestCount);
volatileはコンパイラーによる不要な読み書きの省略を抑制しますが、それだけでスレッド安全になるわけではありません。実際に競合を防ぐ役割を担うのは、インターロック命令による不可分な更新です。関連するWindows APIの調査には、Windows APIの資料も参照できます。
通常の加算との違い
次のコードは、複数スレッドから同時に実行されると正しい結果にならない可能性があります。
++requestCount;
あるスレッドが値を読み取った直後に別のスレッドも同じ値を読み取ると、両方が同じ計算結果を書き戻します。たとえば初期値が10の場合、二回加算した結果が11になるという「更新の取りこぼし」が発生します。
InterlockedIncrementでは、読み出しと加算と書き戻しが一つのアトミック操作として扱われます。ただし、増加した後に別の変数を確認する、条件に応じて複数のフィールドを変更するといった複合処理は、関数一回だけでは保護できません。その場合はクリティカルセクションやミューテックスなどを検討します。
共有カウンターの設計
共有カウンターは、処理件数、アクティブな利用者数、オブジェクトの参照数など、単一の整数を安全に増減したい場面で有効です。特に更新頻度が高く、短時間の加算だけで処理が終わる場合は、毎回ロックを取得するより実装が簡潔になります。
設計時には、カウンターの用途と読み取り方を明確にしておくことが大切です。インクリメント自体は安全でも、通常の代入や非アトミックな読み取りを混在させると、期待した同期状態を保てません。
- 増加と減少を同じ種類のインターロック関数で統一する
- カウンターの所有者と寿命を明確にする
- 更新後の戻り値を必要な判定にそのまま利用する
- 終了条件とオーバーフローの扱いをあらかじめ決める
たとえば参照カウントでは、InterlockedIncrementで参照を追加し、解放時にInterlockedDecrementを使います。減少後の値が0になったスレッドだけが破棄処理を担当する設計にすると、二重解放を避けやすくなります。
メモリ順序と可視性
インターロック関数は単なる数値演算ではなく、スレッド間のメモリ可視性にも関係します。あるスレッドがデータを準備してからカウンターを増加させ、別のスレッドがその値を確認してデータを読むような場合、更新順序を意識しなければなりません。
Windowsには通常のインターロック関数に加え、取得側または解放側の順序を意識した関数もあります。必要以上に強いメモリバリアを使うと性能に影響する可能性がある一方、弱い順序を選ぶと共有データの公開条件を満たせません。ファイル処理の並行化を考えるときは、ReadFileとWriteFileの実装のように、データの受け渡し順序も確認すると整理しやすくなります。
カウンターの値だけを統計情報として更新する場合は、厳密なデータ公開順序が不要なこともあります。しかし、カウンターを「キューにデータが存在する」という通知として使う場合は、書き込み完了とカウンター更新の順序を設計書に記録しておくべきです。
APIの選び分け
1だけ増やすならInterlockedIncrement、1だけ減らすならInterlockedDecrementが自然です。任意の値を加算しながら、加算前の値を基準に範囲を予約したい場合はInterlockedExchangeAddが適しています。たとえば、バッファ内で10バイト分の領域を確保する用途では、戻り値を開始位置として利用できます。
条件を満たすときだけ更新したい場合は、InterlockedCompareExchangeを使います。現在値を確認して期待値と一致した場合だけ新しい値に置き換えるため、簡単なロックフリーアルゴリズムの基本部品になります。ただし、再試行ループやABA問題への対応が必要になることがあります。
64ビット環境で大きな件数を扱う場合は、InterlockedIncrement64などの64ビット版を選びます。型を混在させず、宣言、表示、初期化、API呼び出しのすべてで同じ幅を保つことが重要です。
実装時に確認したい点
コードが動作していても、型や境界条件の問題が残っていると長時間稼働後に障害となります。特に参照カウントや統計値は、テスト開始時には小さくても運用中に大きく増える可能性があります。
- 対象変数を適切にアラインメントする
- 32ビット値と64ビット値のAPIを取り違えない
- 戻り値が増加前か増加後かを確認する
- 符号付き整数の上限と下限を監視する
- デバッグ用の通常加算を本番コードに残さない
アトミック操作は、処理全体を自動的に安全にする機能ではありません。カウンターの読み取り結果を使って別の変数を更新するなら、その区間全体に同期が必要になる場合があります。また、インターロック関数を使っているからといって、変数の寿命が保証されるわけでもありません。解放済みメモリへのアクセスは、別の同期設計で防止します。
性能確認とデバッグ
短い加算処理ではインターロック関数が有効ですが、同じ変数へ多数のスレッドが集中すると、キャッシュラインの競合が性能上のボトルネックになります。カウンターをスレッドごとに分け、最後に集計する方法が速い場合もあります。正確な即時値が必要か、最終的な合計だけでよいかを基準に選びます。
メモリ確保や共有領域と組み合わせる場合は、カウンターと領域の寿命を同時に追跡します。Windowsの仮想メモリ管理を扱う際には、VirtualAllocのメモリ管理についても確認すると、確保、公開、解放の順序を検討しやすくなります。
デバッグでは、更新前後の値、スレッドID、呼び出し箇所を記録すると競合の追跡に役立ちます。最終的に覚えておきたいのは、InterlockedIncrementは共有整数を一回の操作として安全に増加させる手段であり、複数データの整合性やオブジェクトの寿命まで自動的に管理する仕組みではないという点です。