我靠!Semaphore裡面居然有這麼一個大坑!

why技術發表於2020-08-02

這是why的第 59 篇原創文章

荒腔走板

大家好,我是why哥 ,歡迎來到我連續周更優質原創文章的第 59 篇。

上週寫了一篇文章,一不小心戳到了大家的爽點,其中一個轉載我文章的大號,閱讀量居然突破了 10w+,我也是受寵若驚。

但是其實我是一個技術博主來的,偶爾寫點生活相關的。所以這篇還是回到技術上。

但是我的技術文章有個特點是第一張圖片都是我自己拍的。然後我會圍繞這個圖片進行一個簡短的描述,我稱之為荒腔走板環節。

目的是給冰冷的技術文注入一絲色彩。

我這樣做已經堅持了很多篇 ,有的讀者給我說:看完荒腔走板部分就退出去了。

那你們是真的棒哦,至少退出去之前,拉到文末,來個一鍵三連吧,給我來點正反饋。

好了,先說說這期的荒腔走板。

上面這個圖片是我上週末看《樂隊的夏天》的時候拍的。

這個樂隊的名字叫做水木年華,我喜歡這個樂隊。

我聽他們的歌的時候,應該是初中,那個時候磁帶已經差不多快過氣了,進入了光碟的時代,我記得一張光碟裡面有好幾十首歌,第一次在 DVD 裡面聽到他們的歌是《一生有你》,聽到這首歌的時候就感覺很乾淨,很驚豔。

然後一字一句抄在自己的歌詞本上。

聽到這首歌的那個週末,我就看著那個 MV 反覆學,那時的 DVD 有個功能是可以 A-B 反覆播放某個片段,我就一句一句的學,學會了這首歌。

那時候的李健,一雙清澈明亮的大眼睛,就像一汪湖水,我一個小男孩,都好想在他的眼睛裡扎個猛子。

這首歌,我願稱之為校園民謠的巔峰之一。

十多年後的今天,這個樂隊重新出現在我的視野中,只是李健已經不再其中。

他們在樂夏的舞臺上唱了一首《青春再見》,結果被一個自稱 23 歲的胖小夥說“中年人的油膩”,被另個專業樂迷說:“四十多歲的人怎麼還在唱青春再見?”。第一期就被淘汰出局。

這操作,看的我一愣一愣的。

這個怎麼就油膩了?四十多歲的人怎麼就不能唱青春再見了?男人至死都是少年你們不知道嗎?小子,他們玩音樂的時候你還不會說話呢。

他們離開舞臺的畫面,我感覺到一絲辛酸,一絲真的青春再見的辛酸。

水木年華沒有錯,錯的是這個舞臺,這個舞臺不適合他們的歌曲。

好了,說迴文章。

一起看個問題

前幾天有個讀者給我發了一個連結,說這個連結裡面的程式碼,為什麼會這樣執行,實在是沒有搞懂是怎麼回事,連結如下:

https://springboot.io/t/topic/1139

程式碼是這樣的,給大家上個圖:

注意第 10 行,permits 引數,根據他的描述應該是 3:

不知道為什麼程式碼裡面給了一個 2。但是為了保證真實,我直接拿過來了,沒有進行改動。一會我會根據這個程式碼進行簡單的修改。

知道 semaphore 是幹啥的同學可以先看看上面的程式碼,為什麼造成了“死鎖”。

反正是一個非常無語的低階錯誤,但是我反覆看了幾遍居然沒有看出來。

不知道 semaphore 是幹啥的同學,看過來。我先給你科普一下。

semaphore 我們一般叫它訊號量,用來控制同時訪問指定資源的執行緒數量

如果不懂 semaphore ,那上面程式碼你也看不懂了,我按照程式碼的邏輯給你舉個例子。

比如一個高階停車場,只有 3 個車位。(這就是“指定資源”)

現在裡面沒有停車,那麼它最多可以停幾輛車呢?

是的,門口的剩餘車輛指示牌顯示:剩餘停車位 3 輛。

這個時候,有三路人想要過來停車。

三條路分別是:轉發路、點贊路、讚賞路。

路上的車分別是 why 哥的勞斯萊斯、趙四的布加迪、劉能、謝廣坤這對好基友開的法拉利:

這個時候從“點贊路”過來的趙四先開到了,於是停了進去。

門口的停車位顯示:剩餘停車位 2 輛。

劉能、謝廣坤到了後發現,剛好還剩下 2 個車位,於是好基友手拉手,一起停了進去。

門口的停車位顯示:餘下車位 0 輛。

沒多久,我也到了,發現沒有停車位了,怎麼辦呢?我只有在門口等一下了。

沒一會,趙四辦完事了,開著他的布加迪走了。

門口的停車位顯示:餘下車位 1 輛。

我趕緊停進去。

門口的停車位顯示:餘下車位 0 輛。

上面的程式碼想要描述的就是這樣的一個事情。

但是根據提問者的描述,“在執行時,有時只會執行完執行緒A,其執行緒B和執行緒C都靜默了。”

在上面這個場景中就是:趙四的布加迪開進去停車後,後面劉能、謝廣坤的法拉利和我的勞斯萊斯都停不進去了。

就是這樣式兒的:

為什麼停不進去呢?他懷疑是死鎖了,這個懷疑有點無厘頭啊。

我們先回憶一下死鎖的四個必要條件:

  • 互斥條件:一個資源每次只能被一個程式使用,即在一段時間內某資源僅為一個程式所佔有。此時若有其他程式請求該資源,則請求程式只能等待。(不滿足,還有兩個停車位沒有用呢。)

  • 請求與保持條件:程式已經保持了至少一個資源,但又提出了新的資源請求,而該資源已被其他程式佔有,此時請求程式被阻塞,但對自己已獲得的資源保持不放。(不滿足,張三佔了一個停車位了,沒有提出還要一個停車位的要求,另外的停車位也沒有被佔用)

  • 不可剝奪條件:程式所獲得的資源在未使用完畢之前,不能被其他程式強行奪走,即只能由獲得該資源的程式自己來釋放。(滿足,張三的車不開出來,這個停車位理論上是不會被奪走的)

  • 迴圈等待條件: 若干程式間形成首尾相接迴圈等待資源的關係。(不滿足,只有我和劉能、謝廣坤兩撥人在等資源,但沒有迴圈等待的情況。)

這四個條件是死鎖的必要條件,必要條件就是說只要有死鎖了,這些條件必然全部成立。

而經過分析,我們發現沒有滿足死鎖的必要條件。那為什麼會出現這樣的現象呢?

我們先根據上面的場景,自己寫一段程式碼。

自己擼程式碼

下面的程式基本上是按照上面截圖中的示例程式碼接合上面的故事改的,可以直接複製貼上:

public class ParkDemo {
    public static void main(String[] args) throws InterruptedException {

        Integer parkSpace = 3;
        System.out.println("這裡有" + parkSpace + "個停車位,先到先得啊!");
        Semaphore semaphore = new Semaphore(parkSpace, true);

        Thread threadA = new Thread(new ParkCar(1, "布加迪", semaphore), "趙四");
        Thread threadB = new Thread(new ParkCar(2, "法拉利", semaphore), "劉能、謝廣坤");
        Thread threadC = new Thread(new ParkCar(1, "勞斯萊斯", semaphore), "why哥");

        threadA.start();
        threadB.start();
        threadC.start();
    }
}

class ParkCar implements Runnable {
    
    private int n;
    private String carName;
    private Semaphore semaphore;

    public ParkCar(int n, String carName, Semaphore semaphore) {
        this.n = n;
        this.carName = carName;
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            if (semaphore.availablePermits() < n) {
                System.out.println(Thread.currentThread().getName() + "來停車,但是停車位不夠了,等著吧");
            }
            semaphore.acquire(n);
            System.out.println(Thread.currentThread().getName() + "把自己的" + carName + "停進來了,剩餘停車位:" + semaphore.availablePermits() + "輛");
            //模擬停車時長
            int parkTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(parkTime);
            System.out.println(Thread.currentThread().getName() + "把自己的" + carName + "開走了,停了" + parkTime + "小時");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            semaphore.release(n);
            System.out.println(Thread.currentThread().getName() + "走後,剩餘停車位:" + semaphore.availablePermits() + "輛");
        }
    }
}

執行後的結果如下(由於是多執行緒環境,執行結果可能不盡相同):

這次這個執行結果和我們預期的是一致的。並沒有執行緒阻塞的現象。

那為什麼之前的程式碼就會出現“在執行時,有時只會執行完執行緒A,其執行緒B和執行緒C都靜默了”這種現象呢?

是道德的淪喪,還是人性的扭曲?我帶大家走進程式碼:

差異就體現在獲取剩餘通行證的方法上。上面是連結裡面的程式碼,下面是我自己寫的程式碼。

說實在的,連結裡面的程式碼我最開始硬是眼神編譯了一分鐘,沒有看出問題來。

當我真正把程式碼粘到 IDEA 裡面,跑起來後發現當最先執行了 B 執行緒後,A、C 執行緒都可以執行。當最先執行 A 執行緒的時候,B、C 執行緒就不會執行。

我人都懵逼了,反覆分析,發現這和我認知不一樣啊!於是我陷入了沉思:

過了一會,保潔大爺過來收垃圾,問我:“hi,小帥哥,你這瓶紅牛喝完了吧?我把瓶子收走了啊。”然後瞟了一眼螢幕,指著獲取剩餘許可證的那行程式碼對我說:“你這個地方方法呼叫錯了哈,你再好好看看方法說明。”

System.out.println("剩餘可用許可證: " + semaphore.drainPermits());

說完之後,拍了拍我的肩膀,轉身離去。得到大師點化,我才恍然大悟。

由於獲取剩餘可用許可證的方法是 drainPermits,所以執行緒 A 呼叫完成之後,剩下的許可證為0,然後執行 release 之後,許可證變為 1。(後面會有對應的方法解釋)

這時又是一個公平鎖,所以,如果執行緒 B 先進去排隊了,剩下的許可證不足以讓 B 執行緒執行,它就一直等著。 C 執行緒也就沒有機會執行。

把獲取剩餘可用許可證的方法換為 availablePermits 方法後,正常輸出:

這真的是一個很小的點。所謂當局者迷旁觀者清,就是這個道理。

方法解釋

我估計很多不太瞭解 semaphore 的朋友看完前面這兩部分也還是略微有點懵逼。

沒事,所有的疑惑將在這一小節解開。

在上面的測試案例中,我們只用到了 semaphore 的四個方法:

  • availablePermits:獲取剩餘可用許可證。

  • drainPermits :獲取剩餘可用許可證。

  • release(int n):釋放指定數量的許可證。

  • acquire(int n):申請指定數量的許可證。

首先看 availablePermits 和 drainPermits 這個兩個方法的差異:

這兩個地方的文件描述,有點玩文字遊戲的意思了。稍不留神就被帶進去了。

你仔細看:availablePermits 只是 return 當前可用的許可證數量。而 drainPermits 是 acquires and return,它先全部獲取後再返回。

availablePermits 只是看看還有多少許可證,drainPermits 是拿走所有剩下的許可證。

所以在上面的場景下,這兩個方法的返回值是一樣的,但是內部處理完全內部不一樣:

當我把這個發現彙報給保潔大爺後,大爺輕輕一笑:“小夥子,要不你去查一下 drainPermits 前面的 drain 的意思?”

查完之後,我留下了英語四級的淚水:

見名知意。同學們,可見英語對程式設計還是非常重要的。

接下來先看看釋放的方法:release。

該方法就是釋放指定數量許可證。釋放,就意味著許可證的增加。就類似於劉能、謝廣坤把他們各自的法拉利從停車位開出來,駛離停車場,這時停車場就會多兩個停車位。

上面紅框框起來的部分是它的主要邏輯。大家自己看一下,我就不翻譯了,大概意思就是釋放許可證之後,其他等著用許可證的執行緒就可以看一下釋放之後的許可證數量是否夠用,如果夠就可以獲取許可證,然後執行了。

該方法的精華在 599 到 602 行的說明中:

這句話非常關鍵:說的是執行 release 操作的執行緒不一定非得是執行了 acquire 方法的執行緒

開發人員,需要根據實際場景來保證 semaphore 的正確使用。

release 操作這裡,大家都知道需要放到 finally 程式碼塊裡面去執行。但是正是這個認知,是最容易踩坑的地方,而且出了問題還非常不好排查的那種。

放肯定是要放在 finally 程式碼塊裡面的,只是怎麼放,這裡有點講究。

我接合下一節的例子和 acquire 方法一起說明:

acquire 方法主要先關注我紅框框起來的部分。

從該方法的原始碼可以看出,會丟擲 InterruptException 異常。記住這點,我們在下一節,帶入場景討論。

release使用不當的大坑

我們還是帶入之前停車的場景。假設趙四和我先把車停進去了,這個時候劉能、謝廣坤他們來了,發現車位不夠了,兩個好基友嘛,就等著,非要停在一起

等了一會,我們一直沒出來,門口看車的大爺出來對他們說:“我估摸著你們還得等很長時間,別等了,快走吧。”

於是,他們開車離去。

來,就這個場景,整一段程式碼:

public class ParkDemo {
    public static void main(String[] args) throws InterruptedException {

        Integer parkSpace = 3;
        System.out.println("這裡有" + parkSpace + "個停車位,先到先得啊!");
        Semaphore semaphore = new Semaphore(parkSpace, true);

        Thread threadA = new Thread(new ParkCar(1, "布加迪", semaphore), "趙四");
        Thread threadB = new Thread(new ParkCar(2, "法拉利", semaphore), "劉能、謝廣坤");
        Thread threadC = new Thread(new ParkCar(1, "勞斯萊斯", semaphore), "why哥");

        threadA.start();
        threadC.start();
        threadB.start();
        //模擬大爺勸退
        threadB.interrupt();
    }
}

class ParkCar implements Runnable {

    private int n;
    private String carName;
    private Semaphore semaphore;

    public ParkCar(int n, String carName, Semaphore semaphore) {
        this.n = n;
        this.carName = carName;
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            if (semaphore.availablePermits() < n) {
                System.out.println(Thread.currentThread().getName() + "來停車,但是停車位不夠了,等著吧");
            }
            semaphore.acquire(n);
            System.out.println(Thread.currentThread().getName() + "把自己的" + carName + "停進來了," + "剩餘停車位:" + semaphore.availablePermits() + "輛");
            //模擬停車時長
            int parkTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(parkTime);
            System.out.println(Thread.currentThread().getName() + "把自己的" + carName + "開走了,停了" + parkTime + "小時");
        } catch (InterruptedException e) {
            System.err.println(Thread.currentThread().getName() + "被門口大爺勸走了。");
        } finally {
            semaphore.release(n);
            System.out.println(Thread.currentThread().getName() + "走後,剩餘停車位:" + semaphore.availablePermits() + "輛");
        }
    }
}

看著程式碼是沒有毛病,但是執行起來你會發現,有可能出現這樣的情況:

why哥走後,剩餘停車位變成了 5 輛?我是開著勞斯萊斯去給他們開發停車位去了嗎?

在往前看日誌發現,原來是劉能、謝廣坤走後,顯示了剩餘停車位 3 輛。

問題就出在這個地方。

而這個地方對應的程式碼是這樣的:

有沒有一點恍然大悟的感覺。

50 行丟擲了 InterruptedException,導致明明沒有獲取到許可證的執行緒,執行了 release 方法,而該方法導致許可證增加。

在我們的例子裡面就是劉能、謝廣坤的車都還沒停進去,走的時候門口的螢幕就增加了兩個停車位。

這就是坑,就是你程式碼中的 BUG 潛伏地帶。

那麼怎麼修復呢?

答案已經呼之欲出了,這個地方需要 catch 起來,如果出現中斷異常,直接返回:

跑起來,結果也正確,所有車都走了後,停車位還是隻有 3 輛:

上面的寫法還有一個疑問,如果我剛剛拿到許可證,就被中斷了,怎麼辦?

看原始碼啊,原始碼裡面有答案的。

丟擲 InterruptedException 後,分配給這個執行緒的所有許可證都會被分配給其他想要獲取許可證的執行緒,就像通過呼叫 release 方法一樣。

增強release

你分析上面的問題會發現,導致問題的原因是沒有獲取到許可證的執行緒,呼叫了 release 方法。

我覺得這個設定,就是非常容易踩坑的地方。簡直就是一個大坑!

我們可以就這個問題,對 release 方法進行增強,只有獲取後的執行緒,才能呼叫 release 方法。

這一招我是在《Java高併發程式設計詳解-深入理解併發核心庫》裡面學到的:

其中的 3.4.4 小節《擴充套件 Semaphore 增強 release》:

獲取許可證的方法被修改成這樣了(我只擷取其中一個方法),獲取成功後放入到佇列裡面:

裡面的 release 方法修改成這樣了,執行之前先看看當前執行緒是否是在佇列裡面:

還有一段溫馨提示:

這本書寫的還是不錯的,推薦給大家。

最後說一句(求關注)

才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你留言指出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人

相關文章