標籤:java android concurrent 多線程 並發
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帶來的影響。其中的無鎖演算法以及其最佳化值得仔細品味和理解。