併發王者課-鉑金8:峽谷幽會-看CyclicBarrier如何跨越重巒疊嶂

秦二爺發表於2021-07-05

歡迎來到《併發王者課》,本文是該系列文章中的第21篇,鉑金中的第8篇

在上一篇文章中,我們介紹了CountDownLatch的用法。在協調多執行緒的開始和結束時,CountDownLatch是個非常不錯的選擇。而本文即將給你介紹的CyclicBarrier則更加有趣,它在能力上和CountDownLatch既有相似之處,又有著明顯的不同,值得你一覽究竟。本文會先從場景上帶你理解問題,再去理解CyclicBarrier提供的方案。

一、CyclicBarrier初體驗

1. 峽谷森林裡的愛情

在峽谷的江湖中,不僅有生殺予奪和刀光劍影,還有著美妙的愛情故事。

峽谷戰神鎧曾經在危急關頭救了大喬,這一出英雄救美讓他們擦除了愛情的火花,有事沒事兩人就在峽谷中的各個角落幽會。其中,峽谷森林就是他們常去的地方,誰先到就等另一個,兩人都到齊後,再一起玩耍

這裡頭,有兩個重點。一是他們要相互等待,二是都到齊後再玩耍。現在,我們試想一下,如果用程式碼來模擬這個場景的話,你打算怎麼做。有的同學可能會說,兩個人(執行緒)的等待很好處理。可是,如果是三人呢

所以,這個場景問題可以概括為:多個執行緒相互等待,到齊後再執行特定動作

接下來,我們就通過CyclicBarrier來模擬解決這個場景的問題,直觀感受CyclicBarrier的用法。

在下面這段程式碼中,我們定義了一個幽會地點(appointmentPlace),以及大喬這兩個主人公。在他們都達到幽會地點後,我們輸出一句包含三朵玫瑰???的話來予以確認,給他們送上祝福。

 private static String appointmentPlace = "峽谷森林";

 public static void main(String[] args) {
   CyclicBarrier cyclicBarrier = new CyclicBarrier(2, () -> print("???到達約會地點:大喬和鎧都來到了?" + appointmentPlace));
   Thread 大喬 = newThread("大喬", () -> {
     say("鎧,你在哪裡...");
     try {
       cyclicBarrier.await(); //到達幽會地點
       say("鎧,你終於來了...");
     } catch (Exception e) {
       e.printStackTrace();
     }
   });

   Thread 鎧 = newThread("鎧", () -> {
     try {
       Thread.sleep(500); //鎧打野中
       say("我打個野,馬上就到!");
       cyclicBarrier.await(); //到達幽會地點
       say("喬,不好意思,剛打野遇上蘭陵王了,你還好嗎?!");
     } catch (Exception e) {
       e.printStackTrace();
     }
   });

   大喬.start();
   鎧.start();
 }

輸出結果如下:

大喬:鎧,你在哪裡...
鎧:我打個野,馬上就到!
???到達約會地點:大喬和鎧都來到了?峽谷森林
鎧:喬,不好意思,剛打野遇上蘭陵王了,你還好嗎?!
大喬:鎧,你終於來了...

Process finished with exit code 0

對於程式碼的細節暫且不必深究,本文後面對CyclicBarrier的內部細節會有詳解,先感受它的基本用法。

從結果中可以看到,CyclicBarrier可以像CountDownLatch一樣,協調多執行緒的執行結束動作,在它們都結束後執行特定動作。從這點上來說,這是CyclicBarrier與CountDownLatch相似之處。然而,接下來的這個場景,所體現的則是它們一個明顯的不同之處。

2. 小河邊的幽會

在上面的場景中,鎧已經提到他在打野時遇到了蘭陵王。而在鎧與大喬的約會中,蘭陵王竟然又撞見了他們,真是冤家路窄。於是,在蘭陵王的攪局下,鎧和大喬不得不轉移陣地,他們同樣約定到新的約定地點後等待對方。(鎧一直以為蘭陵王也喜歡大喬,要和他橫刀奪愛,其實蘭陵王在乎的只是鎧打了它的野,他的心裡只有野怪,對任何女人毫無興趣)。

此時,如果繼續用程式碼模擬這一場景的話,那麼CountDownLatch就無能為力了,因為CountDownLatch的使用是一次性的,無法重複利用。而此時,你就會發現CyclicBarrier的神奇之處,它竟然可以重複利用。似乎,你可能已經大概明白它為什麼叫Cyclic的原因了。

接下來,我們再走一段程式碼,模擬大喬和鎧的第二次幽會。在程式碼中,我們仍然定義幽會地點、大喬和鎧兩個主人公。但是與此前不同的是,我們還增加了蘭陵王這個攪局者,以及中途變更了幽會地點

private static String appointmentPlace = "峽谷森林";

public static void main(String[] args) {
  CyclicBarrier cyclicBarrier = new CyclicBarrier(2, () -> System.out.println("???到達約會地點:大喬和鎧都來到了?" + appointmentPlace));
  Thread 大喬 = newThread("大喬", () -> {
    say("鎧,你在哪裡...");
    try {
      cyclicBarrier.await();
      say("鎧,你終於來了...");
      Thread.sleep(2600); //約會中...
      say("好的,你要小心!");
      cyclicBarrier.await(); // 注意這裡是第二次呼叫await
      Thread.sleep(100);
      say("真好!");
    } catch (Exception e) {
      e.printStackTrace();
    }
  });

  Thread 鎧 = newThread("鎧", () -> {
    try {
      Thread.sleep(500); //鎧打野中
      say("我打個野,馬上就到!");
      cyclicBarrier.await(); //到達幽會地點
      say("喬,不好意思,剛打野遇上蘭陵王了,你還好嗎?!");
      Thread.sleep(1500); //幽會中...

      note("幽會中...\n");

      Thread.sleep(1000); //幽會中...
      say("這個該死的蘭陵王!喬,你先走,小河邊見!"); //鎧突然看到了蘭陵王
      appointmentPlace = "小河邊"; // 鎧把地點改成了小河邊

      Thread.sleep(1500); //和蘭陵王對決中...
      note("︎\uD83D\uDDE1\uD83D\uDD2A鎧和蘭陵王決戰開始,最終鎧殺死了蘭陵王,並前往小河邊...\n");
      cyclicBarrier.await(); // 殺了蘭陵王后,鎧到了小河邊 !!!注意這裡是第二次呼叫await

      say("喬,我已經解決了蘭陵王,你看今晚夜色多美,我陪你看星星到天明...");
    } catch (Exception ignored) {}
  });

  Thread 蘭陵王 = newThread("蘭陵王", () -> {
    try {
      Thread.sleep(2500);
      note("蘭陵王出場...");
      say("鎧打了我的野,不殺他誓不罷休!");

      say("鎧,原來你和大喬在這裡!\uD83D\uDDE1️\uD83D\uDDE1️");
    } catch (Exception ignored) {}
  });

  蘭陵王.start();
  大喬.start();
  鎧.start();
}

輸出結果如下所示。鎧峽谷森林的好事被蘭陵王攪局後,鎧怒火中燒,讓大喬先走,並約定在小河邊碰面。隨後,鎧斬殺了蘭陵王(可憐的鋼鐵直男),並前往小河邊,完成他和大喬的第二次幽會

大喬:鎧,你在哪裡...
鎧:我打個野,馬上就到!
???到達約會地點:大喬和鎧都來到了?峽谷森林
鎧:喬,不好意思,剛打野遇上蘭陵王了,你還好嗎?!
大喬:鎧,你終於來了...
幽會中...

蘭陵王出場...
蘭陵王:鎧打了我的野,不殺他誓不罷休!
蘭陵王:鎧,原來你和大喬在這裡!?️?️
鎧:這個該死的蘭陵王!喬,你先走,小河邊見!
大喬:好的,你要小心!
︎??鎧和蘭陵王決戰開始,最終鎧殺死了蘭陵王,並前往小河邊...

???到達約會地點:大喬和鎧都來到了?小河邊
鎧:喬,我已經解決了蘭陵王,你看今晚夜色多美,我陪你看星星到天明...
大喬:真好!

Process finished with exit code 0

同樣的,你暫時不要理會程式碼的細節,但是你要注意到其中鎧和大喬對await()的兩次呼叫。在你沒有理解它的原理之前,可能會驚訝於它的神奇,這是正常現象。

二、CyclicBarrier是如何實現的

CyclicBarrier是Java中提供的一個執行緒同步工具,與CountDownLatch相似,但又並不完全相同,最核心的區別在於CyclicBarrier是可以迴圈使用的,這一點在它的名字中也已經有所體現

接下來,我們來分析下它具體的原始碼實現。

1. 核心資料結構

  • private final ReentrantLock lock = new ReentrantLock():進入屏障的鎖,只有一把;
  • private final Condition trip = lock.newCondition():和上面的lock配套使用;
  • private final int parties:參與方的數量,本文上述的例子只有鎧和大喬,所以數量是2;
  • private final Runnable barrierCommand:在本輪結束時執行的特定程式碼。本文上述例子用到了它,可以上翻檢視;
  • private Generation generation = new Generation():當前屏障的代次。比如本文上述的兩個場景中,generation是不同的,在鎧和大喬將幽會地點改成小河邊後,會生成新的generation;
  • private int count:正在等待的參與方數量。在每個代次中,count會從最初的參與數量(即parties)將至0,到0時本代次結束,而在新的代次或本代次被拆除(broken)時,count的值會恢復為parties的值。

2. 核心構造

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

3. 核心方法

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

在CyclicBarrier的各方法中,最為核心的就是dowait(),兩個await()的內部都是呼叫這個方法。所以,理解了dowait(),基本上就理解了CyclicBarrier的實現關鍵。

dowait()方法略長,稍微需要點耐心,我已經對其中部分做了註釋。當然,如果你想看原始碼的話,還是建議直接從JDK中看它的全部,這裡的原始碼只是為了輔助你理解上下文。

private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,TimeoutException {
    final ReentrantLock lock = this.lock;
    lock.lock(); // 注意,這裡是一定要加鎖的
    try {
        final Generation g = generation;

        if (g.broken) // 如果當前屏障被拆除,則丟擲異常
            throw new BrokenBarrierException();

        if (Thread.interrupted()) { 
            breakBarrier(); // 如果當前執行緒被中斷,則拆除屏障並丟擲異常
            throw new InterruptedException();
        }

        int index = --count; // 當執行緒呼叫await後,count減1
        if (index == 0) { // tripped // 如果count為0,接下來將嘗試結束屏障,並開啟新的屏障
            boolean ranAction = false;
            try {
                final Runnable command = barrierCommand;
                if (command != null)
                    command.run();
                ranAction = true;
                nextGeneration();
                return 0;
            } finally {
                if (!ranAction)
                    breakBarrier();
            }
        }

        // loop until tripped, broken, interrupted, or timed out
        for (;;) {
            try {
                if (!timed)
                    trip.await();
                else if (nanos > 0 L)
                    nanos = trip.awaitNanos(nanos);
            } catch (InterruptedException ie) {
                if (g == generation && !g.broken) {
                    breakBarrier();
                    throw ie;
                } else {
                    // We're about to finish waiting even if we had not
                    // been interrupted, so this interrupt is deemed to
                    // "belong" to subsequent execution.
                    Thread.currentThread().interrupt();
                }
            }

            if (g.broken)
                throw new BrokenBarrierException();

            if (g != generation)
                return index;

            if (timed && nanos <= 0 L) {
                breakBarrier();
                throw new TimeoutException();
            }
        }
    } finally {
        lock.unlock();
    }
}

對於CyclicBarrier的核心資料結構、構造和方法,都在上面,它們很重要。但是,更為重要的是,要理解CyclicBarrier的思想,也就是下面這幅值得你收藏的圖理解了這幅圖,也就理解了CyclicBarrier.

此時,從這幅圖再回頭看第一節的兩個場景,鎧和大喬先後在峽谷森林小河邊兩個地點幽會。那麼,如果也用一幅圖來表示的話,它應該是下面這樣:

三、CyclicBarrier與CountDownLatch有何不同

前面兩節已經提到了兩者的核心不同:

  • CountDownLatch是一次性的,而CyclicBarrier則可以多次設定屏障,實現重複利用
  • CountDownLatch中的各個子執行緒不可以等待其他執行緒,只能完成自己的任務;而CyclicBarrier中的各個執行緒可以等待其他執行緒

除此之外,它們倆還有著一些其他的不同,整體彙總後如下面的表格所示:

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

小結

以上就是關於CyclicBarrier的全部內容。在學習CyclicBarrier時,要側重理解它所要解決的問題場景,以及它與CountDownLatch的不同,然後再去看原始碼,這也是為什麼我們沒有上來就放原始碼而是繞彎講了個故事的原因,雖然那個故事挺“狗血”。當然,如果這個狗血的故事能讓你記住這個知識點,狗血也值得了。

正文到此結束,恭喜你又上了一顆星✨

夫子的試煉

  • 編寫程式碼體驗CyclicBarrier用法。

延伸閱讀與參考資料

關於作者

關注【技術八點半】,及時獲取文章更新。傳遞有品質的技術文章,記錄平凡人的成長故事,偶爾也聊聊生活和理想。早晨8:30推送作者品質原創,晚上20:30推送行業深度好文。

如果本文對你有幫助,歡迎點贊關注監督,我們一起從青銅到王者

相關文章