併發王者課-黃金3:雨露均沾-不要讓你的執行緒在競爭中被“餓死”

秦二爺發表於2021-06-13

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

在上篇文章中,我們介紹了避免死鎖的幾種策略。雖然死鎖臭名昭著,然而在併發程式設計中,除了死鎖之外,還有一些同樣重要的執行緒活躍性問題值得關注。它們的知名度不高,但破壞性極強,本文將介紹的正是其中的執行緒飢餓活鎖問題

一、飢餓的產生

所謂執行緒 飢餓(Starvation) 指的是在多執行緒的資源競爭中,存在貪婪的執行緒一直鎖定資源不釋放,其他的執行緒則始終處於等待狀態,然而這個等待是沒有結果的,它們會被活活地餓死

獨佔者的貪婪是飢餓產生的原因之一,概括來說,飢餓一般由下面三種原因導致:

(1)執行緒被無限阻塞

當獲得鎖的執行緒需要執行無限時間長的操作時(比如IO或者無限迴圈),那麼後面的執行緒將會被無限阻塞,導致被餓死。

(2) 執行緒優先順序降低沒有獲得CPU時間

當多個競爭的執行緒被設定優先順序之後,優先順序越高,執行緒被給予的CPU時間越多。在某些極端情況下,低優先順序的執行緒可能永遠無法被授予充足的CPU時間,從而導致被餓死。

(3) 執行緒永遠在等待資源

在青銅系列文章中,我們說過notify在傳送通知時,是無法喚醒指定執行緒的。當多個執行緒都處於wait時,那麼部分執行緒可能始終無法被通知到,以至於捱餓。

二、飢餓與公平

為了直觀體驗執行緒的飢餓,我們建立了下面的程式碼。

建立哪吒、蘭陵王等四個英雄玩家,他們以競爭的方式打野,殺死野怪可以獲得經濟收益。

public class StarvationExample {

  public static void main(String[] args) {
    final WildMonster wildMonster = new WildMonster();

    String[] players = {
      "哪吒",
      "蘭陵王",
      "鎧",
      "典韋"
    };
    for (String player: players) {
      Thread playerThread = new Thread(new Runnable() {
        public void run() {
          wildMonster.killWildMonster();
        }
      });
      playerThread.setName(player);
      playerThread.start();
    }
  }
}

 public class WildMonster {
   public synchronized void killWildMonster() {
     while (true) {
       String playerName = Thread.currentThread().getName();
       System.out.println(playerName + "斬獲野怪!");
       try {
         Thread.sleep(500);
       } catch (InterruptedException e) {
         System.out.println("打野中斷");
       }
     }
   }
 }

執行結果如下:

哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!
哪吒斬獲野怪!

Process finished with exit code 130 (interrupted by signal 2: SIGINT)

從結果中可以看到,在幾個執行緒的執行中,始終只有哪吒可以斬獲野怪,其他英雄束手無策等著被餓死。為什麼會發生這樣的事?

仔細看WildMonster類中的程式碼,問題出在killWildMonster同步方法中。一旦某個英雄進入該方法後,將一直持有物件鎖,其他執行緒被阻塞而無法再進入

當然,解決的方法也很簡單,只要打破獨佔即可。比如,我們在下面的程式碼中把Thread.sleep改成wait,那麼問題將迎刃而解。

 public static class WildMonster {
   public synchronized void killWildMonster() {
     while (true) {
       String playerName = Thread.currentThread().getName();
       System.out.println(playerName + "斬獲野怪!");
       try {
         wait(500);
       } catch (InterruptedException e) {
         System.out.println("打野中斷");
       }
     }
   }
 }

執行結果如下:

哪吒斬獲野怪!
鎧斬獲野怪!
蘭陵王斬獲野怪!
典韋斬獲野怪!
蘭陵王斬獲野怪!
典韋斬獲野怪!

Process finished with exit code 130 (interrupted by signal 2: SIGINT)

從結果中可以看到,四個英雄都獲得了打野的機會,在一定程度上實現了公平。(備註:wait會釋放鎖,但sleep不會,對此不理解的可以檢視青銅系列文章。)

如何讓執行緒之間公平競爭,是執行緒問題中的重要話題。雖然我們無法保證百分百的公平,但我們仍然要通過設計一定的資料結構和使用相應的工具類來增加執行緒之間的公平性。

關於執行緒之間的公平性,在本文中重要的是理解它的存在和重要性,關於如何優雅地解決,我們會在後續的文章中介紹相關的併發工具類

三、活鎖的麻煩

相對於死鎖,你可能對活鎖沒有那麼熟悉。然而,活鎖所造成的負面影響並不亞於死鎖。在結果上,活鎖和死鎖都是災難性的,都將會造成應用程式無法提供正常的服務能力

所謂活鎖(LiveLock),指的是兩個執行緒都忙於響應對方的請求,但卻不幹自己的事。它們不斷地重複特定的程式碼,卻一事無成

不同於死鎖,活鎖並不會造成執行緒進入阻塞狀態,但它們會原地打轉,所以在影響上和死鎖相似,程式會進入無線死迴圈,無法繼續進行。

如果你無法直觀理解活鎖是什麼,相信你在走路時一定遇到過下面這種情況。兩人相向而行,出於禮貌兩人互相讓行,讓來讓去,結果兩人仍然無法通行。活鎖,也是這個意思。

小結

以上就是關於執行緒飢餓與活鎖的全部內容。在本文中,我們介紹了執行緒產生飢餓的原因。對待執行緒飢餓,沒有百分百的方案,但可以儘可能地實現公平競爭。我們沒有在本文列舉執行緒公平性的一些工具類,因為我認為對問題的理解要比解決方案更重要。如果沒有對問題的理解,方案在落地時也會出現知其然而不知其所以然的情況。另外,雖然活鎖並不像死鎖那樣知名度,但是對活鎖的恰當理解仍然非常必要,它是併發知識體系中的一部分。

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

夫子的試煉

  • 編寫程式碼設定不同執行緒的優先順序,體驗執行緒飢餓並給出解決方案。

延伸閱讀與參考資料

關於作者

關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(儘量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不做標題黨。

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

相關文章