14 - マルチスレッド ロックの最適化 (パート 2): オプティミスティック ロックを使用した並列操作の最適化

最初の 2 つの講義では、Synchronized と Lock によって実装された同期ロック メカニズムについて説明しました。どちらの同期ロックも悲観的ロックであり、スレッドの安全性を保護する最も直感的な方法です。

ペシミスティック ロックを使用した同時実行性の高いシナリオでは、激しいロック競合によりスレッド ブロックが発生することがわかっており、ブロックされたスレッドの数が多いとシステム コンテキストの切り替えが発生し、システム パフォーマンスのオーバーヘッドが増加します。スレッドの安全性を確保するためにノンブロッキング ロック メカニズムを実装することは可能ですか? 答えは「はい」です。今日は、楽観的ロックの最適化方法を学び、それを使用してその価値を最大化する方法を見ていきます。

1. 楽観的ロックとは何ですか?

最適化を始める前に、楽観的ロックの定義を簡単に確認してみましょう。

楽観的ロックとは、その名のとおり、共有リソースを操作する際に、常に楽観的な姿勢で操作を行い、操作が正常に完了すると信じ込むことを意味します。しかし実際には、複数のスレッドが共有リソースを同時に操作する場合、成功するのは 1 つのスレッドだけなので、失敗したスレッドはどうなるでしょうか? これらは悲観的ロックのようにオペレーティング システムでハングするのではなく、復帰するだけであり、システムは失敗したスレッドの再試行を許可し、終了操作を自動的に放棄することもできます。

したがって、悲観的ロックと比較して、楽観的ロックはデッドロック、飢餓、その他の活断層の問題を引き起こすことがなく、スレッド間の相互影響も悲観的ロックよりもはるかに小さいです。さらに重要なことは、楽観的ロックには競合によるシステム オーバーヘッドがないため、パフォーマンスが優れていることです。

2. 楽観的ロックの実装原理

上記の内容はある程度理解できたと思いますが、最適化手法を根本的にまとめるために、楽観的ロックの実装原理を見ていきましょう。

CAS は楽観的ロックを実装するためのコア アルゴリズムであり、V (更新が必要な変数)、E (期待値)、N (最新値) の 3 つのパラメーターが含まれています。

更新が必要な変数が期待値と等しい場合にのみ、更新が必要な変数は最新の値に設定されます 更新値が期待値と異なる場合は、すでに他のスレッドが更新していることを意味します更新する必要がある変数。この時点では、現在のスレッドは何も行いません。操作は、V の真の値を返します。

2.1. CAS がアトミック操作を実装する方法

JDK の並行パッケージでは、アトミック パス以下のクラスはすべて CAS に基づいて実装されます。AtomicInteger は、CAS に基づいて実装されたスレッドセーフな整数クラスです。CAS を使用して、ソース コードを通じてアトミック操作を実装する方法を学びましょう。

AtomicInteger の自動インクリメント メソッド getAndIncrement が Unsafe の getAndAddInt メソッドを使用していることがわかります。明らかに AtomicInteger はローカル メソッド Unsafe クラスに依存しています。Unsafe クラスの操作メソッドは CPU の基礎となる命令を呼び出してアトミック操作を実装します。

    // 基于 CAS 操作更新值
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
    // 基于 CAS 操作增 1
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    
    // 基于 CAS 操作减 1
    public final int getAndDecrement() {
        return unsafe.getAndAddInt(this, valueOffset, -1);

2.2. プロセッサはアトミック操作をどのように実装しますか?

CAS は、プロセッサの基礎となる命令を呼び出してアトミック操作を実装します。では、基礎となるプロセッサはどのようにしてアトミック操作を実装するのでしょうか?

プロセッサと物理メモリ間の通信は、プロセッサ間の処理よりもはるかに遅いため、プロセッサには独自の内部キャッシュがあります。次の図に示すように、操作が実行されると、頻繁に使用されるメモリ データがプロセッサの L1、L2、および L3 キャッシュにキャッシュされ、頻繁な読み取りが高速化されます。

通常、シングルコア プロセッサは、基本的なメモリ操作がアトミックであることを保証できます。スレッドがバイトを読み取るとき、すべてのプロセスとスレッドは同じキャッシュからのバイトを参照します。他のスレッドはこのバイトのメモリ アドレスにアクセスできません。

しかし、今日のサーバーは通常マルチプロセッサであり、各プロセッサはマルチコアです。各プロセッサは 1 バイトのメモリを維持し、各コアは 1 バイトのキャッシュを維持しますが、このとき、マルチスレッドの同時実行によりキャッシュの不整合が発生し、データの不整合が発生します。

現時点では、プロセッサは、複雑なメモリ操作のアトミック性を確保するために、バス ロックキャッシュ ロックという2 つのメカニズムを提供します。

プロセッサがシェア変数を操作したい場合、バス上に Lock 信号を送信しますが、このとき他のプロセッサはシェア変数を操作できず、プロセッサはこの共有メモリ内の変数を排他的に所有することになります。ただし、バス ロックが共有変数を取得するための他のプロセッサの操作要求をブロックすると、多数のブロックが発生し、システムのパフォーマンスのオーバーヘッドが増加する可能性があります。

したがって、新しいプロセッサはキャッシュ ロック メカニズムを提供します。これは、プロセッサがキャッシュ内の共有変数を操作するときに、他のプロセッサに共有リソースの保存を放棄するか、共有リソースを再読み取るように通知することを意味します。現在、最新のプロセッサはキャッシュ ロック メカニズムをサポートしています。

3. CAS オプティミスティック ロックを最適化する

同時実行パフォーマンスの点では、楽観的ロックは悲観的ロックよりも優れていますが、読み取りよりも書き込みが多い操作シナリオでは、CAS が失敗する可能性が高くなります。CAS 操作を放棄しない場合は、CAS の再試行を繰り返す必要があります。これは間違いなく長時間 CPU を占有します。

Java7 では、次のコードからわかります: AtomicInteger の getAndSet メソッドで for ループが使用され、CAS 操作を継続的に再試行します。長時間失敗すると、CPU に非常に大きな実行オーバーヘッドが生じます。Java 8 では、for ループは削除されていますが、Unsafe クラスを逆コンパイルすると、ループが実際には Unsafe クラスにカプセル化されており、CPU 実行のオーバーヘッドがまだ存在していることがわかります。

   public final int getAndSet(int newValue) {
        for (;;) {
            int current = get();
            if (compareAndSet(current, newValue))
                return current;
        }
    }

JDK1.8 では、Java は新しいアトミック クラス LongAdder を提供します。LongAdder は、より多くのメモリ領域を消費しますが、同時実行性が高いシナリオでは AtomicInteger や AtomicLong よりも優れたパフォーマンスを発揮します。

LongAdder の原理は、共有変数に対する同時操作の数を減らすことです。つまり、単一の共有変数の操作圧力を複数の変数値に分散し、競合する各書き込みスレッドの値を配列に分散し、異なるスレッドは配列の異なるスロットでヒットします, 各スレッドは独自のスロットの値に対して CAS 操作のみを実行します. 最後に, 値を読み取るときに, アトミック操作の共有変数が分散された各値に追加されます配列を使用すると、ほぼ正確な値が返されます。

LongAdder は内部的に基本変数と cell[] 配列で構成されます。書き込みスレッドが 1 つだけで競合がない場合、LongAdder はベース変数をアトミック操作変数として直接使用し、CAS 操作を通じて変数を変更します。ベースを占有する 1 つの書き込みスレッドに加えて、複数の書き込みスレッドが競合している場合は、 variable を使用すると、他のスレッドはそれぞれ、変更された変数を独自のスロット cell[] 配列に書き込み、最終結果は次の式で計算できます。

操作後の LongAdder の戻り値はほぼ正確な値にすぎないことがわかりますが、LongAdder は最終的には正確な値を返すため、高いリアルタイム パフォーマンスが必要な一部のシナリオでは、LongAdder は AtomicInteger または AtomicLong を置き換えることはできません。

4. まとめ

日常の開発において、オプティミスティック ロックを使用する最も一般的なシナリオは、データベースの更新操作です。データベースの操作の原子性を確保するために、データごとにバージョン番号を定義し、更新前にバージョン番号を取得することがよくありますが、データベースを更新する際には、取得したバージョン番号が更新されているかどうかも確認する必要があります。そうでない場合は、この操作を実行します。

CAS オプティミスティック ロックは、通常の使用では比較的制限されています。保証できるのは 1 つの変数操作のアトミック性のみです。複数の変数が関係する場合、CAS は無力です。ただし、最初の 2 つの講義で述べたペシミスティック ロックは、全体をロックすることで実行できます。コード ブロック。これを行うにはロックを追加します。

CAS オプティミスティック ロック 同時書き込みが読み取りよりも多いシナリオでは、ほとんどのスレッドのアトミック操作が失敗し、失敗したスレッドは引き続き CAS アトミック操作を再試行します。これにより、大量のスレッドが CPU リソースを占有することになります。長時間使用すると、システムに大きな問題が発生し、パフォーマンスに多大なオーバーヘッドが生じます。JDK1.8 では、Java に新しいアトミック クラス LongAdder が追加されました。これは、space-for-time メソッドを使用して上記の問題を解決します。

第 11 章から第 13 章では、JVM に基づいて実装される同期ロック、AQS によって実装される同期ロック、CAS によって実装される楽観的ロックについて詳しく説明しました。これら 3 つのロックのどれが最もパフォーマンスが優れているかについても知りたいと思いますので、3 つの異なる実装方法でロックのパフォーマンスを比較してみましょう。

実際のビジネス シナリオから切り離されたパフォーマンス比較テストには意味がないため、「読み取りが多く、書き込みが少ない」、「読み取りが少なく、書き込みが多い」、「ほぼ読み取りと書き込み」の 3 つのシナリオでテストを実行できます。また、錠の性能は競技の激しさにも関係するため、さらに競技レベルの異なる3種類の錠の性能試験も実施します。

上記の条件に基づいて、Synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock、楽観的ロック LongAdder の 4 つのモードで 5 つのロックに対してストレス テストを実行します。

簡単な説明は次のとおりです: 異なる競争レベルで、異なる数の読み取りおよび書き込みスレッドを使用して 4 つのテスト セットを構成しました。テスト コードは、同時カウンタの計算に使用されます。読み取りスレッドはカウンタ値を読み取り、書き込みスレッドは読み取りを行います。カウンタ値を操作して変更しますが、動作環境は4コアi7プロセッサです。結果が示されており、Githubをクリックすると、特定のテスト コードを表示およびダウンロードできます。

上記の結果から、読み取りが書き込みよりも大きいシナリオでは、読み取り/書き込みロック ReentrantReadWriteLock、StampedLock、およびオプティミスティック ロックの読み取りおよび書き込みパフォーマンスが最高であり、書き込みが読み取りよりも大きいシナリオでは、オプティミスティック ロックのパフォーマンスが最高です。他の 4 つのロックのパフォーマンスも同様です。読み取りと書き込みが同様のシナリオでは、2 つの読み取り/書き込みロックとオプティミスティック ロックのパフォーマンスが Synchronized ロックや ReentrantLock よりも優れています。

5. 考える質問

CAS 操作を使用するときに注意すべき ABA の問題は何ですか?

おすすめ

転載: blog.csdn.net/qq_34272760/article/details/132714044