探秘多執行緒-閉鎖、柵欄與非同步編排

yuan發表於2023-02-04

無論是專案開發還是開原始碼閱讀,多執行緒都是不可或缺的一個重要知識點,基於這個考量,於是總結出本篇文章,討論閉鎖(CountDownLatch)、柵欄(CyclicBarrier)與非同步編排(CompletableFuture)
@Author:Akai-yuan
@更新時間:2023/2/4

1.CountDownLatch

1.適用場景

  1. 協調子執行緒結束動作:等待所有子執行緒執行結束

主執行緒建立了5個子執行緒,各子任務執行確認動作,期間主執行緒進入等待狀態,直到各子執行緒的任務均已經完成,主執行緒恢復繼續執行。

  1. 協調子執行緒開始動作:統一各執行緒動作開始的時機

從多執行緒的角度看,這恰似你建立了一些多執行緒,但是你需要統一管理它們的任務開始時間。

2.設計思想

CountDownLatch基於一個同步器實現,並且只有CountDownLatch(int count)一個構造器,指定數量count不得在中途修改它。
核心函式

  • await():等待latch降為0;
  • boolean await(long timeout, TimeUnit unit):等待latch降為0,但是可以設定超時時間。
  • countDown():latch數量減1;
  • getCount():獲取當前的latch數量。

3.場景例項

場景1. 對各子執行緒的等待

public static void main(String[] args) throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(4);

    Thread t1 = new Thread(countDownLatch::countDown);
    Thread t2 = new Thread(countDownLatch::countDown);
    Thread t3 = new Thread(countDownLatch::countDown);
    Thread t4 = new Thread(() -> {
        try {
            // 稍等...
            Thread.sleep(1500);
            countDownLatch.countDown();
        } catch (InterruptedException ignored) {}
    });

    t1.start();
    t2.start();
    t3.start();
    t4.start();
		//直到所有執行緒都對計數器進行減一後,這裡才放行
    countDownLatch.await();
    System.out.println("所有子執行緒就位,可以繼續執行其他任務");
}

場景2. 對多執行緒的統一管理

我們仍然用4個執行緒呼叫了start(),但是它們在執行時都在等待countDownLatch的訊號,在訊號未收到前,它們不會往下執行。

public static void main(String[] args) throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(1);

    Thread t1 = new Thread(() -> waitForCountDown(countDownLatch));
    Thread t2 = new Thread(() -> waitForCountDown(countDownLatch));
    Thread t3 = new Thread(() -> waitForCountDown(countDownLatch));
    Thread t4 = new Thread(() -> waitForCountDown(countDownLatch));

    t1.start();
    t2.start();
    t3.start();
    t4.start();
    Thread.sleep(1000);
    countDownLatch.countDown();
    System.out.println("所有執行緒準備完成");
}

private static void waitForCountDown(CountDownLatch countDownLatch) {
    try {
			// 等待訊號
        countDownLatch.await(); 
        System.out.println("本執行緒等待完畢");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

輸出:

所有執行緒準備完成
本執行緒等待完畢
本執行緒等待完畢
本執行緒等待完畢
本執行緒等待完畢

Process finished with exit code 0

場景3. SOFAJRaft的實踐

// 定義一個CountDownLatch計數器
private final CountDownLatch startTimeInitialized = new CountDownLatch(1);

public void start() {
    switch (workerStateUpdater.get(this)) {
        case WORKER_STATE_INIT:
            if (workerStateUpdater.compareAndSet(this, WORKER_STATE_INIT, WORKER_STATE_STARTED)) {
                //此處呼叫工作執行緒執行CountDownLatch的countDown()方法
                //即startTimeInitialized.countDown();
                workerThread.start();
            }
            break;
        case WORKER_STATE_STARTED:
            break;
        case WORKER_STATE_SHUTDOWN:
            throw new IllegalStateException("cannot be started once stopped");
        default:
            throw new Error("Invalid WorkerState");
    }

    // 等待startTime被工作執行緒初始化完成
    while (startTime == 0) {
        try {
            startTimeInitialized.await();
        } catch (InterruptedException ignore) {
            // Ignore - it will be ready very soon.
        }
    }
}

2.CyclicBarrier

1.適用場景

柵欄類似於閉鎖,它能阻塞一組執行緒直到某個事件的發生。柵欄與閉鎖的關鍵區別在於,所有的執行緒必須同時到達柵欄位置,才能繼續執行。閉鎖用於等待事件,而柵欄用於等待其他執行緒。
CyclicBarrier與CountDownLatch的區別

CyclicBarrier CountDownLatch
CyclicBarrier是可重用的,其中的執行緒會等待所有的執行緒完成任務。屆時,屏障將被拆除,並可以選擇性地做一些特定的動作。 CountDownLatch是一次性的,不同的執行緒在同一個計數器上工作,直到計數器為0
CyclicBarrier面向的是執行緒數 CountDownLatch面向的是任務數
在使用CyclicBarrier時,你必須在構造中指定參與協作的執行緒數,這些執行緒必須呼叫await()方法 使用CountDownLatch時,則必須要指定任務數,至於這些任務由哪些執行緒完成無關緊要
CyclicBarrier可以在所有的執行緒釋放後重新使用 CountDownLatch在計數器為0時不能再使用
在CyclicBarrier中,如果某個執行緒遇到了中斷、超時等問題時,則處於await的執行緒都會出現問題 在CountDownLatch中,如果某個執行緒出現問題,其他執行緒不受影響

2.設計思想

1.構造器

// 指定參與方的數量;
public CyclicBarrier(int parties);
// 指定參與方的數量,並指定在本代次結束時執行的程式碼
public CyclicBarrier(int parties, Runnable barrierAction):

2.核心方法

//如果當前執行緒不是第一個到達屏障的話,它將會進入等待,直到其他執行緒都到達
//除非發生被中斷、屏障被拆除、屏障被重設等情況
public int await();
//和await()類似,但是加上了時間限制;
public int await(long timeout, TimeUnit unit);
//當前屏障是否被拆除;
public boolean isBroken();
//重設當前屏障。會先拆除屏障再設定新的屏障
public void reset();
//正在等待的執行緒數量
public int getNumberWaiting();

3.場景例項

下面以一個簡單的日常對話來講解CyclicBarrier的使用例項

private static String appointmentPlace = "書房";

public static void main(String[] args) {
  CyclicBarrier cyclicBarrier = new CyclicBarrier(2, () -> System.out.println("yuan所在的地點:" + appointmentPlace));
    // 執行緒Akai
    Thread Akai = newThread("Akai", () -> {
    System.out.println("yuan,飯好了快來吃飯...");
    try {
      // 此時Akai在屏障前等待
      cyclicBarrier.await();
      System.out.println("yuan,你來了...");
      // 開始吃飯...
      Thread.sleep(2600); 
      System.out.println("好的,你去洗你的碗吧!");
      // 第二次呼叫await
      cyclicBarrier.await(); 
      Thread.sleep(100);
      System.out.println("好吧,你這個懶豬!");
    } catch (Exception e) {
      e.printStackTrace();
    }
  });
  // 執行緒yuan
  Thread yuan = newThread("yuan", () -> {
    try {
      // yuan在敲程式碼
      Thread.sleep(500); 
      System.out.println("我在敲程式碼,我馬上就來!");
      // yuan到達飯桌前
      cyclicBarrier.await(); 
      Thread.sleep(500); 
      System.out.println("Akai,不好意思,剛剛沉迷於敲程式碼了!");
      // 開始吃飯...
      Thread.sleep(1500); 
      // yuan想先吃完趕快洗碗然後溜出去敲程式碼
      System.out.println("我吃完了,我要去洗碗了"); 
      // yuan把地點改成了廚房
      appointmentPlace = "廚房"; 
      // 洗碗中...
      Thread.sleep(1500); 
      System.out.println("︎yuan終於洗完自己的碗了");
      // 第二次呼叫await
      cyclicBarrier.await();
      System.out.println("Akai你吃完了,你的碗自己去洗吧,我已經在敲程式碼了");
    } catch (Exception ignored) {}
  });

  Akai.start();
  yuan.start();
}

輸出結果:

yuan,飯好了快來吃飯...
我在敲程式碼,我馬上就來!
yuan所在的地點:書房
yuan,你來了...
Akai,不好意思,剛剛沉迷於敲程式碼了!
我吃完了,我要去洗碗了
好的,你去洗你的碗吧!
yuan終於洗完自己的碗了
yuan所在的地點:廚房
Akai你吃完了,你的碗自己去洗吧,我已經在敲程式碼了
好吧,你這個懶豬!    

3.CompletableFuture

1.設計思想

1.Future的侷限性

  • 併發執行多工:Future只提供了get()方法來獲取結果,並且是阻塞的。所以,除了等待你別無他法;
  • 無法對多個任務進行鏈式呼叫:如果你希望在計算任務完成後執行特定動作,比如發郵件,但Future卻沒有提供這樣的能力;
  • 無法組合多個任務:如果你執行了10個任務,並期望在它們全部執行結束後執行特定動作,那麼在Future中這是無能為力的;
  • 沒有異常處理:Future介面中沒有關於異常處理的方法;

2.Completable有哪些優勢

CompletableFuture是Future介面的擴充套件和增強
CompletableFuture完整地繼承了Future介面,並在此基礎上進行了豐富地擴充套件,完美地彌補了Future上述的種種問題。更為重要的是,CompletableFuture實現了對任務的編排能力。藉助這項能力,我們可以輕鬆地組織不同任務的執行順序、規則以及方式。
從某種程度上說,這項能力是它的核心能力。而在以往,雖然透過CountDownLatch等工具類也可以實現任務的編排,但需要複雜的邏輯處理,不僅耗費精力且難以維護。

2.核心設計

我們首先來討論CompletableFuture的核心:CompletionStage
顧名思義,根據CompletionStage名字中的"Stage",你可以把它理解為任務編排中的步驟。步驟,即任務編排的基本單元,它可以是一次純粹的計算或者是一個特定的動作。在一次編排中,會包含多個步驟,這些步驟之間會存在依賴鏈式組合等不同的關係,也存在並行序列的關係。這種關係,類似於Pipeline或者流式計算。
既然是編排,就需要維護任務的建立、建立計算關係。為此,CompletableFuture提供了多達50多個方法,但沒有必要全部完全理解,但我們可以透過分類的方式簡化對方法的理解,理解了型別變種,基本上我們也就掌握了CompletableFuture的核心能力。
這些方法可以總結為以下四類,其他大部分方法都是基於這四種型別的變種:

3.核心用法

1.runAsync

  • runAsync()是CompletableFuture最常用的方法之一,它可以接收一個待執行的任務並返回一個CompletableFuture
  • 當我們想非同步執行某個任務時,在以往需要手動實現Thread或者藉助Executor實現。而透過runAsync()`就簡單多了。比如,我們可以直接傳入Runnable型別的任務:
CompletableFuture.runAsync(new Runnable() {
    @Override
    public void run() {
        System.out.println("something");
    }
});

2.supply與supplyAsync

  • 所謂supply表示提供結果,換句話說當我們使用supply()時,就表明我們會返回一個結果,並且這個結果可以被後續的任務所使用。
// 建立nameFuture,返回姓名
CompletableFuture <String> nameFuture = CompletableFuture.supplyAsync(() -> {
    return "Akai-yuan";
});

 // 使用thenApply()接收nameFuture的結果,並執行回撥動作
CompletableFuture <String> sayLoveFuture = nameFuture.thenApply(name -> {
    return "love you," + name;
});

//阻塞獲得表白的結果
System.out.println(sayLoveFuture.get()); // love you,Akai-yuan

一旦理解了supply()的含義,它也就如此簡單。如果你希望用新的執行緒執行任務,可以使用supplyAsync().

3.thenApply與thenApplyAsync

  • 我們已經知道supply()是用於提供結果的,並且順帶提了thenApply()。很明顯,thenApply()supply()的搭檔,用於接收supply()的執行結果,並執行特定的程式碼邏輯,最後返回CompletableFuture結果
 // 使用thenApply()接收nameFuture的結果,並執行回撥動作
CompletableFuture <String> sayLoveFuture = nameFuture.thenApply(name -> {
    return "愛你," + name;
});

public <U> CompletableFuture <U> thenApplyAsync(
    Function <? super T, ? extends U> fn) {
    return uniApplyStage(null, fn);
}

4.thenAccept與thenAcceptAsync

作為supply()的檔案,thenApply()並不是唯一的存在,thenAccept()也是。但與thenApply()不同,thenAccept()只接收資料,但不會返回,它的返回型別是Void.

CompletableFuture<Void> sayLoveFuture = nameFuture.thenAccept(name -> {
     System.out.println("愛你," + name);
});
        
public CompletableFuture < Void > thenAccept(Consumer < ? super T > action) {
    return uniAcceptStage(null, action);
}

5.thenRun

thenRun()就比較簡單了,不接收任務的結果,只執行特定的任務,並且也不返回結果。

public CompletableFuture < Void > thenRun(Runnable action) {
   return uniRunStage(null, action);
}

所以,如果你在回撥中不想返回任何的結果,只執行特定的邏輯,那麼你可以考慮使用thenAcceptthenRun一般來說,這兩個方法會在呼叫鏈的最後面使用。

6.thenCompose與 thenCombine

以上幾種方法都是各玩各的,但thenCompose()與thenCombine()就不同了,它們可以實現對依賴非依賴兩種型別的任務的編排。
編排兩個存在依賴關係的任務
在前面的例子中,在接收前面任務的結果時,我們使用的是thenApply(). 也就是說,sayLoveFuture在執行時必須依賴nameFuture的完成,否則執行個錘子。

// 建立Future
CompletableFuture <String> nameFuture = CompletableFuture.supplyAsync(() -> {
    return "Akai-yuan";
});

 // 使用thenApply()接收nameFuture的結果,並執行回撥動作
CompletableFuture <String> sayLoveFuture = nameFuture.thenApply(name -> {
    return "愛你," + name;
});

但其實,除了thenApply()之外,我們還可以使用thenCompose()來編排兩個存在依賴關係的任務。比如,上面的示例程式碼可以寫成:

// 建立Future
CompletableFuture <String> nameFuture = CompletableFuture.supplyAsync(() -> {
    return "Akai-yuan";
});

CompletableFuture<String> sayLoveFuture2 = nameFuture.thenCompose(name -> {
    return CompletableFuture.supplyAsync(() -> "愛你," + name);
});

可以看到,thenCompose()和thenApply()的核心不同之處在於它們的返回值型別

  • thenApply():返回計算結果的原始型別,比如返回String;
  • thenCompose():返回CompletableFuture型別,比如返回CompletableFuture.

組合兩個相互獨立的任務
考慮一個場景,當我們在執行某個任務時,需要其他任務就緒才可以,應該怎麼做?這樣的場景並不少見,我們可以使用前面學過的併發工具類實現,也可以使用thenCombine()實現。
舉個例子,當我們計算某個勝率時,我們需要獲取她參與的總場次(rounds),以及獲勝的場次(winRounds),然後再透過winRounds / rounds來計算。對於這個計算,我們可以這麼做:

CompletableFuture < Integer > roundsFuture = CompletableFuture.supplyAsync(() -> 500);
CompletableFuture < Integer > winRoundsFuture = CompletableFuture.supplyAsync(() -> 365);

CompletableFuture < Object > winRateFuture = roundsFuture
    .thenCombine(winRoundsFuture, (rounds, winRounds) -> {
        if (rounds == 0) {
            return 0.0;
        }
        DecimalFormat df = new DecimalFormat("0.00");
        return df.format((float) winRounds / rounds);
    });
System.out.println(winRateFuture.get());

thenCombine()將另外兩個任務的結果同時作為引數,參與到自己的計算邏輯中。在另外兩個引數未就緒時,它將會處於等待狀態。

7.allOf與anyOf

allOf()與anyOf()也是一對孿生兄弟,當我們需要對多個Future的執行進行組織時,就可以考慮使用它們:

  • allOf():給定一組任務,等待所有任務執行結束;
  • anyOf():給定一組任務,等待其中任一任務執行結束。

allOf()與anyOf()的方法簽名如下:

static CompletableFuture<Void>	 allOf(CompletableFuture<?>... cfs)
static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs)

需要注意的是,anyOf()將返回完任務的執行結果,但是allOf()不會返回任何結果,它的返回值是Void.
allOf()與anyOf()的示例程式碼如下所示。我們建立了roundsFuture和winRoundsFuture,並透過sleep模擬它們的執行時間。在執行時,winRoundsFuture將會先返回結果,所以當我們呼叫 CompletableFuture.anyOf時也會發現輸出的是365.

 CompletableFuture < Integer > roundsFuture = CompletableFuture.supplyAsync(() -> {
   try {
     Thread.sleep(200);
     return 500;
   } catch (InterruptedException e) {
     return null;
   }
 });
 CompletableFuture < Integer > winRoundsFuture = CompletableFuture.supplyAsync(() -> {
   try {
     Thread.sleep(100);
     return 365;
   } catch (InterruptedException e) {
     return null;
   }
 });

 CompletableFuture < Object > completedFuture = CompletableFuture.anyOf(winRoundsFuture, roundsFuture);
 System.out.println(completedFuture.get()); // 返回365

 CompletableFuture < Void > completedFutures = CompletableFuture.allOf(winRoundsFuture, roundsFuture);

在CompletableFuture之前,如果要實現所有任務結束後執行特定的動作,我們可以考慮CountDownLatch等工具類。現在,則多了一選項,我們也可以考慮使用CompletableFuture.allOf.

8.異常處理

在CompletableFuture鏈式呼叫中,如果某個任務發生了異常,那麼後續的任務將都不會再執行。對於異常,我們有兩種處理方式:exceptionally()handle().

1.使用exceptionally()回撥處理異常

在鏈式呼叫的尾部使用exceptionally(),捕獲異常並返回錯誤情況下的預設值。需要注意的是,exceptionally()僅在發生異常時才會呼叫

CompletableFuture < ? extends Serializable > winRateFuture = roundsFuture
    .thenCombine(winRoundsFuture, (rounds, winRounds) -> {
        if (rounds == 0) {
            throw new RuntimeException("總場次錯誤");
        }
        DecimalFormat df = new DecimalFormat("0.00");
        return df.format((float) winRounds / rounds);
    }).exceptionally(ex -> {
        System.out.println("出錯:" + ex.getMessage());
        return "";
    });
System.out.println(winRateFuture.get());
2. 使用handle()處理異常

除了exceptionally(),CompletableFuture也提供了handle()來處理異常。不過,與exceptionally()不同的是,當我們在呼叫鏈中使用了handle(),那麼無論是否發生異常,都會呼叫它。所以,在handle()方法的內部,我們需要透過 if (ex != null) 來判斷是否發生了異常。

CompletableFuture < ? extends Serializable > winRateFuture = roundsFuture
    .thenCombine(winRoundsFuture, (rounds, winRounds) -> {
        if (rounds == 0) {
            throw new RuntimeException("總場次錯誤");
        }
        DecimalFormat df = new DecimalFormat("0.00");
        return df.format((float) winRounds / rounds);
    }).handle((res, ex) -> {
        if (ex != null) {
            System.out.println("出錯:" + ex.getMessage());
            return "";
        }
        return res;
    });
System.out.println(winRateFuture.get());

當然,如果我們允許某個任務發生異常而不中斷整個呼叫鏈路,那麼可以在其內部透過try-catch消化掉。

相關文章