【連載 11】Phaser 類

FunTester發表於2025-01-13

2.6 Phaser

Phaser 是上一節提到的更高階的執行緒同步工具。Phaser 的包路徑是 java.util.concurrent.Phaser,屬於 Java 多執行緒程式設計的核心功能。Phaser 類的主要功能是控制多個執行緒在特定的同步時間點同步執行。從文字介紹上看,它似乎沒有特別之處,但其實際功能相比 CountDownLatch 增強了不止一星半點。Phaser 可以說是 Java 多執行緒同步的終極解決方案。

Phaser 類支援多階段執行緒同步、動態的註冊和登出、指定同步階段、子同步功能,可以在到達集合點後不阻塞繼續執行下一階段,還可以中斷等待的階段、全域性管理等。

終究是 Phaser 類功能太強大了,而作為效能測試工具,它有些高攀不起。所以在效能測試中使用到的還是 Phaser 類的基礎功能。總結起來有兩點原因:一是效能測試需要的場景複雜程度相對 Phaser 類來講,還是小兒科了;二是使用 Java 進行效能測試時,儘量避免使用邏輯複雜的解決方案。還是那句話,如果遇到過於複雜的場景,則拋開 Phaser,尋求更加簡單、可靠的解決方案。

相比 CountDownLatch,Phaser 在實戰中典型的使用場景是處理不定數量的併發任務同步問題。CountDownLatch 需要提前確定同步數量,但 Phaser 不需要。在使用當中,通常的使用流程如下:

  1. 建立 Phaser 物件,同步數量為 1。
  2. 指定多執行緒任務,每個任務開始前使用 Phaser 物件註冊,完成之後登出。
  3. 等待同步執行緒使用 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 萬賬號進行商品下單的操作。那麼需要這麼設計用例:

  1. 前置階段初始化使用者的收貨地址。
  2. 待所有任務完成後,進行下單的效能測試。
  3. 待壓測結束後,重置使用者資料,恢復測試使用者的元狀態。

這其中步驟 2 和 3 均涉及到了多執行緒同步,Phaser 是最好的選擇。此外,具有階段性的多執行緒任務非常適合 Phaser 大展拳腳,例如:要先從註冊賬號開始,其次將註冊成功的賬號進行使用者資訊初始化,然後再執行效能測試,最後清理資料。

2.6.5 自定義同步類

雖然 java.util.concurrent.Phaser 功能強大,但畢竟不是為了效能測試開發的功能類,在實踐中也會遇到一些水土不服的情況,總結為下面兩種:

  1. 註冊同步數量有上限,對應程式碼 private static final int MAX_PARTIES = 0xffff,約 6 萬多。
  2. 極限效能不理想。在 Phaser 功能設計中,涉及多處鎖的操作,在高併發情況下效能表現不佳。

基於這樣的情況,如果我們有需求,就可以自己設計一款功能簡化之後的同步類。這個同步類需要實現以下功能:

  1. 執行緒安全計數,統計未完成的註冊任務數量。
  2. 執行緒安全計數,統計已完成任務數量。
  3. 提供註冊和完成方法。
  4. 提供返回註冊數量和完成數量的方法。

執行緒安全技術類,我們就選擇 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 自動化
  • 理論、感悟、影片
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章