CAS の正式名は Compare And Swap で、同時プログラミングにおける重要な概念です。この記事では、Java のマルチスレッド操作を組み合わせて CAS アルゴリズムを説明します。
CAS アルゴリズムの利点は、ロックを行わずにスレッドの安全性を確保できるため、スレッド間の競合とオーバーヘッドが軽減されることです。
目次
2. CAS 疑似コード (CAS を関数として想像した場合)
2. ABA の問題を解決する - バージョン番号を使用する
1. CASアルゴリズムの内容
1. 基本的な考え方と手順
CAS アルゴリズムの基本的な考え方は、まずメモリ M の値とレジスタ A の値 (古い期待値、expectValue) を比較してそれらが等しいかどうかを確認し、等しい場合はレジスタ B に値を書き込むことです。 (新しい値、swapValue) をメモリに取り込みます。; 等しくない場合、操作は実行されません。プロセス全体はアトミックであり、他の同時操作によって中断されることはありません。
これにはメモリとレジスタ値の「交換」が含まれますが、多くの場合、レジスタに格納されている値ではなく、メモリ内の値が重要です (メモリに格納されている値は、変数)。したがって、ここでの「交換」は交換とみなす必要はなく、直接代入操作とみなすことができます。つまり、レジスタ B の値がメモリ M に直接代入されます。
CAS 操作は、メモリ位置 (通常は共有変数)、期待値、および新しい値の 3 つのオペランドで構成されます。CAS 操作の実行プロセスは次のとおりです。
- メモリ位置の現在値を読み取ります。
- 現在の値と期待値が等しいかどうかを比較します。
- 等しい場合、新しい値がメモリの場所に書き込まれますが、そうでない場合、操作は失敗します。
2. CAS 疑似コード (CAS を関数として想像した場合)
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
上記のコードは単なる疑似コードであり、実際の CAS コードではありません。実際、CAS 操作は CPU ハードウェアによってサポートされるアトミック ハードウェア命令であり、この 1 つの命令で上記のコードの機能を完了できます。
「命令」と「コードの一部」の最大の違いは、原子性です。上記の疑似コードはアトミックではないため、動作中にスレッド スケジューリングでスレッド セーフティの問題が発生する可能性がありますが、アトミック命令にはスレッド セーフティの問題はありません。
同時に、CAS にはメモリの可視性の問題がありません。メモリの可視性は、コンパイラが一連の命令を調整し、メモリを読み取る命令をレジスタを読み取る命令に調整するのと同じです。ただし、CAS 自体は命令レベルでメモリを読み取る操作であるため、メモリの可視性によって引き起こされるスレッドのセキュリティの問題は発生しません。
したがって、CAS はロックを行わなくてもスレッドの安全性をある程度確保できます。これにより、CAS アルゴリズムに基づく一連の操作が行われます。
2. CASアルゴリズムの適用
CAS を使用すると、ロックフリー プログラミングを実装できます。アトミック クラスの実装とスピン ロックの実装は、ロックフリー プログラミングの 2 つの方法です。
1. アトミッククラスを実装する
標準ライブラリjava.util.concurrent.atomicパッケージには、他の操作のアトミック性を確保するために(ロックを使用する代わりに)非常に効率的なマシンレベルの命令を使用するクラスが多数あります。
たとえば、Atomiclntegerクラスには、incrementAndGet、 getAndIncrement 、decrementAndGet、およびgetAndDecrement メソッドが用意されており、これらはそれぞれ整数をアトミックにインクリメントまたはデクリメントします。
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(()->{
//num++
num.getAndIncrement();
//++num
num.incrementAndGet();
//num--
num.getAndDecrement();
//--num
num.decrementAndGet();
});
たとえば、次のように数値シーケンスを安全に生成できます。
import java.util.concurrent.atomic.AtomicInteger;
public class Test2 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
//num++
num.getAndIncrement();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
//num++
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num.get());
}
}
実行結果: num の最終値はちょうど 100000 です。
これは、 getAndIncrement() メソッドが num の値をアトミックに取得し、 num をインクリメントするためです。つまり、値を取得し、それを増分して設定し、新しい値を生成するという操作は中断されません。複数のスレッドが同じインスタンスに同時にアクセスした場合でも、正しい値が計算されて返されることが保証されます。
ソース コードを見ると、 getAndIncrement() メソッドがロック (同期) を使用していないことがわかります。
しかし、その後 getAndAddInt メソッドを入力すると、CAS アルゴリズムが使用されていることがわかります。
CompareAndSwapInt メソッドに入ると、これがネイティブによって変更されたメソッドであることがわかります。CAS アルゴリズムの実装は、基盤となるハードウェアとオペレーティング システムによって提供されるアトミック操作サポートに依存しているため、より低レベルの操作になります。
補遺 - 対照的なスレッド非安全なケースは次のとおりです。
以下はスレッドの安全性の例です。このコードでは、カウンター変数が作成され、2 つのスレッド t1 と t2 がそれぞれ作成されるため、これら 2 つのスレッドは同じカウンターを 50,000 回インクリメントできます。
class Counter { private int count = 0; //让count增加 public void add() { count++; } //获得count public int get() { return count; } } public class Test { public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); // 创建两个线t1和t2,让这两个线程分别对同一个counter自增5w次 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.add(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.add(); } }); t1.start(); t2.start(); // main线程等待两个线程都执行结束,然后再查看结果 t1.join(); t2.join(); System.out.println(counter.get()); } }
論理的に言えば、カウンタ出力の最終結果は 100,000 回になるはずです。しかし、プログラムを実行した後、結果が 10w ではなかっただけでなく、実行するたびに結果が異なり、実際の結果はランダムな値のように見えたことがわかりました。
スレッドのランダムなスケジューリングのため、count++ ステートメントはアトミックではなく、基本的に 3 つの CPU 命令で構成されます。
- 負荷。メモリ内のデータを CPU レジスタに読み取ります。
- 追加。レジスタの値に対して +1 演算を実行します。
- 保存。レジスタの値をメモリに書き込みます。
CPU は、この自己インクリメント操作を 3 つのステップで完了する必要があります。シングルスレッドの場合は、この 3 つの手順で問題ありませんが、マルチスレッド プログラミングの場合は状況が異なります。マルチスレッドのスケジューリング順序は不確実であるため、実際の実行プロセスでは、2 つのスレッドの count++ 操作の命令の順序にはさまざまな可能性があります。
上記は可能性のほんの一部を列挙したものにすぎず、実際にはさらに多くの可能性が考えられます。配置順序が異なると、プログラムの実行結果がまったく異なる場合があります。たとえば、次の 2 つの状況の実行プロセスが考えられます。
したがって、 スレッドの実際のスケジューリング順序が乱れているため、自動インクリメント プロセス中に 2 つのスレッドが何を経験したかを確認することはできません。また、「順次実行」される命令の数と「時間差実行」される命令の数も確認できません。 」。最終結果は変化する値となり、カウントは 10w 以下でなければなりません。
*アトミッククラスを実装するための疑似コード
コード:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
上記のコードでは、oldValue に value を代入した直後に、value と oldvalue が等しいかどうかを比較しているように見えますが、比較結果が等しくない場合があります。これはマルチスレッド環境にあるためです。value はメンバー変数です。2 つのスレッドが同時に getAndIncrement メソッドを呼び出すと、不平等が発生する可能性があります。実はここでのCASとは現在値が変化したかどうかを確認するものです。変更されていない場合は増分できますが、変更されている場合は、まず値を更新してから増分できます。
以前のスレッドは安全ではありませんでした。主な理由は、あるスレッドが別のスレッドによるメモリの変更を時間内に検出できないことです。
t1 が最初に読み取られてからインクリメントされていたため、以前はスレッドは安全ではありませんでした。このとき、t1 がインクリメントされる前に、すでに t2 がインクリメントされていますが、t1 は初期値 0 に基づいてインクリメントされます。このとき問題が発生します。
ただし、CAS 演算では、自動インクリメントを実行する前に、t1 がレジスタとメモリ内の値が一致するかどうかを比較し、一致する場合にのみ自動インクリメントが実行され、一致しない場合はメモリ内の値がそのまま実行されます。レジスタに再同期されます。
この操作にはブロック待機が含まれないため、以前のロック ソリューションよりもはるかに高速になります。
2.スピンロックを実装する
スピン ロックはビジー待機ロック メカニズムです。スレッドがスピン ロックを取得する必要がある場合、スレッドはすぐにブロックされるのではなく、ロックが利用可能かどうかを繰り返し確認します。ロックの取得が失敗した場合 (ロックがすでに別のスレッドによって占有されている場合)、現在のスレッドはすぐに再度ロックの取得を試み、ロックが取得されるまでロックが解放されるのを待ちながらスピン (アイドル) を続けます。ロックを取得する最初の試行は失敗し、2 回目の試行は非常に短い時間内に行われます。これにより、他のスレッドによってロックが解放された後、現在のスレッドができるだけ早くロックを取得できるようになります。一般に、楽観的ロック (ロック競合の可能性が低い) の場合は、スピン ロックを実装する方が適切です。
*スピンロックを実装するための疑似コード
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有
// 如果这个锁已经被别的线程持有, 那么就自旋等待
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
3. CASのABA問題
CAS の ABA 問題は、CAS を使用するときに遭遇する古典的な問題です。
CAS の鍵は、メモリとレジスタの値を比較してそれらが同じかどうかを確認することであることが知られており、この比較によってメモリ内の値が変化したかどうかを判断します。しかし、比較対象は同じでも、実際にはメモリ内の値は変化しておらず、A 値から B 値に変化し、その後 A 値に戻った場合はどうなるでしょうか。
このとき、一定の確率で何らかの問題が発生する可能性があります。この状況は ABA 問題と呼ばれます。CASは値が同じかどうかを比較することしかできず、途中で値が変わったかどうかを判断することはできません。
これは、ある魚のウェブサイトから携帯電話を購入するようなものですが、その携帯電話が工場から出荷されたばかりの新しい携帯電話なのか、それとも他人が使用して再生した携帯電話なのかがわかりません。
1. ABAの問題によるバグ
実際、ほとんどの場合、ABA の問題はほとんど影響しません。ただし、いくつかの特殊なケースを除外することはできません。
Xiao Ming が 100 の預金を持っていると仮定します。彼はATMから50元を引き出したいと考えています。現金自動預け払い機は 2 つのスレッドを作成し、それらを同時に実行します -50
(口座から50元引き落とし) この操作。
2 つのスレッドのうち 1 つは -50 で正常に実行され、もう 1 つのスレッドは -50 で失敗すると予想されます。CAS を使用してこの控除プロセスを完了すると、問題が発生する可能性があります。
通常のプロセス
- 100をデポジットします。スレッド1 は現在のデポジット値を100 に取得し、期待値は 50 に更新されます。スレッド2は現在のデポジット値を 100 に取得し、期待値は50 に更新されます。
- スレッド1 は控除を正常に実行し、デポジットは50 に変更されます。スレッド2はブロックされて待機しています。
- スレッド2 が実行する番ですが、現在のデポジットが50であることがわかり、以前に読み取られた100とは異なるため、実行は失敗します。
異常なプロセス
- 100をデポジットします。スレッド1 は現在のデポジット値を100 に取得し、期待値は 50 に更新されます。スレッド2は現在のデポジット値を 100 に取得し、期待値は50 に更新されます。
- スレッド1 は控除を正常に実行し、デポジットは50 に変更されました。スレッド2はブロックされて待機しています。
- スレッド2が実行される前に、シャオ ミンの友人がシャオ ミンに 50 を送金したところ 、この時点でシャオ ミンのアカウントの残高は再び 100 になりました。
- スレッド2 が実行する番になり、現在のデポジットが100 であることがわかり、これは前に読み取られた100と同じであり、減算操作が再度実行されます。
このとき、減算操作が2回行われました!これはすべてABAの問題が原因です。
2. ABA の問題を解決する - バージョン番号を使用する
ABA 問題の鍵は、メモリ内の共有変数の値が繰り返しジャンプすることです。データが一方向にのみ変更できることが合意されれば、問題は解決されます。
これにより、「バージョン番号」という概念が導入されます。バージョン番号はインクリメントのみが可能であることが合意されています (変数が変更されるたびに、バージョン番号が追加されます)。また、CAS が比較するたびに、値自体ではなくバージョン番号が比較されます。このようにして、他のスレッドは CAS 操作の実行時にバージョン番号が変更されたかどうかを確認できるため、ABA の問題の発生を回避できます。
(変数値ではなく、バージョン番号が基準として使用されます。バージョン番号はインクリメントのみが可能であることが合意されているため、ABA のような水平方向のジャンプが繰り返されることはありません。)
しかし、実際の場面では、たとえABAの問題に遭遇しても、問題にならないことがほとんどです。バージョン番号を知るだけで、ABA の問題を解決できます。