Exchanger源碼剖析

    Exchanger是一個針對線程可以結對交換元素的同步器。每條線程把某個對象作爲參數調用exchange方法,與夥伴線程進行匹配,然後再函數返回的時接收夥伴的對象。另外,Exchanger內部實現採用的是無鎖算法,能夠大大提高多線程競爭下的吞吐量以及性能。

算法實現
    基本方法是維持一個“槽”(slot),這個槽是保持交換對象的結點的引用,同時也是一個等待填滿的“洞”(hole)。如果一個即將到來的“佔領”(occupying)線程發現槽爲空,然後它就會CAS(compareAndSet)一個結點到這個槽並且等待另外一個線程調用exchange方法。第二個“匹配”(fulfilling)線程發現槽爲非空,則CAS它爲空,並且通過CAS洞來交換對象,另外如果佔領線程被阻塞,則會一併喚醒佔領線程。在每個例子裏,CAS都可能由於槽一開始爲非空但在CAS的時候爲空,或者反之等情況而失敗,所以線程需要重試這些動作。
    在只有少量線程使用Exchanger的時候,這個簡單的方法效果不錯,但是在比較多線程使用同一個Exchanger的時候,由於CAS在同一個槽上競爭,性能就會急劇下降。因此我們使用一個“區域”(arena);總的來說,就是一個槽數量可以動態變化的哈希表,其中任意一個槽都可以被線程用來交換。到來的線程就可以用基於它們的線程id的哈希值來選擇槽。如果到來的線程在選擇槽上CAS失敗來,它就會選擇另外一個槽。類似地,如果一條線程成功CAS進去一個槽,但是沒有其它線程到來,它也會嘗試另外一個槽,直到第0槽,即使表縮小的時候第0槽也會一直存在。這個特別的機制如下:

等待(Waiting):第0槽特別在於沒有競爭的時候它是唯一存在的槽。當單條線程佔領了第0槽後,如果沒有線程匹配,那麼該線程會在短暫的自旋之後阻塞。在其它情況下,佔領線程最終會放棄並且嘗試另外的槽。在阻塞(如果是第0槽)或者放棄(其它的槽)或者重新開始的時候,等待線程都會自旋片刻(比上下文切換時間稍微短的一段時間)。除非不大可能有其它線程的存在,否則沒有理由讓線程阻塞。爲了避免內存競爭,所以競爭者會在靜靜地輪詢一段比阻塞然後喚醒稍短的時間。由於缺少其它線程,非0槽會等待自旋時間結束,大概每次嘗試都會浪費一次額外的上下文切換時間,平均依然比另外的方法(阻塞然後喚醒)快很多。

改變大小(Sizing):通常,使用少量槽能夠減少競爭。特別地當在少量線程時,使用太多槽會導致和使用太少槽的一樣的糟糕性能,還有會導致空間不足的錯誤。變量“max”維持實際使用的槽的數量。當一條線程發現太多CAS失敗的時候會增加“max”(這個類似於常規的基於一個目標載入因子來改變大小的哈希表,在這裏不同的是,增長的速度是加一而不是按比例)。增長需要在每個槽上三次的失敗競爭纔會發生。需要多次失敗纔會增長可以處理這樣的情況,一些CAS的失敗並非由於競爭,可能在兩條線程簡單的競爭或者在讀取和CAS過程中有線程搶先運行。同時,非常短暫的高峯競爭可能會大大高於平均可忍受的程度。當非0槽等待超時沒有被p匹配的時候,就會嘗試減少最大槽數量(max)限制。線程經歷了超時等待會移動到更加接近第0槽,所以即使由於不活躍導致表大小縮減,但最終也會發現存在(或者未來)的線程。這個增長和縮減的選擇機制和閥值從本質上講都會在交換代碼裏捲入索引和哈希,而且無法很好地抽象出去。

哈希(Hashing):每條線程都會選擇與簡單的哈希碼一直的初始槽來使用。對於任意指定線程,每次相遇的順序都是相同的,但實際上對於線程是隨機的。使用區域會遇到經典的哈希表的成本與質量權衡問題(cost vs quality tradeoffs)。這裏,我們使用基於當前線程的Thread.getId()返回值的one-step FNV-1a哈希值,還加上一個低廉的近似模數(mod)操作去選擇一個索引。以這樣的方式來優化索引選擇的缺陷是需要硬編碼去使用一個最大爲32的最大表大小。但是這個值足以超過已知的平臺。

探查(Probing):在偵查到已選的槽的競爭後,我們會按順序探查整個表,類似與哈希表在衝突中的線性探查。(循環地移動,按照相反的順序,可以最好地配合表增長和縮減規則——表的增長和縮減都是從尾部開始,頭部0槽保持不變)除了爲了最小化錯報和緩存失效的影響,我們會對第一個選擇的槽進行兩次探查。

填充(Padding):即使有了競爭管理,槽還是會被嚴重競爭,所以利用緩存填充(cache-jpadding)去避免糟糕的內存性能。由於這樣,槽只有在使用的時候延遲構造,避免浪費不必要的空間。當內存地址不是程序的優先問題的時候,隨着時間消逝,垃圾回收器執行壓縮,槽非常可能會被移動到互相聯結,除非使用了填充,否則會導致大量在多個內核上的高速緩存行無效。

    算法實現主要爲了優化高競爭條件下的吞吐量,所以增加了較多的特性來避免各種問題,初始看上去較爲複雜,因此建議先大致看一下流程,然後再看看源碼實現,再反過來看會有更加深刻的理解。

源碼實現
    Exchanger主要目的是不同線程間交換對象,因此exchange方法是Exchanger唯一的public方法。exchange方法有兩個版本,一個是隻拋出InterruptedException異常的無超時版本,一個是拋出InterruptedException, TimeoutException的有超時版本。先來看看無超時版本的實現
    public V exchange(V x) throws InterruptedException {
        if (!Thread.interrupted()) {
            Object v = doExchange((x == null) ? NULL_ITEM : x, false, 0);
            if (v == NULL_ITEM)
                return null;
            if (v != CANCEL)
                return (V)v;
            Thread.interrupted(); // Clear interrupt status on IE throw
        }
        throw new InterruptedException();
    }
    函數首先判斷當前線程是否已經被中斷,如果是則拋出IE異常,否則調用doExchange函數,調用函數之前,爲了防止傳入交換對象的參數x爲null,因此會當null時會傳入NULL_ITEM,一個預定義的作爲標識的Object作爲參數,另外,根據doExchange返回的對象來判斷槽中的對象爲null或者當前操作被中斷,如果被中斷則doExchange返回CANCEL對象,這樣exchange就會拋出IE異常。
    private static final Object CANCEL = new Object();
    private static final Object NULL_ITEM = new Object();
    我們再來看看doExchange方法的實現。
    private Object doExchange(Object item, boolean timed, long nanos) {
        Node me = new Node(item);                 // Create in case occupying
        int index = hashIndex();                  // Index of current slot
        int fails = 0;                            // Number of CAS failures


        for (;;) {
            Object y;                             // Contents of current slot
            Slot slot = arena[index];
            if (slot == null)                     // Lazily initialize slots
                createSlot(index);                // Continue loop to reread
            else if ((y = slot.get()) != null &&  // Try to fulfill
                     slot.compareAndSet(y, null)) {
                Node you = (Node)y;               // Transfer item
                if (you.compareAndSet(null, item)) {
                    LockSupport.unpark(you.waiter);
                    return you.item;
                }                                 // Else cancelled; continue
            }
            else if (y == null &&                 // Try to occupy
                     slot.compareAndSet(null, me)) {
                if (index == 0)                   // Blocking wait for slot 0
                    return timed ?
                        awaitNanos(me, slot, nanos) :
                        await(me, slot);
                Object v = spinWait(me, slot);    // Spin wait for non-0
                if (v != CANCEL)
                    return v;
                me = new Node(item);              // Throw away cancelled node
                int m = max.get();
                if (m > (index >>>= 1))           // Decrease index
                    max.compareAndSet(m, m - 1);  // Maybe shrink table
            }
            else if (++fails > 1) {               // Allow 2 fails on 1st slot
                int m = max.get();
                if (fails > 3 && m < FULL && max.compareAndSet(m, m + 1))
                    index = m + 1;                // Grow on 3rd failed slot
                else if (--index < 0)
                    index = m;                    // Circularly traverse
            }
        }
    }
    函數首先利用當前要交換對象作爲參數構造Node變量me,類Node定義如下
    private static final class Node extends AtomicReference<Object> {
        public final Object item;
        public volatile Thread waiter;


        public Node(Object item) {
            this.item = item;
        }
    }
    內部類Node繼承於AtomicReference,並且內部擁有兩個成員對象item,waiter。假設線程1和線程2需要進行對象交換,類Node把線程1中需要交換的對象作爲參數傳遞給Node構造函數,然後線程2如果在槽中發現此Node,則會利用CAS把當前原子引用從null變爲需要交換的item對象,然後返回Node的成員變量item對象,構造Node的線程1調用get()方法發現原子引用非null的時候,就返回此對象。這樣線程1和線程2就順利交換對象。類Node的成員變量waiter一般在線程1如果需要阻塞和喚醒的情況下使用。
    我們順便看看槽Slot以及其相關變量的定義
    private static final int CAPACITY = 32;

    private static final class Slot extends AtomicReference<Object> {
        // Improve likelihood of isolation on <= 128 byte cache lines.
        // We used to target 64 byte cache lines, but some x86s (including
        // i7 under some BIOSes) actually use 128 byte cache lines.
        long q0, q1, q2, q3, q4, q5, q6, q7, q8, q9, qa, qb, qc, qd, qe;
    }

    private volatile Slot[] arena = new Slot[CAPACITY];

    private final AtomicInteger max = new AtomicInteger();
    內部類Slot也是繼承於AtomicReference,其內部變量一共定義了15個long型成員變量,這15個long成員變量的作用就是緩存填充(cache padding),這樣可以避免在大量CAS的時候減輕cache的影響。arena定義爲大小爲CAPACITY的數組,而max就是arena實際使用的數組大小,一般max會根據情況進行增長或者縮減,這樣避免同時對一個槽進行CAS帶來的性能下降影響。

    我們看回doExchange函數,函數接着調用hashIndex根據線程Id獲取對應槽的索引。
   private final int hashIndex() {
        long id = Thread.currentThread().getId();
        int hash = (((int)(id ^ (id >>> 32))) ^ 0x811c9dc5) * 0x01000193;


        int m = max.get();
        int nbits = (((0xfffffc00  >> m) & 4) | // Compute ceil(log2(m+1))
                     ((0x000001f8 >>> m) & 2) | // The constants hold
                     ((0xffff00f2 >>> m) & 1)); // a lookup table
        int index;
        while ((index = hash & ((1 << nbits) - 1)) > m)       // May retry on
            hash = (hash >>> nbits) | (hash << (33 - nbits)); // non-power-2 m
        return index;
    }
    hashIndex主要根據當前線程的id根據one-step FNV-1a的算出對應的哈希值,並且利用一個快速的模數估算來把哈希值限制在[0, max)之間(max是槽實際使用大小),具體實現涉及各種運算,有興趣可以自行研究,此處略去。

    doExchange函數接着會進入一個循環中,循環內部便是真正的算法邏輯,一共有4個判斷,每個判斷完之後如果沒有返回再需要再次重新判斷。首先從arena獲取當前選中的Slot,由於hashIndex保證小於max值,因此不會數組越界。我們來看第一個判斷,當第一次使用Slot的時候,該Slot爲null,因此調用createSlot進行初始化。
    private void createSlot(int index) {
        Slot newSlot = new Slot();
        Slot[] a = arena;
        synchronized (a) {
            if (a[index] == null)
                a[index] = newSlot;
        }
    }
    createSlot的實現很簡單,只是根據index參數把數組中的對應位置添加引用。但要注意併發問題,因此在給數組賦值的時候還要利用synchronized關鍵字進行同步。
    接着看回doExchange循環。來看看第二個判斷,如果選擇的slot已經初始化,則調用當前slot.get()方法嘗試獲取Node節點,如果當前Node節點非null,則表明之前已有線程佔領此Slot,則此時繼續嘗試CAS此slot爲null,如果成功,則表示當前線程已經和此前的佔領線程進行了匹配,接下來則CAS替換Node的原子引用爲交換對象item,然後喚醒Node的佔領線程waiter,接着返回Node.item完成了交換。
    第三個判斷中,如果獲取槽中的Node爲null,則表明選中的槽沒有被佔領,於是CAS把當前槽從null變爲一開始以交換對象item構造的Node結點me,如果CAS成功,則要按照選擇的槽索引分爲兩種處理,首先對於第0槽,需要進行阻塞等待,由於我們這裏是非超時等待,因此調用await函數。
    private static final int NCPU = Runtime.getRuntime().availableProcessors();

    private static final int SPINS = (NCPU == 1) ? 0 : 2000;

    private static Object await(Node node, Slot slot) {
        Thread w = Thread.currentThread();
        int spins = SPINS;
        for (;;) {
            Object v = node.get();
            if (v != null)
                return v;
            else if (spins > 0)                 // Spin-wait phase
                --spins;
            else if (node.waiter == null)       // Set up to block next
                node.waiter = w;
            else if (w.isInterrupted())         // Abort on interrupt
                tryCancel(node, slot);
            else                                // Block
                LockSupport.park(node);
        }
    }
    首先看看SPINS變量的定義,SPINS表示的是在阻塞或者等待匹配中超時放棄前需要自旋輪詢變量的次數,在當只有單個CPU時爲0,否則爲2000。SPINS在多核CPU上能夠在交換中,如果其中一條線程由於GC或者被搶佔等原因暫停時,能夠只等待短暫的輪詢後即可重新進行交換操作。來看看await的實現,同樣在循環裏有四個判斷:
    第一個判斷,調用Node的get方法,如果非null,則證明已經有線程成功交換對象又或者因爲線程中斷被取消了此次等待,因此直接返回對象v;
    第二個判斷,則get方法返回null,則要進行自旋等待,自旋的值是根據SPINS來決定;
    第三個判斷,此時自旋已經完結,因此需要進入阻塞狀態,阻塞之前,首先把node.waiter賦值爲當前線程,這樣等後面有線程進行交換的時候可以喚醒此線程;
    第四個判斷,在最後進入阻塞前,如果發現當前線程已經被中斷,則需要調用tryCancel取消此次等待
    最後,調用LockSupport.park進入阻塞。
    private static boolean tryCancel(Node node, Slot slot) {
        if (!node.compareAndSet(null, CANCEL))
            return false;
        if (slot.get() == node) // pre-check to minimize contention
            slot.compareAndSet(node, null);
        return true;
    }
    tryCancel的實現很簡單,首先需要CAS把當前結點的原子引用從null變爲CANCEL對象,如果CAS失敗,則有可能已經有線程順利與當前結點進行匹配,並且調用CAS進行了交換。否則的話,再調用CAS把node所在的slot修改爲null。如果這裏CAS成功,則CANCEL對象會被返回到exchange方法裏,讓exchange方法判斷後,拋出InterruptedException異常。

    接着我們看回doExchange第三個判斷,如果選擇的是非0槽,則會調用spinWait進行自旋等待。
    private static Object spinWait(Node node, Slot slot) {
        int spins = SPINS;
        for (;;) {
            Object v = node.get();
            if (v != null)
                return v;
            else if (spins > 0)
                --spins;
            else
                tryCancel(node, slot);
        }
    }
    spinWait的實現與await類似,但稍有不同,主要邏輯是如果經過SPINS次自旋以後,仍然無法被匹配,則會調用tryCancel把當前結點調用tryCancel取消,這樣返回doExchange的時候,如果發現當前結點已經被取消,則重新構造一個新結點Node,並且把index的值右移一位(即整除2),另外此處還需要考慮把槽的數量減少,於是判斷如果max的值比整除後的index要大,則通過CAS把max值減去一。

    doExchange的第四個判斷裏,如果前三個判斷都失敗,則表明CAS失敗,CAS的失敗有可能只是因爲兩條線程之間的競爭,也有可能大量線程的併發,因此我們先把fails值加一記錄此次的失敗,然後繼續循環前面的判斷;如果連續兩次都失敗,則大量線程併發的可能性較大,此時如果失敗次數大於3次,並且max仍然小於FULL(定義max的最大值),則嘗試CAS把max增加1,如果成功的話,則把index賦值爲m+1,下次選擇的槽則爲新分配的索引;如果失敗次數還不夠3次,則把當前索引減去一,循環遍歷整個Slot表。

    於是doExchange大致邏輯便是如此,exchange的超時版本大體邏輯類似,在調用doExchange傳入對應超時參數,這樣在第0槽需要等待的時候會調用另外的函數awaitNanos。
   private Object awaitNanos(Node node, Slot slot, long nanos) {
        int spins = TIMED_SPINS;
        long lastTime = 0;
        Thread w = null;
        for (;;) {
            Object v = node.get();
            if (v != null)
                return v;
            long now = System.nanoTime();
            if (w == null)
                w = Thread.currentThread();
            else
                nanos -= now - lastTime;
            lastTime = now;
            if (nanos > 0) {
                if (spins > 0)
                    --spins;
                else if (node.waiter == null)
                    node.waiter = w;
                else if (w.isInterrupted())
                    tryCancel(node, slot);
                else
                    LockSupport.parkNanos(node, nanos);
            }
            else if (tryCancel(node, slot) && !w.isInterrupted())
                return scanOnTimeout(node);
        }
    }
    awaitNanos大體邏輯基本與await相同,但添加了一些關於超時判斷的邏輯。其中最主要的是在超時之後,會嘗試調用scanOnTimeout函數。
    private Object scanOnTimeout(Node node) {
        Object y;
        for (int j = arena.length - 1; j >= 0; --j) {
            Slot slot = arena[j];
            if (slot != null) {
                while ((y = slot.get()) != null) {
                    if (slot.compareAndSet(y, null)) {
                        Node you = (Node)y;
                        if (you.compareAndSet(null, node.item)) {
                            LockSupport.unpark(you.waiter);
                            return you.item;
                        }
                    }
                }
            }
        }
        return CANCEL;
    }
    scanOnTimeout把整個槽表都掃描一次,如果發現有線程在另外的槽位中,則進行CAS交換。這樣就可以減少超時的可能性。注意CAS替換的是node.item,並不是get()方法返回的先前在tryCancel中被CAS掉的原子引用。

總結
    Exchanger使用了無鎖算法,使用了一個可以在多線程下兩組線程相互交換對象引用的同步器。該同步器在激烈競爭的環境下,做了大量的優化,並在對於CAS的內存競爭也採用了padding來避免cache帶來的影響。其中的無鎖算法以及其優化值得仔細品味和理解。
發佈了36 篇原創文章 · 獲贊 4 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章