【並發那些事】生產者消費者問題


Step 1. 什么是生產者消費者問題

生產者消費者問題也叫有限緩沖問題,是多線程同步的一個最最最經典的問題。這個問題描述的場景是對於一個有固定大小的緩沖區,同時共享給兩個線程去使用。而這兩個線程會分為兩個角色,一個負責往這個緩沖區里放入一定的數據,我們叫他生產者。另一個負責從緩沖區里取數據,我們叫他消費者
這里就會有兩個問題,第一個問題是生產者不可能無限制的放數據去緩沖區,因為緩沖區是有大小的,當緩沖區滿的時候,生產者就必須停止生產。第二個問題亦然,消費者也不可能無限制的從緩沖區去取數據,取數據的前提是緩沖區里有數據,所以當緩沖區空的時候,消費者就必須停止生產。
這兩個問題看起來簡單,但是在實際編碼的時候還是會有許多坑,稍不留意就會鑄成大錯。而且上面只是單個消費者生產者問題,實現應用中,還會遇到多生產多消費等更復雜的場景。這些問題下面會詳細敘述。

Step 2. 為什么會有這個問題

通過上節的內容,我們知道了什么是生產者消費者問題。但是為什么會出現這種問題呢?
其實如果說『生產者消費者問題』,可能因為有了『問題』兩個字而顯得比較負面。我更喜歡稱之為『生產者消費者模式』,就像我們學的那些代碼設計模式一樣。他其實是多線程情況下的一種設計模式,是某些場景下久經考驗的最佳實踐。
那么這種模式有哪些作用呢?
他的第一個好處是解耦。
舉個外賣的例子。在沒有美團、餓了么之前,肯定沒有現在這么多滿大街跑的外賣小哥。你打電話點了一份外賣,通常都是老板自己做菜自己送。你想像一下,老板洗菜、切菜、做菜,做好之后再打包,然后拎着打包盒,騎個自行車,再滿小區找地址,最后送到你的手中。這里就會出現幾個問題,第一,老板挺不容易的,要會洗菜、切菜、做菜烹飪一條龍,做好之后,還要會騎車,光會騎車還不行,他還要認路,哪哪小區在哪里,哪哪棟在哪里,從哪走比較近,哪個門口保安不讓進。這樣就把所有的職能都集中在了老板身上,做飯與送飯,其實是兩條事,理論上沒有什么聯系,但是這里如果老板切菜時,一不小心切到了手,那不光菜做不了,后面也沒法送。或者送外賣的路上,為趕時間闖紅燈被交警攔了下來,不光飯送不了,還回不來做下一份。這就像我們的代碼全都耦合在一起的后果,兩個業務相互影響,一個業務出現問題另一個也跟着出現問題,一個業務變更就帶着另一個業務變更。
我們想想,有了外賣小哥之后呢?老板只要關注於做菜就好了,做好給到外賣小哥。外賣小哥會送到用戶手上。老板想的是怎么把菜做的更好吃,外賣小哥想的是怎么最快送達。職能清晰了,效率就更高了。這里可以把老板當成生產者,對應的外賣小哥就是消費者。
他的第二個好處就是均衡生產者與消費者的能力。
還是舉外賣的例子。有些外賣是要實時准備的,比如說做菜就是這樣,用戶下單后,老板立刻洗菜、切菜、做菜然后打包。對於比較耗時的菜品,比如煲粥、燉湯之類的時間可能很長。而外賣小哥耗費的時間只是接到通知后來到這家店的時間。因為現在的外賣系統比較智能,通知的都是距離商戶最近的外賣小哥,所以到店的時間一般比較短。這種場景下瓶頸就是商家的產能,高峰期就可能會造成排隊。如下圖:
image.png

再嚴重一點就會這樣

image.png

對於這個問題的原因我們很清楚了,是因為生產者(商家)的產能跟不上消費者(外賣小哥)的消費(送餐)速度。因為我們把職能分開了,所以解決問題也很清晰,那就提高生產者的產能,比如說老板可以多雇幾個廚師或者再開一家分店。這樣就把生產者的產能提高到與消費者的產能平衡的位置。
還有另一種生產者比消費者快的情況,比如說一些小超市,他也有外賣服務。因為他的東西都是現成的,用戶下完單后,只要按訂單裝好就可以了。這個時候反而是從外邊過來的外賣小哥要慢的多。再或者是商品准備的時間很短,但是送餐的路途遙遠,路況復雜。所以瓶頸到外賣小哥身上。

image.png

image.png

這種情況下問題也很清晰了,消費者消耗的速度跟不上生產者的產能,那擴充消費者的數量好了。比如經常遇到的外賣轉單,一個外賣小哥來不及了,轉給了另一個外賣小哥。同樣也能達到生產者與消費者的產能均衡。

Step 3. 怎么去實現生產者消費者模式

好了,說完了 what 還有 why,那么我們現在接着說怎么去實現生產者消費者模式,不再廢話直接上代碼。
首先我們寫一個老板類:

3.1 Boss.java (老板)

/**
 * fshows.com
 * Copyright (C) 2013-2019 All Rights Reserved.
 */
package cn.coder4j.study.example.thread;

import java.util.LinkedList;

/**
 * 老板
 * @author buhao
 * @version Boss.java, v 0.1 2019-11-09 15:09 buhao
 */
public class Boss implements Runnable {
    /**
     * 最大生產數量
     */
    public static final int MAX_NUM = 5;
    /**
     * 桌子
     */
    private LinkedList<String> tables;

    public Boss(LinkedList<String> tables) {
        this.tables = tables;
    }

    @Override
    public void run() {
        // 注意點1
        while (true){
            synchronized (this.tables){
                // 注意點2
                while (tables.size() == MAX_NUM){
                    System.out.println("通知外賣小哥取餐");
                    // 注意點3
                    this.tables.notifyAll();
                    try {
                        System.out.println("老板開始休息了");
                        this.tables.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                String goods = "牛肉面" + tables.size();
                System.out.println("老板做了一碗" + goods);
                tables.addLast(goods);
            }
        }
    }
}

然后我們再寫一個外賣小哥類,但是尷尬的是發現不知道外賣小哥英文怎么寫,查了一下結果如下
image.png
這個 brother 總感覺怪怪的,但是我讀書少,他騙我也不知道,就用這個吧。 要是有英語大神可以留言回復一下正確怎么寫。

3.2 TakeawayBrother.java (外賣小哥)

/**
 * fshows.com
 * Copyright (C) 2013-2019 All Rights Reserved.
 */
package cn.coder4j.study.example.thread;

import java.util.LinkedList;

/**
 * 外賣小哥
 * @author buhao
 * @version TakeawayBrother.java, v 0.1 2019-11-09 15:14 buhao
 */
public class TakeawayBrother implements Runnable {

    private LinkedList<String> tables;

    public TakeawayBrother(LinkedList<String> tables) {
        this.tables = tables;
    }

    @Override
    public void run() {
        while (true){
            synchronized (this.tables){
                while (this.tables == null || this.tables.size() == 0){
                    System.out.println("催老板趕快做外賣");
                    this.tables.notifyAll();
                    try {
                        System.out.println("一邊玩手機一邊等外賣");
                        this.tables.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                String goods = tables.removeFirst();
                System.out.println("外賣小哥取餐了" + goods);
            }
        }
    }
}

事件發生總歸有一個地方吧,一般老板把外賣給到外賣小哥都是在店鋪里,最后我們再加一個店鋪場景類吧

3.3 StoreContext.java (店鋪)

/**
 * fshows.com
 * Copyright (C) 2013-2019 All Rights Reserved.
 */
package cn.coder4j.study.example.thread;

import java.util.LinkedList;

/**
 * 店鋪場景
 * @author buhao
 * @version StoreContext.java, v 0.1 2019-11-09 15:28 buhao
 */
public class StoreContext {

    public static void main(String[] args) {
        // 先創建一張用於存放外賣的桌子
        LinkedList<String> tables = new LinkedList<>();
        // 再創建一個老板
        Boss boss = new Boss(tables);
        // 最后創建一個外賣小哥
        TakeawayBrother takeawayBrother = new TakeawayBrother(tables);
        // 創建線程對象
        Thread bossThread = new Thread(boss);
        Thread takeawayBrotherThread = new Thread(takeawayBrother);
        // 運行線程
        bossThread.start();
        takeawayBrotherThread.start();
    }
}

3.4 運行結果

老板做了一碗牛肉面0
老板做了一碗牛肉面1
老板做了一碗牛肉面2
老板做了一碗牛肉面3
老板做了一碗牛肉面4
通知外賣小哥取餐
老板開始休息了
外賣小哥取餐了牛肉面0
外賣小哥取餐了牛肉面1
外賣小哥取餐了牛肉面2
外賣小哥取餐了牛肉面3
外賣小哥取餐了牛肉面4
催老板趕快做外賣
一邊玩手機一邊等外賣
老板做了一碗牛肉面0
老板做了一碗牛肉面1
老板做了一碗牛肉面2
老板做了一碗牛肉面3
老板做了一碗牛肉面4
通知外賣小哥取餐
老板開始休息了
外賣小哥取餐了牛肉面0
外賣小哥取餐了牛肉面1
外賣小哥取餐了牛肉面2
外賣小哥取餐了牛肉面3
外賣小哥取餐了牛肉面4
催老板趕快做外賣
一邊玩手機一邊等外賣
..........

Step 4. 代碼說明

首先上面的代碼是一個最基本的單生產單消費的例子。如果你想要多生產多消費,那多創建幾個 boss 或者 takeawayBrother 就可以了。
然后店鋪場景類沒什么可說的,只是基本的創建線程邏輯,如果對於線程創建不了解的,可以參考前文的【並發那些事】創建線程的三種方式。此文不再贅述。另外觀察代碼,可以發現生產者與消費者的代碼極為相似,只是一個存一個取。這里我們以生產者為例子說明。
首先在 Boss 類中他有兩個成員屬性,一個是 MAX_NUM 一個是 tables。還記得我們在一開頭提到的『固定大小的緩沖區』嗎?這里的 MAX_NUM 對應的就是『固定大小』這幾個字,這里我們設置的是 5 個。他的現實意義就是老板不可能從早到晚一刻不停的做菜,一般是在點單的時候開始做,也有一些在高峰期的時候提前做一點,但是他放菜的桌子只有那么大,放滿了就不能接着做。而 tables 就對應着『緩沖區』這幾個字。老板做完菜總要有一個地方先放着等外賣小哥來拿吧,緩沖區就是放菜的桌子。
然后我們再接着看代碼邏輯,我在代碼中標記了幾個注意點。
第一個注意點是最外面一層的 while。這個是多線程通用寫法,因為不寫 while 的話,一次任務結束后代碼就退出了。現實業務中我們通常想要業務一直持續的運行,所以加個 while 解決。
第二個注意點 while (tables.size() == MAX_NUM) 。這個信息量相對多一點,首先 while 的判斷條件的意思是判斷當前桌子上的外賣是不是已經達到上限,如果是會進入 while 代碼塊的內容,首先通知(notifyAll)外賣小哥可以拿外賣了,然后自己可以歇着了(wait),否則接着往下走繼續做。初次接觸生產消費模型的同學,很容易出錯的點就是把這里的 while 寫成 if。因為這里本身也只是要判斷當前緩沖區是否滿足生產的條件。其實在語法與邏輯上沒有問題,但是在多線程下就會出現 虛假喚醒 的問題。比如現在有兩個生產者都處於調用 wait 的地方。突然消費者線程把數據消費完了,並通知了所有生產者去生產,兩個生產者都接收到消息,但是只有一個生產者拿到鎖,他就去生產了,生產完后,把鎖就釋放了,剛剛另一個接收到消息的生產者拿到鎖就接着往下走,如果這里是 if 的話,因為都已經判斷過了,不會再判斷,但是明顯另一個線程已經完了任務,他現在已經不符合條件。接着往下走就會出現問題。所以當這里換成 while 后,他醒來后還會接着判斷一次,不滿足就接着等待,這樣就避免了虛假喚醒這種問題。
第三個注意點 this.tables.notifyAll()。關於第二個問題,大家可能要說了,出現問題是因為我們同時通知了兩個生產者造成的,java 自帶了一個喚醒單個線程的 notify 方法為什么不用,反而用喚醒所有線程的 notifyAll 方法。這是因為 notify 喚醒線程是 隨機 的,也就是說你喚醒的可能是生產者也可能是消費者。比如說你是生產者,你生產夠了,你想喚醒消費者,但是不幸的是你喚醒了另一個生產者,另一個生產者一覺醒來,發現菜都做完了,就接着睡,如果生產者一直喚醒的都是生產者,那么程序就會進入 假死 狀態,消費者永遠都處於等待狀態。

其它

1. 項目代碼

因為篇幅有限,無法貼完所有代碼,如遇到問題可到github上查看源碼。

2. 參考鏈接

  1. 生產者消費者問題[WIKI]
  2. Java多線程14:生產者/消費者模型
  3. 一篇文章,讓你徹底弄懂生產者--消費者問題

image.png

image.png


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM