2.6 Phaser
Phaser 是上一節提到的更高階的執行緒同步工具。Phaser 的包路徑是 java.util.concurrent.Phaser
,屬於 Java 多執行緒程式設計的核心功能。Phaser 類的主要功能是控制多個執行緒在特定的同步時間點同步執行。從文字介紹上看,它似乎沒有特別之處,但其實際功能相比 CountDownLatch
增強了不止一星半點。Phaser 可以說是 Java 多執行緒同步的終極解決方案。
Phaser 類支援多階段執行緒同步、動態的註冊和登出、指定同步階段、子同步功能,可以在到達集合點後不阻塞繼續執行下一階段,還可以中斷等待的階段、全域性管理等。
終究是 Phaser 類功能太強大了,而作為效能測試工具,它有些高攀不起。所以在效能測試中使用到的還是 Phaser 類的基礎功能。總結起來有兩點原因:一是效能測試需要的場景複雜程度相對 Phaser 類來講,還是小兒科了;二是使用 Java 進行效能測試時,儘量避免使用邏輯複雜的解決方案。還是那句話,如果遇到過於複雜的場景,則拋開 Phaser,尋求更加簡單、可靠的解決方案。
相比 CountDownLatch
,Phaser 在實戰中典型的使用場景是處理不定數量的併發任務同步問題。CountDownLatch
需要提前確定同步數量,但 Phaser 不需要。在使用當中,通常的使用流程如下:
- 建立 Phaser 物件,同步數量為 1。
- 指定多執行緒任務,每個任務開始前使用 Phaser 物件註冊,完成之後登出。
- 等待同步執行緒使用 Phaser 物件進行等待,直到全部註冊任務都完成。
2.6.1 基礎方法
在 Java 進行效能測試中,Phaser 類常用的構造方法只有 1 個:
public Phaser(int parties) {
this(null, parties);
}
這個方法只有一個 int
資料型別的引數,表示同步數量,這一點跟 CountDownLatch
類一樣。該方法對應 Phaser 工作流程的第一步。
Phaser 工作流程第二步,註冊:
public int register() {
return doRegister(1);
}
這個方法沒有引數,含義是當前執行緒申請同步數量加一,返回 int
型別資料,含義是當前同步階段。該方法存在失敗的可能,若嘗試註冊數量超過閾值,則會丟擲 IllegalStateException
異常。
登出:
public int arriveAndDeregister() {
return doArrive(ONE_DEREGISTER);
}
這個方法同樣沒有引數,含義是當前執行緒到達集合點並申請同步數量減一,返回 int
型別資料,含義是當前同步階段。該方法存在失敗可能,若是註冊人數或者未到達人數會因為該登出行為變成負值,則會丟擲 IllegalStateException
異常。
如果多執行緒任務到達集合點,期望等待其他執行緒都到達,並且繼續參與下一個階段的同步,可以使用下面這個方法:
public int arriveAndAwaitAdvance() {
// 方法體內容過多,省去。
}
如果多執行緒任務不想等其他執行緒,直接進入下一階段同步,可以使用下面這個方法:
public int arrive() {
return doArrive(ONE_ARRIVAL);
}
Phaser 工作流程第三步,同步執行緒的等待方法與多執行緒任務到達集合點方法重合,即使用 arriveAndAwaitAdvance()
方法。若是多階段同步的話,還可以指定需要等待的同步階段,透過呼叫下面的方法實現:
public int awaitAdvance(int phase) {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase)
return root.internalAwaitAdvance(phase, null);
return p;
}
這裡建議儘量避免使用多階段同步,儘量不在同步執行緒外呼叫該方法。因為這樣會使得程式碼邏輯複雜程度數量級上升,容易造成無限等待。
通常我們會統計非同步任務完成的數量,此時還會用到另外一個方法,獲取同步計數:
public int getArrivedParties() {
return arrivedOf(reconcileState());
}
該方法沒有引數,返回 int
資料型別,含義是已經註冊且已經到達集合點的數量。在 Java 效能測試中,通常用來統計多執行緒任務的完成進度。這裡請注意,統計數量不包含那些已經登出的任務,如果要統計所有完成的任務,請在到達集合點時使用 arrive()
方法而不是 arriveAndDeregister()
。
2.6.2 最佳實戰
下面透過一個小例子演示 Phaser 使用方法。
package org.funtester.performance.books.chapter02.section5;
import java.util.concurrent.Phaser;
/**
* Phaser 演示類
*/
public class PhaserDemo {
public static void main(String[] args) throws InterruptedException {
Phaser phaser = new Phaser(1); // 建立 Phaser 物件,將參與的執行緒數初始化為 1
for (int i = 0; i < 3; i++) { // 建立並啟動 3 個執行緒
phaser.register(); // 每建立並啟動一個執行緒,註冊一次
new Thread(() -> { // 建立非同步執行緒
phaser.arrive(); // 每個執行緒執行完任務後,通知 phaser,當前執行緒任務完成
System.out.println(System.currentTimeMillis() + " 完成完成 " + Thread.currentThread().getName()); // 列印當前執行緒完成任務的時間和執行緒名稱
}).start(); // 啟動非同步執行緒
}
System.out.println(System.currentTimeMillis() + " 完成任務總數: " + phaser.getArrivedParties()); // 列印已經完成任務的執行緒數
Thread.sleep(10); // 等待 10 毫秒
System.out.println(System.currentTimeMillis() + " 完成任務總數: " + phaser.getArrivedParties()); // 列印已經完成任務的執行緒數
phaser.arriveAndAwaitAdvance(); // 通知 phaser,當前執行緒任務完成,並等待其他執行緒完成任務
System.out.println(System.currentTimeMillis() + " 完成任務總數: " + phaser.getArrivedParties()); // 列印已經完成任務的執行緒數
}
}
上面這個例子中,首先建立了 Phaser 物件,並設定同步數量等於 1。其次建立 3 個非同步執行緒,分別在建立執行緒之前將同步物件註冊一次,每個執行緒執行邏輯為:到達集合點,不阻塞立即列印日誌。main
執行緒立即列印任務總數日誌,然後休眠 10 毫秒,再列印任務總數日誌,到達同步點並且阻塞等待所有執行緒到達同步點,最後再列印一次任務總數日誌。
1698560341651 完成完成 Thread-0
1698560341651 完成任務總數: 2
1698560341651 完成完成 Thread-1
1698560341652 完成完成 Thread-2
1698560341662 完成任務總數: 3
1698560341662 完成任務總數: 0
可以看出,第一次列印任務總數時,只有 2 個執行緒完成了任務。當 main
執行緒休眠完成之後,所有執行緒完成,所以第二次列印任務總數就是 3 了。當 main
執行緒到達同步點後,再列印日誌任務總數就是 0 了,原因是因為所有執行緒到達集合點之後,已經進行了第二階段的同步,所以列印出來的是第二個階段到達集合點的執行緒數,即為 0。
2.6.4 使用場景
對於大多數執行緒同步場景來說,動用 Phaser 的確大材小用,所以實際使用場景也不是很多。上面提到過的多執行緒處理批次任務,例如我需要把 1 萬個使用者個人資料都新增上收貨地址,然後再用這 1 萬賬號進行商品下單的操作。那麼需要這麼設計用例:
- 前置階段初始化使用者的收貨地址。
- 待所有任務完成後,進行下單的效能測試。
- 待壓測結束後,重置使用者資料,恢復測試使用者的元狀態。
這其中步驟 2 和 3 均涉及到了多執行緒同步,Phaser 是最好的選擇。此外,具有階段性的多執行緒任務非常適合 Phaser 大展拳腳,例如:要先從註冊賬號開始,其次將註冊成功的賬號進行使用者資訊初始化,然後再執行效能測試,最後清理資料。
2.6.5 自定義同步類
雖然 java.util.concurrent.Phaser
功能強大,但畢竟不是為了效能測試開發的功能類,在實踐中也會遇到一些水土不服的情況,總結為下面兩種:
- 註冊同步數量有上限,對應程式碼
private static final int MAX_PARTIES = 0xffff
,約 6 萬多。 - 極限效能不理想。在 Phaser 功能設計中,涉及多處鎖的操作,在高併發情況下效能表現不佳。
基於這樣的情況,如果我們有需求,就可以自己設計一款功能簡化之後的同步類。這個同步類需要實現以下功能:
- 執行緒安全計數,統計未完成的註冊任務數量。
- 執行緒安全計數,統計已完成任務數量。
- 提供註冊和完成方法。
- 提供返回註冊數量和完成數量的方法。
執行緒安全技術類,我們就選擇 java.util.concurrent.atomic.AtomicInteger
,可以規避掉 Phaser 上限較低的問題,如果還覺得不夠,可以用 java.util.concurrent.atomic.AtomicLong
替代。其他方法就比較容易,筆者將這個自定義的同步類叫做 FunPhaser
,以下是程式碼實踐內容:
package org.funtester.performance.books.chapter02.section6;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 自定義多執行緒同步類
*/
public class FunPhaser {
/**
* 任務總數索引, 用於標記任務完成狀態
* 註冊增加, 任務完成減少
*/
AtomicInteger index;
/**
* 任務總數, 用於記錄任務完成數量
*/
AtomicInteger taskNum;
public FunPhaser() {
this.index = new AtomicInteger(); // 初始化
this.taskNum = new AtomicInteger(); // 初始化
}
/**
* 註冊任務, 並返回當前註冊數量
* @return
*/
public int register() {
return this.index.incrementAndGet();
}
/**
* 任務完成
* @return
*/
public void done() {
this.index.getAndDecrement();
this.taskNum.getAndIncrement();
}
/**
* 等待所有任務完成
* @return
*/
public void await() throws InterruptedException {
long start = System.currentTimeMillis();
while (index.get() > 0) {
if (System.currentTimeMillis() - start > 100000) { // 預設超時時間 100 秒
System.out.println(System.currentTimeMillis() - start);
break;
}
Thread.sleep(100);
}
}
/**
* 等待所有任務完成
* @param timeout 超時時間, 單位毫秒
* @return
*/
public void await(int timeout) throws InterruptedException {
long start = System.currentTimeMillis();
while (index.get() > 0) {
if (System.currentTimeMillis() - start >= timeout) {
break;
}
Thread.sleep(100);
}
}
/**
* 獲取註冊總數
* @return
*/
public int queryRegisterNum() {
return index.get();
}
/**
* 獲取任務完成總數
* @return
*/
public int queryTaskNum() {
return taskNum.get();
}
}
下面寫個用例進行測試。思路如下:啟動 10 個執行緒,每個執行緒註冊一次,休眠模擬業務執行,最後完成任務。程式碼如下:
FunPhaser phaser = new FunPhaser(); // 建立 Phaser
for (int i = 0; i < 10; i++) { // 建立 10 個執行緒
new Thread(() -> { // 建立執行緒
for (int j = 0; j < 10; j++) { // 每個執行緒執行 10 次任務
phaser.register();
try {
Thread.sleep(10); // 模擬任務執行時間
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
phaser.done(); // 任務完成
}
}
}).start();
}
try {
phaser.await(); // 等待所有任務完成
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("任務註冊數 " + phaser.queryRegisterNum() + " 個");
System.out.println("任務完成總數 " + phaser.queryTaskNum() + " 個");
控制檯列印如下:
任務註冊數 0 個
任務完成總數 100 個
可以看出,我們最初的預想已經完美實現。
書的名字:從 Java 開始做效能測試 。
如果本書內容對你有所幫助,希望各位不吝讚賞,讓我可以貼補家用。讚賞兩位數可以提前閱讀未公開章節。我也會嘗試製作本書的影片教程,包括必要的答疑。
FunTester 原創精華
【連載】從 Java 開始效能測試
- 混沌工程、故障測試、Web 前端
- 服務端功能測試
- 效能測試專題
- Java、Groovy、Go
- 白盒、工具、爬蟲、UI 自動化
- 理論、感悟、影片