The art of multipropcessor programming 讀書筆記-硬件基礎1


本系列是 The art of multipropcessor programming 的讀書筆記,在原版圖書的基礎上,結合 OpenJDK 11 以上的版本的代碼進行理解和實現。並根據個人的查資料以及理解的經歷,給各位想更深入理解的人分享一些個人的資料

硬件基礎

首先,我們無需掌握大量的底層計算機系統結構設計知識的細節,如果大家對於計算機系統結構非常感興趣,那么我非常推薦大家搜一下 CSE 502 Computer Architecture 的課件,我看了這門課的 CPU 和 緩存章節,受益匪淺。這里列出的硬件基礎知識,主要為了能讓我們理解編程語言為了提高性能做的設計。

我們一般能輕松寫出來適合單處理器運行的代碼,但是面對多處理器的情況,可能同樣的代碼效率就低很多,請看下面這個例子:假設兩個線程需要訪問一個資源,這個資源不能被多線程同時訪問,訪問前必須上鎖,拿到鎖之后才能訪問這個資源,並且需要在訪問完資源后釋放鎖。

我們這里使用一個 boolean 域實現鎖,如果這個 boolean 為 false 則鎖是空閑狀態,否則就是正在被使用。使用 compareAndSet 來獲取鎖,這個調用返回 true 就是修改成功,即獲取鎖成功,返回 false 則修改失敗,即獲取鎖失敗。假設我們有如下這個鎖的接口:

public interface Lock {
    void lock();
    void unlock();
}

對於這個鎖,我們有以下兩種實現,第一個是:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public class TASLock implements Lock {
    private boolean locked = false;
    //操作 lock 的句柄
    private static final VarHandle LOCKED;
    static {
        try {
            //初始化句柄
            LOCKED = MethodHandles.lookup().findVarHandle(TASLock.class, "locked", boolean.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }

    @Override
    public void lock() {
        // compareAndSet 成功代表獲取了鎖
        while(!LOCKED.compareAndSet(this, false, true)) {
            //讓出 CPU 資源,這是目前實現 SPIN 效果最好的讓出 CPU 的方式,當線程數量遠大於 CPU 數量時,效果比 Thread.yield 好,從及時性角度效果遠好於 Thread.sleep
            Thread.onSpinWait();
        }
    }

    @Override
    public void unlock() {
        //需要 volatile 更新,讓其他線程感知到
        LOCKED.setVolatile(this, false);
    }
}

另一個鎖的實現是:

public class TTASLock implements Lock {
    private boolean locked = false;
    //操作 locked 的句柄
    private static final VarHandle LOCKED;
    static {
        try {
            //初始化句柄
            LOCKED = MethodHandles.lookup().findVarHandle(TTASLock.class, "locked", boolean.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }

    @Override
    public void lock() {
        while (true) {
            //普通讀取 locked,如果被占用,則一直 SPIN
            while ((boolean) LOCKED.get(this)) {
                //讓出 CPU 資源,這是目前實現 SPIN 效果最好的讓出 CPU 的方式,當線程數量遠大於 CPU 數量時,效果比 Thread.yield 好,從及時性角度效果遠好於 Thread.sleep
                Thread.onSpinWait();
            }
            //成功代表獲取了鎖
            if (LOCKED.compareAndSet(this, false, true)) {
                return;
            }
        }
    }

    @Override
    public void unlock() {
        LOCKED.setVolatile(this, false);
    }
}

為了靈活性和統一,我們兩個鎖都是使用了句柄,而不是 volatile 變量或者是 AtomicBoolean,因為我們在這兩個鎖中會有 volatile 更新,普通讀取,以及原子更新;如果讀者不習慣,可以使用 AtomicBoolean 近似代替。

接下來我們使用 JMH 測試這兩個鎖的性能以及有效性,我們將一個 int 類型的變量使用多線程加 500 萬次,並且使用我們實現的這兩個鎖確保並發安全,查看耗時。

//測試指標為單次調用時間
@BenchmarkMode(Mode.SingleShotTime)
//需要預熱,排除 jit 即時編譯以及 JVM 采集各種指標帶來的影響,由於我們單次循環很多次,所以預熱一次就行
@Warmup(iterations = 1)
//單線程即可
@Fork(1)
//測試次數,我們測試10次
@Measurement(iterations = 10)
//定義了一個類實例的生命周期,所有測試線程共享一個實例
@State(value = Scope.Benchmark)
public class Test {
    private static class ValueHolder {
        int count = 0;
    }

    //測試不同線程數量
    @Param(value = {"1", "2", "5", "10", "20", "50", "100"})
    private int threadsCount;

    @Benchmark
    public void testTASLock(Blackhole blackhole) throws InterruptedException {
        test(new TASLock());
    }

    @Benchmark
    public void testTTASLock(Blackhole blackhole) throws InterruptedException {
        test(new TTASLock());
    }

    private void test(Lock lock) throws InterruptedException {
        ValueHolder valueHolder = new ValueHolder();
        Thread[] threads = new Thread[threadsCount];
        //測試累加 5000000 次
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 5000000 / threads.length; j++) {
                    lock.lock();
                    try {
                        valueHolder.count++;
                    } finally {
                        lock.unlock();
                    }
                }
            });
            threads[i].start();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i].join();
        }
        if (valueHolder.count != 5000000) {
            throw new RuntimeException("something wrong in lock implementation");
        }
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder().include(Test.class.getSimpleName()).build();
        new Runner(opt).run();
    }
}

結果是:

Benchmark          (threadsCount)  Mode  Cnt  Score   Error  Units
Test.testTASLock                1    ss   10  0.087 ± 0.012   s/op
Test.testTASLock                2    ss   10  0.380 ± 0.145   s/op
Test.testTASLock                5    ss   10  0.826 ± 0.310   s/op
Test.testTASLock               10    ss   10  1.698 ± 0.563   s/op
Test.testTASLock               20    ss   10  2.897 ± 0.699   s/op
Test.testTASLock               50    ss   10  5.448 ± 2.513   s/op
Test.testTASLock              100    ss   10  7.900 ± 5.011   s/op
Test.testTTASLock               1    ss   10  0.083 ± 0.003   s/op
Test.testTTASLock               2    ss   10  0.353 ± 0.067   s/op
Test.testTTASLock               5    ss   10  0.543 ± 0.123   s/op
Test.testTTASLock              10    ss   10  0.743 ± 0.356   s/op
Test.testTTASLock              20    ss   10  1.437 ± 0.161   s/op
Test.testTTASLock              50    ss   10  1.926 ± 0.769   s/op
Test.testTTASLock             100    ss   10  2.428 ± 0.878   s/op

可以看出,這兩個鎖雖然邏輯上是等價的,但是性能上確有很大差異,並且隨着線程的增加,這個差異越來越大,如下圖所示:

image

本章為了解釋這個問題,會涵蓋要寫出高效的並發算法和數據結構所需要的關於多處理器系統結構的大多數知識。我們會考慮如下這些組件:

  • 處理器(processors):執行軟件線程(threads)的設備。通常情況下,線程數遠大於處理器個數,處理器需要切換,即運行一個線程一段時間之后切換到另一個線程運行。
  • 互連線(interconnect):連接處理器與處理器或者處理器與內存。
  • 內存(memory):具有層次的存儲數據的組件,包括多層高速緩存和速度相對較慢的大容量主內存。理解這些層次之間的相互關系是理解許多並發算法實際性能的基礎。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM