アトミック操作を使用して、排他制御の問題を回避することができます。スレッドがアトミック操作を行うと、その他のスレッドではその操作が瞬時に発生したかのように認識されます。アトミック操作の長所は、ロックに比べて比較的処理が速く、デッドロックやコンボイなどを回避できる点にあります。短所は、限られた操作しか行うことができず、より複雑な操作を効率良く総合的に扱うには不十分なことです。しかし、排他制御の代わりにアトミック操作を使用する機会を逃す手はありません。atomic<T> クラスは、C++ スタイルでアトミック操作を実装します。
アトミック操作の典型的な使用方法は、スレッドセーフな参照カウントに使用することです。x が int 型の参照カウントで、プログラムでは参照カウントが 0 になったときに何らかのアクションが必要だとします。シングルスレッド・コードでは、x に int を使用し、--x; if(x==0) action() を書き込みます。しかし、このメソッドは、次の表で示されるように、2 つのスレッドが自身の操作をインターリーブするため、マルチスレッド・コードでは失敗する可能性があります。次の表で示されているように、ta と tb はマシンレジスターで、時間は下方向に経過します。
スレッド A |
スレッド B |
---|---|
ta = x |
|
|
tb = x |
x = ta - 1 |
|
|
x = tb – 1 |
if( x==0 ) |
|
|
if( x==0 ) |
コードでは x が 2 回デクリメントされることになっていますが、x は、元の値と比べて 1 つしか減っていません。また、x のテストがデクリメントとは別に行われるため、ほかの問題も生じます。x が 2 から開始して、どちらかのスレッドが if 条件を評価する前に、両方のスレッドで x 回デクリメントする場合、両方の スレッドが action() を呼び出してしまいます。この問題を解決するには、一度に 1 つのスレッドのみがデクリメントを行い、さらに if によってチェックされる値がデクリメントの結果の値であることを保証しなければなりません。mutex を導入して行うことも可能ですが、x を atomic<int> として宣言し、if(--x==0) action() にすることで、より素早く簡単に行うことができます。atomic<int>::operator-- メソッドはアトミックに動作します。ほかのスレッドは介入できません。
atomic<T> は、整数型またはポインター型の T 型でアトミック操作をサポートします。5 つの基本的な操作がサポートされており、構文の便宜上、オーバーロードされた演算子の形式のインターフェイスが追加されています。例えば、atomic<T> の ++、--、-=、+= 演算子は、基本操作 フェッチ・アンド・アッド (fetch-and-add) の形式です。次の表は、atomic<T> 型の変数 x の 5 つの基本操作を示しています。
= x |
x の値を読み取ります。 |
x= |
x の値を書き込み、値を返します。 |
x.fetch_and_store(y) |
x=y を行い、x の古い値を返します。 |
x.fetch_and_add(y) |
x+=y を行い、x の古い値を返します。 |
x.compare_and_swap(y,z) |
x と z が等しい場合、x=y を行います。いずれの場合でも、x の古い値を返します。 |
これらの操作はアトミックに行われるため、排他制御がなくても安全に使用できます。次の例について考えてみます。
atomic<unsigned> counter; unsigned GetUniqueInteger() { return counter.fetch_and_add(1); }
GetUniqueInteger ルーチンは、カウンターが巡回するまで呼び出されるごとに異なる整数を返します。どれだけのスレッドが同時に GetUniqueInteger ルーチンを呼び出してもこれは変わりません。
compare_and_swap 操作は、多くの非ブロック・アルゴリズムにおける基本操作です。排他制御における問題は、ロックを保持しているスレッドが停止した場合に、ほかのすべてのスレッドは、保持しているスレッドが再開されるまでブロックされる点にあります。非ブロック・アルゴリズムでは、ロックではなく、アトミック操作を使用することによってこの問題を回避します。アトミック操作は、一般に複雑で、確認には洗練された解析が必要ですが、次の表現方法は、分かりやすいので知っておくと良いでしょう。この表現方法では、共有変数 globalx を古い値を基にして更新します。
atomic<int> globalx; int UpdateX() { // x を更新して x の古い値を返します。 do { // globalx を読む oldx = globalx; // 新しい値を計算 newx = ...expression involving oldx.... // 別のスレッドが globalX を変更していない場合、新しい値を格納します。 } while( globalx.compare_and_swap(newx,oldx)!=oldx ); return oldx; }
さらに、いくつかのスレッドは、ほかのスレッドが干渉しなくなるまでループを繰り返します。通常、更新に 2 から 3 個の命令しか必要ない場合は、この表現方法は、対応する排他制御よりも処理が高速です。
次のシーケンスが意図に反する場合は、この表現方法は適切ではありません。
スレッドが globalx から A の値を読み取る
ほかのスレッドが globalx を A から B へ、そして再び A へ変更する
ステップ 1 のスレッドは、自身の compare_and_swap を行い、A を読み取るため、B が変更されたことを認識しません。
この問題は、ABA 問題 と呼ばれます。リンクされたデータ構造に対する非ブロック・アルゴリズムの設計に問題があるケースが大半です。詳細は、インターネットで検索してください。