コンカレントプログラミング-マルチスレッドカウントのより良いソリューション:LongAdder原理分析

序文

最近、ConcurrentHashMapのソースコードを調べていたところ、マップ内の要素の数をカウントするためのよりユニークな方法を使用していることがわかりました。当然、その原理とアイデアを研究すると同時に、ConcurrentHashMap自体をよりよく理解する必要があります。

この記事の主なアイデアは、次の4つの部分に分かれています

1.カウントの効果

2.原理の直感的な説明

3.ソースコードの詳細な分析

4.AtomicIntegerとの比較

5.思考の抽象化

学習への入り口は当然地図のプット方法です

public V put(K key, V value) {
    return putVal(key, value, false);
}

putValメソッドを表示する

ConcurrentHashMap自体の原理についてはあまり議論されていないので、カウント部分に直接ジャンプします

final V putVal(K key, V value, boolean onlyIfAbsent) {
    ...
    addCount(1L, binCount);
    return null;
}

要素が正常に追加されるたびに、addCountメソッドが呼び出され、数値を1ずつ累積する操作が実行されます。これが調査の目標です。

ConcurrentHashMapの本来の目的は、マルチスレッドの同時シナリオでマップ操作を解決することであるため、値を追加するときにスレッドの安全性を考慮するのは自然なことです。

もちろん、マルチスレッド値の累積は、通常、同時プログラミングを学習するための最初のレッスンです。それほど複雑ではありません。AtomicIntegerまたはロックを使用して、この問題を解決できます。

ただし、この方法を見ると、比較的単純なはずの累積方法であることがわかりますが、その論理は非常に複雑に見えます。

ここでは、累積アルゴリズムのコア部分のみを投稿しました

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
                (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
                !(uncontended =
                        U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    ...
}

このロジックの実現について調べてみましょう。このアイデアは実際にはLongAdderクラスのロジックをコピーしたので、アルゴリズムの元のクラスを直接調べます。

1.LongAdderクラスの使用

まず、LongAdderの効果を見てみましょう。

LongAdder adder = new LongAdder();
int num = 0;

@Test
public void test5() throws InterruptedException {
    Thread[] threads = new Thread[10];
    for (int i = 0; i < 10; i++) {
        threads[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                adder.add(1);
                num += 1;
            }
        });
        threads[i].start();
    }
    for (int i = 0; i < 10; i++) {
        threads[i].join();
    }
    System.out.println("adder:" + adder);
    System.out.println("num:" + num);
}

出力結果

adder:100000
num:40982

加算器は使用効果の観点から累積的なスレッドの安全性を保証できることがわかります。

2.LongAdderの原理の直感的な理解

ソースコードをよりよく分析するには、その原理を直感的に理解する必要があります。そうしないと、コードを直接見ると混乱してしまいます。

LongAdderの数は主に2つのオブジェクトに分けられます

長いタイプのフィールド:ベース

Cellオブジェクトの配列であるCellオブジェクトは、カウント用の長いフィールド値を維持します

/**
 * Table of cells. When non-null, size is a power of 2.
 */
transient volatile Cell[] cells;

/**
 * Base value, used mainly when there is no contention, but also as
 * a fallback during table initialization races. Updated via CAS.
 */
transient volatile long base;

1

スレッドの競合がない場合、ベースフィールドで累積が発生します。これは、シングルスレッドの累積を2回実行するのと同じですが、ベースの累積はcas操作です。

1

スレッドの競合が発生すると、ベースのcas累積操作に失敗するスレッドが存在する必要があるため、最初にセルが初期化されているかどうかを判断し、初期化されていない場合は、長さ2の配列を初期化し、スレッドのハッシュ値に従って対応するものを見つけます。インデックスを配列し、インデックス付けされたCellオブジェクトに値を累積します(この累積はcasの操作でもあります)

1

合計3つのスレッドが競合している場合、最初のスレッドはベースのcasを正常に蓄積し、残りの2つのスレッドはCellアレイの要素を蓄積する必要があります。セル内の値の累積もcas演算であるため、2番目のスレッドと3番目のスレッドのハッシュ値に対応する配列インデックスが同じである場合、競合も発生します。2番目のスレッドが成功すると、最初のスレッドが成功します。 3つのスレッドは、独自のハッシュ値を再ハッシュします。取得した新しいハッシュ値が、要素がnullである別の配列添え字に対応する場合、新しいCellオブジェクトが追加され、値の値が累積されます。

1

スレッド4が同時に競合に参加している場合、スレッド4の場合、再ハッシュした後でも、casはスレッド3との競合に失敗する可能性があります。このとき、現在のアレイ容量がシステムで使用可能なCPUの数より少ない場合は、配列は展開されてから再度ハッシュされ、Cell配列に添え字オブジェクトを繰り返し蓄積しようとします。

1

上記は全体的な直感的な理解ですが、コードにはまだ学ぶ価値のある詳細がたくさんあるので、ソースコード分析のリンクを入力し始めます

3.ソースコード分析

入力方法はaddです

public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    /**
     * 这里优先判断了cell数组是否为空,之后才判断base字段的cas累加
     * 意味着如果线程不发生竞争,cell数组一直为空,那么所有的累加操作都会累加到base上
     * 而一旦发生过一次竞争导致cell数组不为空,那么所有的累加操作都会优先作用于数组中的对象上
     */
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        /**
         * 这个字段是用来标识在对cell数组中的对象进行累加操作时是否发生了竞争
         * 如果发生了竞争,那么在longAccumulate方法中会多进行一次rehash的自旋
         * 这个在后面的方法中详细说明,这里先有个印象
         * true表示未发生竞争
         */
        boolean uncontended = true;
        /**
         * 如果cell数组为空或者长度为0则直接进入主逻辑方法
         */
        if (as == null || (m = as.length - 1) < 0 ||
                /**
                 * 这里的getProbe()方法可以认为就是获取线程的hash值
                 * hash值与(数组长度-1)进行位与操作后得到对应的数组下标
                 * 判断该元素是否为空,如果不为空那么就会尝试累加
                 * 否则进入主逻辑方法
                 */
                (a = as[getProbe() & m]) == null ||
                /**
                 * 对数组下标的元素进行cas累加,如果成功了,那么就可以直接返回
                 * 否则进入主逻辑方法
                 */
                !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}

スレッドの競合がない場合、前の図の状況に対応して、最初のifで累積操作がcasBaseによって処理されます。

スレッドの競合が発生すると、累積操作はセル配列によって処理されます。これは、前に示したケース2に対応します(配列はlongAccumulateメソッドで初期化されます)。

次に、メソッドが比較的長いため、メインロジックメソッドを見ていきます。セクションごとに分析します。

longAccumulateメソッド

署名のパラメータ

xは累積される値を表します

fnは、累積する方法を示します。通常はnullを渡しますが、重要ではありません。

wasUncontendedは、外側のレイヤーの判断ロジックが複数の「または」であるため、外側のメソッドで競合の失敗が発生したかどうかを示します(as == null ||(m = as.length-1)<0 ||(a = as [ getProbe()&m])== null)、したがって、配列が空であるか、対応する添え字要素が初期化されていない場合、このフィールドはfalseのままになります

final void longAccumulate(long x, LongBinaryOperator fn,
                          boolean wasUncontended) {
  ...
}

まず、スレッドのハッシュ値が0であるかどうかを判別します。0である場合は、初期化、つまり再ハッシュを実行する必要があります。

その後、wasUncontendedはtrueに設定されます。これは、以前に競合したことがある場合でも、再ハッシュ後、競合しない要素を含む配列添え字を見つけることができると最初に想定するためです。

int h;//线程的hash值,在后面的逻辑中会用到
if ((h = getProbe()) == 0) {
    ThreadLocalRandom.current(); // force initialization
    h = getProbe();
    wasUncontended = true;
}

次に、エンドレスループがあります。エンドレスループには3つの大きなifブランチがあります。これらの3つのブランチのロジックは、アレイが初期化されていないときに機能します。アレイが初期化されると、すべてメインロジックに入るので、ここにメインロジックを配置します。それらを抽出し、後で別々に配置します。これにより、外側のブランチがアイデアに与える影響を回避することもできます。

/**
 * 用来标记某个线程在上一次循环中找到的数组下标是否已经有Cell对象了
 * 如果为true,则表示数组下标为空
 * 在主逻辑的循环中会用到
 */
boolean collide = false;
/**
 * 死循环,提供自旋操作
 */
for (; ; ) {
    Cell[] as;
    Cell a;
    int n;//cell数组长度
    long v;//需要被累积的值
    /**
     * 如果cells数组不为空,且已经被某个线程初始化成功,那么就会进入主逻辑,这个后面详细解释
     */
    if ((as = cells) != null && (n = as.length) > 0) {
        ...
        /**
         * 如果数组为空,那么就需要初始化一个Cell数组
         * cellsBusy用来标记cells数组是否能被操作,作用相当于一个锁
         * cells == as 判断是否有其他线程在当前线程进入这个判断之前已经初始化了一个数组
         * casCellsBusy 用一个cas操作给cellsBusy字段赋值为1,如果成功可以认为拿到了操作cells数组的锁
         */
    } else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
        /**
         * 这里就是初始化一个数组,不解释了
         */
        boolean init = false;
        try {                           
            if (cells == as) {
                Cell[] rs = new Cell[2];
                rs[h & 1] = new Cell(x);
                cells = rs;
                init = true;
            }
        } finally {
            cellsBusy = 0;
        }
        if (init)
            break;
        /**
         * 如果当前数组是空的,又没有竞争过其他线程
         * 那么就再次尝试去给base赋值
         * 如果又没竞争过(感觉有点可怜),那么就自旋
         * 另外提一下方法签名中的LongBinaryOperator对象就是用在这里的,不影响逻辑
         */
    } else if (casBase(v = base, ((fn == null) ? v + x :
            fn.applyAsLong(v, x))))
        break;                          // Fall back on using base
}

次に、セル配列の要素を蓄積する主なロジックを見てください

/**
 * 如果cells数组不为空,且已经被某个线程初始化成功,进入主逻辑
 */
if ((as = cells) != null && (n = as.length) > 0) {
    /**
     * 如果当前线程的hash值对应的数组元素为空
     */
    if ((a = as[(n - 1) & h]) == null) {
        /**
         * Cell数组并未被其他线程操作
         */
        if (cellsBusy == 0) {
            /**
             * 这里没有理解作者为什么会在这里初始化单个Cell
             * 作者这里的注释是Optimistically create,如果有理解的同学可以说一下
             */
            Cell r = new Cell(x);
            /**
             * 在此判断cell锁的状态,并尝试加锁
             */
            if (cellsBusy == 0 && casCellsBusy()) {
                boolean created = false;
                try {
                    /**
                     * 这里对数组是否为空等状态再次进行校验
                     * 如果校验通过,那么就将之前new的Cell对象放到Cell数组的该下标处
                     */
                    Cell[] rs;
                    int m, j;
                    if ((rs = cells) != null &&
                            (m = rs.length) > 0 &&
                            rs[j = (m - 1) & h] == null) {
                        rs[j] = r;
                        created = true;
                    }
                } finally {
                    cellsBusy = 0;
                }
                /**
                 * 如果创建成功,就说明累加成功,直接退出循环
                 */
                if (created)
                    break;
                /**
                 * 走到这里说明在判空和拿到锁之间正好有其他线程在该下标处创建了一个Cell
                 * 因此直接continue,不rehash,下次就不会进入到该分支了
                 */
                continue;
            }
        }
        /**
         * 当执行到这里的时候,因为是在 if ((a = as[(n - 1) & h]) == null) 这个判断逻辑中
         * 就说明在第一个if判断的时候该下标处没有元素,所以赋值为false
         * collide的意义是:上一次循环中找到的数组下标是否已经有Cell对象了
         * True if last slot nonempty
         */
        collide = false;
    /**
     * 这个字段如果为false,说明之前已经和其他线程发过了竞争
     * 即使此时可以直接取尝试cas操作,但是在高并发场景下
     * 这2个线程之后依然可能发生竞争,而每次竞争都需要自旋的话会很浪费cpu资源
     * 因此在这里先直接增加自旋一次,在for的最后会做一次rehash
     * 使得线程尽快地找到自己独占的数组下标
     */
    } else if (!wasUncontended) 
        wasUncontended = true;
    /**
     * 尝试给hash对应的Cell累加,如果这一步成功了,那么就返回
     * 如果这一步依然失败了,说明此时整体的并发竞争非常激烈
     * 那就可能需要考虑扩容数组了
     * (因为数组初始化容量为2,如果此时有10个线程在并发运行,那就很难避免竞争的发生了)
     */
    else if (a.cas(v = a.value, ((fn == null) ? v + x :
            fn.applyAsLong(v, x))))
        break;
    /**
     * 这里判断下cpu的核数,因为即使有100个线程
     * 能同时并行运行的线程数等于cpu数
     * 因此如果数组的长度已经大于cpu数目了,那就不应当再扩容了
     */
    else if (n >= NCPU || cells != as)
        collide = false;
    /**
     * 走到这里,说明当前循环中根据线程hash值找到的数组下标已经有元素了
     * 如果此时collide为false,说明上一次循环中找到的下边是没有元素的
     * 那么就自旋一次并rehash
     * 如果再次运行到这里,并且collide为true,就说明明竞争非常激烈,应当扩容了
     */
    else if (!collide)
        collide = true;
    /**
     * 能运行到这里,说明需要扩容数组了
     * 判断锁状态并尝试获取锁
     */
    else if (cellsBusy == 0 && casCellsBusy()) {
        /**
         * 扩容数组的逻辑,这个扩容比较简单,就不解释了
         * 扩容大小为2倍
         */
        try {
            if (cells == as) { 
                Cell[] rs = new Cell[n << 1];
                for (int i = 0; i < n; ++i)
                    rs[i] = as[i];
                cells = rs;
            }
        } finally {
            cellsBusy = 0;
        }
        collide = false;
        /**
        * 这里直接continue,因为扩容过了,就先不rehash了
        */
        continue;               
    }
    /**
     * 做一个rehash,使得线程在下一个循环中可能找到独占的数组下标
     */
    h = advanceProbe(h);
}

この時点で、LongAdderのソースコードは実際に終了しています。実際、コードはそれほど多くありませんが、彼のアイデアは学ぶ価値があります。

4.AtomicIntegerとの比較

実際、光分析のソースコードはまだ少し悪いですが、すでにAtomicIntegerが存在するのに、なぜこのような非常に複雑なクラスを設計する必要があるのか​​はまだわかりません。

まず、スレッドの安全性を確保するためにAtomicIntegerの原理を分析しましょう

最も基本的なgetAndIncrementメソッドを表示する

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

UnsafeクラスのgetAndAddIntメソッドを呼び出し、見下ろし続けます

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

ここでは、getIntVolatileメソッドとcompareAndSwapIntメソッドの特定の実装については、すでにネイティブメソッドであるため、詳しく説明しません。

AtomicIntegerの最下層がcas + spinを使用して原子性の問題を解決していることがわかります。つまり、割り当てが失敗した場合は、割り当てが成功するまでスピンします。

次に、多数のスレッドが同時に発生し、競争が非常に激しい場合、AtomicIntegerによって一部のスレッドが競争を続けて失敗し、スピンを続けて、タスクのスループットに影響を与える可能性があると推測できます。

高い同時実行性の下でのスピンの問題を解決するために、LongAdderの作成者は、設計中に競合するオブジェクトを1つの値から複数の値に変更する配列を追加しました。これにより、競合の頻度が減り、自己が軽減されます。もちろん、スピンの問題は追加のストレージスペースです。

最後に、2つのカウント方法の時間がかかることを比較するために簡単なテストを行いました

原理からわかるように、LongAdderの利点は、スレッドの競合が非常に激しい場合にのみ明らかになるため、ここでは100スレッドを使用し、各スレッドは同じ数を1000000回累積します。結果は次のようになり、ギャップは非常に大きくなり、 15回!

時間がかかるLongAdder:104292242nanos

時間がかかるAtomicInteger:1583294474nanos

もちろん、これは単純なテストであり、ランダム性が多く含まれています。興味のある学生は、さまざまなレベルの競争で複数のテストを試すことができます。

5.思考の抽象化

最後に、思考プロセスを明確にするために、作成者の特定のコードと実装ロジックを抽象化する必要があります

1)AtomicIntegerが遭遇する問題:単一のリソースの競合がスピンの発生につながる

2)解決策のアイデア:単一のオブジェクトの競争を複数のオブジェクトの競争に拡大します(いくつかの分割と征服のアイデアがあります)

3)拡張の制御可能性:複数の競合他社は追加のストレージスペースを支払う必要があるため、考えずに拡張することはできません(極端な場合、1つのスレッドが1つのオブジェクトをカウントするため、明らかに不合理です)

4)問題の層別化:クラスを使用するときのシーンは制御できないため、同時実行の強度に応じて追加のストレージスペースを動的に拡張する必要があります(同期の拡張と同様)

5)3つの階層戦略:競合がない場合は、1つの値を使用して累積します。ある程度の競合が発生した場合は、容量が2の配列を作成して、競合するリソースを3に拡張します。競合が多い場合は、激しい場合は、アレイを拡張し続けます(図の1スレッドから4スレッドへのプロセスに対応)

6)戦略の詳細:スピン中に再ハッシュを追加します。現時点では、ハッシュの計算や配列オブジェクトの比較などに一定の計算時間が費やされますが、これにより、同時スレッドが自分のオブジェクトをできるだけ早く見つけることができます。再び競争があります(ナイフを共有し、誤って木を切るのではなく、wasUncontendedフィールドの対応するソリューションに特別な注意を払ってください)

おすすめ

転載: blog.csdn.net/GYHYCX/article/details/109322989