Java中快如閃電的執行緒間通訊
這個故事源自一個很簡單的想法:建立一個對開發人員友好的、簡單輕量的執行緒間通訊框架,完全不用鎖、同步器、訊號量、等待和通知,在Java裡開發一個輕量、無鎖的執行緒內通訊框架;並且也沒有佇列、訊息、事件或任何其他併發專用的術語或工具。
只用普通的老式Java介面實現POJO的通訊。
它可能跟Akka的型別化actor類似,但作為一個必須超級輕量,並且要針對單臺多核計算機進行優化的新框架,那個可能有點過了。
當actor跨越不同JVM例項(在同一臺機器上,或分佈在網路上的不同機器上)的程式邊界時,Akka框架很善於處理程式間的通訊。
但對於那種只需要執行緒間通訊的小型專案而言,用Akka型別化actor可能有點兒像用牛刀殺雞,不過型別化actor仍然是一種理想的實現方式。
我花了幾天時間,用動態代理,阻塞佇列和快取執行緒池建立了一個解決方案。
圖一是這個框架的高層次架構:
圖一: 框架的高層次架構
SPSC佇列是指單一生產者/單一消費者佇列。MPSC佇列是指多生產者/單一消費者佇列。
派發執行緒負責接收Actor執行緒傳送的訊息,並把它們派發到對應的SPSC佇列中去。
接收到訊息的Actor執行緒用其中的資料呼叫相應的actor例項中的方法。藉助其他actor的代理,actor例項可以將訊息傳送到MPSC佇列中,然後訊息會被髮送給目標actor執行緒。
我建立了一個簡單的例子來測試,就是下面這個打乒乓球的程式:
public interface PlayerA ( void pong(long ball); //發完就忘的方法呼叫 } public interface PlayerB { void ping(PlayerA playerA, long ball); //發完就忘的方法呼叫 } public class PlayerAImpl implements PlayerA { @Override public void pong(long ball) { } } public class PlayerBImpl implements PlayerB { @Override public void ping(PlayerA playerA, long ball) { playerA.pong(ball); } } public class PingPongExample { public void testPingPong() { // 管理器隱藏了執行緒間通訊的複雜性 // 控制actor代理,actor實現和執行緒 ActorManager manager = new ActorManager(); // 在管理器內註冊actor實現 manager.registerImpl(PlayerAImpl.class); manager.registerImpl(PlayerBImpl.class); //建立actor代理。代理會將方法呼叫轉換成內部訊息。 //會線上程間發給特定的actor例項。 PlayerA playerA = manager.createActor(PlayerA.class); PlayerB playerB = manager.createActor(PlayerB.class); for(int i = 0; i < 1000000; i++) { playerB.ping(playerA, i); } }
經過測試,速度大約在每秒500,000 次乒/乓左右;還不錯吧。然而跟單執行緒的執行速度比起來,我突然就感覺沒那麼好了。在 單執行緒中執行的程式碼每秒速度能達到20億 (2,681,850,373)!
居然差了5,000 多倍。太讓我失望了。在大多數情況下,單執行緒程式碼的效果都比多執行緒程式碼更高效。
我開始找原因,想看看我的乒乓球運動員們為什麼這麼慢。經過一番調研和測試,我發現是阻塞佇列的問題,我用來在actor間傳遞訊息的佇列影響了效能。
圖 2: 只有一個生產者和一個消費者的SPSC佇列
所以我發起了一場競賽,要將它換成Java裡最快的佇列。我發現了Nitsan Wakart的 部落格 。他發了幾篇文章介紹單一生產者/單一消費者(SPSC)無鎖佇列的實現。這些文章受到了Martin Thompson的演講 終極效能的無鎖演算法的啟發。
跟基於私有鎖的佇列相比,無鎖佇列的效能更優。在基於鎖的佇列中,當一個執行緒得到鎖時,其它執行緒就要等著鎖被釋放。而在無鎖的演算法中,某個生產者執行緒生產訊息時不會阻塞其它生產者執行緒,消費者也不會被其它讀取佇列的消費者阻塞。
在Martin Thompson的演講以及在Nitsan的部落格中介紹的SPSC佇列的效能簡直令人難以置信—— 超過了100M ops/sec。比JDK的併發佇列實現還要快10倍 (在4核的 Intel Core i7 上的效能大約在 8M ops/sec 左右)。
我懷著極大的期望,將所有actor上連線的鏈式阻塞佇列都換成了無鎖的SPSC佇列。可惜,在吞吐量上的效能測試並沒有像我預期的那樣出現大幅提升。不過很快我就意識到,瓶頸並不在SPSC佇列上,而是在多個生產者/單一消費者(MPSC)那裡。
用SPSC佇列做MPSC佇列的任務並不那麼簡單;在做put操作時,多個生產者可能會覆蓋掉彼此的值。SPSC 佇列就沒有控制多個生產者put操作的程式碼。所以即便換成最快的SPSC佇列,也解決不了我的問題。
為了處理多個生產者/單一消費者的情況,我決定啟用LMAX Disruptor ——一個基於環形緩衝區的高效能程式間訊息庫。
圖3: 單一生產者和單一消費者的LMAX Disruptor
藉助Disruptor,很容易實現低延遲、高吞吐量的執行緒間訊息通訊。它還為生產者和消費者的不同組合提供了不同的用例。幾個執行緒可以互不阻塞地讀取環形緩衝中的訊息:
圖 4: 單一生產者和兩個消費者的LMAX Disruptor
下面是有多個生產者寫入環形緩衝區,多個消費者從中讀取訊息的場景。
圖 5: 兩個生產者和兩個消費者的LMAX Disruptor
經過對效能測試的快速搜尋,我找到了 三個釋出者和一個消費者的吞吐量測試。 這個真是正合我意,它給出了下面這個結果:
LinkedBlockingQueue | Disruptor | |
Run 0 | 4,550,625 ops/sec | 11,487,650 ops/sec |
Run 1 | 4,651,162 ops/sec | 11,049,723 ops/sec |
Run 2 | 4,404,316 ops/sec | 11,142,061 ops/sec |
在3 個生產者/1個 消費者場景下, Disruptor要比LinkedBlockingQueue快兩倍多。然而這跟我所期望的效能上提升10倍仍有很大差距。
這讓我覺得很沮喪,並且我的大腦一直在搜尋解決方案。就像命中註定一樣,我最近不在跟人拼車上下班,而是改乘地鐵了。突然靈光一閃,我的大腦開始將車站跟生產者消費者對應起來。在一個車站裡,既有生產者(車和下車的人),也有消費者(同一輛車和上車的人)。
我建立了 Railway類,並用AtomicLong追蹤從一站到下一站的列車。我先從簡單的場景開始,只有一輛車的鐵軌。
public class RailWay { private final Train train = new Train(); // stationNo追蹤列車並定義哪個車站接收到了列車 private final AtomicInteger stationIndex = new AtomicInteger(); // 會有多個執行緒訪問這個方法,並等待特定車站上的列車 public Train waitTrainOnStation(final int stationNo) { while (stationIndex.get() % stationCount != stationNo) { Thread.yield(); // 為保證高吞吐量的訊息傳遞,這個是必須的。 //但在等待列車時它會消耗CPU週期 } // 只有站號等於stationIndex.get() % stationCount時,這個忙迴圈才會返回 return train; } // 這個方法通過增加列車的站點索引將這輛列車移到下一站 public void sendTrain() { stationIndex.getAndIncrement(); } }
為了測試,我用的條件跟在Disruptor效能測試中用的一樣,並且也是測的SPSC佇列——測試線上程間傳遞long值。我建立了下面這個Train類,其中包含了一個long陣列:
public class Train { // public static int CAPACITY = 2*1024; private final long[] goodsArray; // 傳輸運輸貨物的陣列 private int index; public Train() { goodsArray = new long[CAPACITY]; } public int goodsCount() { //返回貨物數量 return index; } public void addGoods(long i) { // 向列車中新增條目 goodsArray[index++] = i; } public long getGoods(int i) { //從列車中移走條目 index--; return goodsArray[i]; } }
然後我寫了一個簡單的測試 :兩個執行緒通過列車互相傳遞long值。
圖 6: 使用單輛列車的單一生產者和單一消費者Railway
public void testRailWay() { final Railway railway = new Railway(); final long n = 20000000000l; //啟動一個消費者程式 new Thread() { long lastValue = 0; @Override public void run() { while (lastValue < n) { Train train = railway.waitTrainOnStation(1); //在#1站等列車 int count = train.goodsCount(); for (int i = 0; i < count; i++) { lastValue = train.getGoods(i); // 卸貨 } railway.sendTrain(); //將當前列車送到第一站 } } }.start(); final long start = System.nanoTime(); long i = 0; while (i < n) { Train train = railway.waitTrainOnStation(0); // 在#0站等列車 int capacity = train.getCapacity(); for (int j = 0; j < capacity; j++) { train.addGoods((int)i++); // 將貨物裝到列車上 } railway.sendTrain(); if (i % 100000000 == 0) { //每隔100M個條目測量一次效能 final long duration = System.nanoTime() - start; final long ops = (i * 1000L * 1000L * 1000L) / duration; System.out.format("ops/sec = %,d\n", ops); System.out.format("trains/sec = %,d\n", ops / Train.CAPACITY); System.out.format("latency nanos = %.3f%n\n", duration / (float)(i) * (float)Train.CAPACITY); } } }
在不同的列車容量下執行這個測試,結果驚著我了:
容量 | 吞吐量: ops/sec | 延遲: ns |
1 | 5,190,883 | 192.6 |
2 | 10,282,820 | 194.5 |
32 | 104,878,614 | 305.1 |
256 | 344,614,640 | 742. 9 |
2048 | 608,112,493 | 3,367.8 |
32768 | 767,028,751 | 42,720.7 |
在列車容量達到32,768時,兩個執行緒傳送訊息的吞吐量達到了767,028,751 ops/sec。比Nitsan部落格中的SPSC佇列快了幾倍。
繼續按鐵路列車這個思路思考,我想知道如果有兩輛列車會怎麼樣?我覺得應該能提高吞吐量,同時還能降低延遲。每個車站都會有它自己的列車。當一輛列車在第一個車站裝貨時,第二輛列車會在第二個車站卸貨,反之亦然。
圖 7: 使用兩輛列車的單一生產者和單一消費者Railway
下面是吞吐量的結果:
容量 | 吞吐量: ops/sec | 延時: ns |
1 | 7,492,684 | 133.5 |
2 | 14,754,786 | 135.5 |
32 | 174,227,656 | 183.7 |
256 | 613,555,475 | 417.2 |
2048 | 940,144,900 | 2,178.4 |
32768 | 797,806,764 | 41,072.6 |
結果是驚人的;比單輛列車的結果快了1.4倍多。列車容量為一時,延遲從192.6納秒降低到133.5納秒;這顯然是一個令人鼓舞的跡象。
因此我的實驗還沒結束。列車容量為2048的兩個執行緒傳遞訊息的延遲為2,178.4 納秒,這太高了。我在想如何降低它,建立一個有很多輛列車 的例子:
圖 8: 使用多輛列車的單一生產者和單一消費者Railway
我還把列車容量降到了1個long值,開始玩起了列車數量。下面是測試結果:
列車數量 | 吞吐量: ops/sec | 延遲: ns |
2 | 10,917,951 | 91.6 |
32 | 31,233,310 | 32.0 |
256 | 42,791,962 | 23.4 |
1024 | 53,220,057 | 18.8 |
32768 | 71,812,166 | 13.9 |
用32,768 列車線上程間傳送一個long值的延遲降低到了13.9 納秒。通過調整列車數量和列車容量,當延時不那麼高,吞吐量不那麼低時,吞吐量和延時就達到了最佳平衡。
對於單一生產者和單一消費者(SPSC)而言,這些數值很棒;但我們怎麼讓它在有多個生產者和消費者時也能生效呢?答案很簡單,新增更多的車站!
圖 9:一個生產者和兩個消費者的Railway
每個執行緒都等著下一趟列車,裝貨/卸貨,然後把列車送到下一站。在生產者往列車上裝貨時,消費者在從列車上卸貨。列車周而復始地從一個車站轉到另一個車站。
為了測試單一生產者/多消費者(SPMC) 的情況,我建立了一個有8個車站的Railway測試。 一個車站屬於一個生產者,而另外7個車站屬於消費者。結果是:
列車數量 = 256 ,列車容量 = 32:
ops/sec = 116,604,397 延遲(納秒) = 274.4
列車數量= 32,列車容量= 256:
ops/sec = 432,055,469 延遲(納秒) = 592.5
如你所見,即便有8個工作執行緒,測試給出的結果也相當好– 32輛容量為256個long的列車吞吐量為432,055,469 ops/sec。在測試期間,所有CPU核心的負載都是100%。
圖 10:在測試有8個車站的Railway 期間的CPU 使用情況
在玩這個Railway演算法時,我幾乎忘了我最初的目標:提升多生產者/單消費者情況下的效能。
圖 11:三個生產者和一個消費者的 Railway
我建立了3個生產者和1個消費者的新測試。每輛列車一站一站地轉圈,而每個生產者只給每輛車裝1/3容量的貨。消費者取出每輛車上三個生產者給出的全部三項貨物。效能測試給出的平均結果如下所示:
ops/sec = 162,597,109 列車/秒 = 54,199,036 延遲(納秒) = 18.5
結果相當棒。生產者和消費者工作的速度超過了160M ops/sec。
為了填補差異,下面給出相同情況下的Disruptor結果- 3個生產者和1個消費者:
Run 0, Disruptor=11,467,889 ops/sec Run 1, Disruptor=11,280,315 ops/sec Run 2, Disruptor=11,286,681 ops/sec Run 3, Disruptor=11,254,924 ops/sec
下面是另一個批量訊息的Disruptor 3P:1C 測試 (10 條訊息每批):
Run 0, Disruptor=116,009,280 ops/sec Run 1, Disruptor=128,205,128 ops/sec Run 2, Disruptor=101,317,122 ops/sec Run 3, Disruptor=98,716,683 ops/sec;
最後是用帶LinkedBlockingQueue 實現的Disruptor 在3P:1C場景下的測試結果:
Run 0, BlockingQueue=4,546,281 ops/sec Run 1, BlockingQueue=4,508,769 ops/sec Run 2, BlockingQueue=4,101,386 ops/sec Run 3, BlockingQueue=4,124,561 ops/sec
如你所見,Railway方式的平均吞吐量是162,597,109 ops/sec,而Disruptor在同樣的情況下的最好結果只有128,205,128 ops/sec。至於 LinkedBlockingQueue,最好的結果只有4,546,281 ops/sec。
Railway演算法為事件批處理提供了一種可以顯著增加吞吐量的簡易辦法。通過調整列車容量或列車數量,很容易達成想要的吞吐量/延遲。
另外, 當同一個執行緒可以用來消費訊息,處理它們並向環中返回結果時,通過混合生產者和消費者,Railway也能用來處理複雜的情況:
圖 12: 混合生產者和消費者的Railway
最後,我會提供一個經過優化的超高吞吐量 單生產者/單消費者測試:
圖 13:單個生產者和單個消費者的Railway
它的平均結果為:吞吐量超過每秒15億 (1,569,884,271)次操作,延遲為1.3 微秒。如你所見,本文開頭描述的那個規模相同的單執行緒測試的結果是每秒2,681,850,373。
你自己想想結論是什麼吧。
我希望將來再寫一篇文章,闡明如何用Queue和 BlockingQueue介面支援Railway演算法,用來處理不同的生產者和消費者組合。敬請關注。
相關文章
- java多執行緒5:執行緒間的通訊Java執行緒
- java多執行緒間的通訊Java執行緒
- 說說Java執行緒間通訊Java執行緒
- 執行緒間的通訊執行緒
- Java中利用管道實現執行緒間的通訊(轉)Java執行緒
- Java-執行緒間通訊小結Java執行緒
- Java中的執行緒通訊詳解Java執行緒
- 執行緒4--執行緒間通訊執行緒
- Java多執行緒-執行緒通訊Java執行緒
- Java執行緒通訊Java執行緒
- 徹底明白Java的多執行緒-執行緒間的通訊(2)(轉)Java執行緒
- 徹底明白Java的多執行緒-執行緒間的通訊(1)(轉)Java執行緒
- Java 執行緒間通訊 —— 等待 / 通知機制Java執行緒
- 【Java】【多執行緒】兩個執行緒間的通訊、wait、notify、notifyAllJava執行緒AI
- JUC之執行緒間的通訊執行緒
- 多執行緒之間的通訊執行緒
- Java之執行緒通訊Java執行緒
- Android執行緒間通訊Android執行緒
- 多執行緒之間通訊及執行緒池執行緒
- Java多執行緒學習——執行緒通訊Java執行緒
- java多執行緒:執行緒間通訊——生產者消費者模型Java執行緒模型
- iOS GCD執行緒之間的通訊iOSGC執行緒
- Android中的執行緒通訊Android執行緒
- Android小知識-Java多執行緒相關(執行緒間通訊)上篇AndroidJava執行緒
- Java多執行緒學習(五)執行緒間通訊知識點補充Java執行緒
- 【JAVA併發第三篇】執行緒間通訊Java執行緒
- Java多執行緒中的wait/notify通訊模式Java執行緒AI模式
- Java通過wait()和notifyAll()方法實現執行緒間的通訊JavaAI執行緒
- Java多執行緒學習(3)執行緒同步與執行緒通訊Java執行緒
- 程式通訊 執行緒通訊執行緒
- 執行緒間通訊_等待/通知機制執行緒
- Java執行緒(九):Condition-執行緒通訊更高效的方式Java執行緒
- JAVA - 基於Socket的多執行緒通訊Java執行緒
- Java的通過管道來實現執行緒通訊Java執行緒
- Java併發程式設計之執行緒安全、執行緒通訊Java程式設計執行緒
- JUC之執行緒間定製化通訊執行緒
- Android開發之執行緒間通訊Android執行緒
- 《Java 多執行緒程式設計核心技術》筆記——第3章 執行緒間通訊(三)Java執行緒程式設計筆記