【并发那些事】⽣产者消费者问题
Step 1. 什么是⽣产者消费者问题
⽣产者消费者问题也叫有限缓冲问题,是多线程同步的⼀个最最最经典的问题。这个问题描述的场景是对于⼀个有固定⼤⼩的缓冲区,同时共享给两个线程去使⽤。⽽这两个线程会分为两个⾓⾊,⼀个负责往这个缓冲区⾥放⼊⼀定的数据,我们叫他⽣产者。另⼀个负责从缓冲区⾥取数据,我们叫他消费者。
这⾥就会有两个问题,第⼀个问题是⽣产者不可能⽆限制的放数据去缓冲区,因为缓冲区是有⼤⼩的,当缓冲区满的时候,⽣产者就必须停⽌⽣产。第⼆个问题亦然,消费者也不可能⽆限制的从缓冲区去取数据,取数据的前提是缓冲区⾥有数据,所以当缓冲区空的时候,消费者就必须停⽌⽣产。
这两个问题看起来简单,但是在实际编码的时候还是会有许多坑,稍不留意就会铸成⼤错。⽽且上⾯只是单个消费者⽣产者问题,实现应⽤中,还会遇到多⽣产多消费等更复杂的场景。这些问题下⾯会详细叙述。
Step 2. 为什么会有这个问题
通过上节的内容,我们知道了什么是⽣产者消费者问题。但是为什么会出现这种问题呢?
其实如果说『⽣产者消费者问题』,可能因为有了『问题』两个字⽽显得⽐较负⾯。我更喜欢称之为『⽣产者消费者模式』,就像我们学的那些代码设计模式⼀样。他其实是多线程情况下的⼀种设计模式,是某些场景下久经考验的最佳实践。
那么这种模式有哪些作⽤呢?
他的第⼀个好处是解耦。
举个外卖的例⼦。在没有美团、饿了么之前,肯定没有现在这么多满⼤街跑的外卖⼩哥。你打电话点了⼀份外卖,通常都是⽼板⾃⼰做菜⾃⼰送。你想像⼀下,⽼板洗菜、切菜、做菜,做好之后再打包,然后拎着打包盒,骑个⾃⾏车,再满⼩区地址,最后送到你的⼿中。这⾥就会出现⼏个问题,第⼀,⽼板挺不容易的,要会洗菜、切菜、做菜烹饪⼀条龙,做好之后,还要会骑车,光会骑车还不⾏,他还要认路,哪哪⼩区在哪⾥,哪哪栋在哪⾥,从哪⾛⽐较近,哪个门⼝保安不让进。这样就把所有的职能都集中在了⽼板⾝上,做饭与送饭,其实是两条事,理论上没有什么联系,但是这⾥如果⽼板切菜时,⼀不⼩⼼切到了⼿,那不光菜做不了,后⾯也没法送。或者送外卖的路上,为赶时间闯红灯被交警拦了下来,不光饭送不了,还回不来做下⼀份。这就像我们的代码全都耦合在⼀起的后果,两个业务相互影响,⼀个业务出现问题另⼀个也跟着出现问题,⼀个业务变更就带着另⼀个业务变更。
我们想想,有了外卖⼩哥之后呢?⽼板只要关注于做菜就好了,做好给到外卖⼩哥。外卖⼩哥会送到⽤户⼿上。⽼板想的是怎么把菜做的更好吃,外卖⼩哥想的是怎么最快送达。职能清晰了,效率就更⾼了。这⾥可以把⽼板当成⽣产者,对应的外卖⼩哥就是消费者。
他的第⼆个好处就是均衡⽣产者与消费者的能⼒。
还是举外卖的例⼦。有些外卖是要实时准备的,⽐如说做菜就是这样,⽤户下单后,⽼板⽴刻洗菜、切菜、做菜然后打包。对于⽐较耗时的菜品,⽐如煲粥、炖汤之类的时间可能很长。⽽外卖⼩哥耗费的时间只是接到通知后来到这家店的时间。因为现在的外卖系统⽐较智能,通知的都是距离商户最近的外卖⼩哥,所以到店的时间⼀般⽐较短。这种场景下瓶颈就是商家的产能,⾼峰期就可能会造成排队。如下图:
再严重⼀点就会这样
对于这个问题的原因我们很清楚了,是因为⽣产者(商家)的产能跟不上消费者(外卖⼩哥)的消费(送餐)速度。因为我们把职能分开了,所以解决问题也很清晰,那就提⾼⽣产者的产能,⽐如说⽼板可以多雇⼏个厨师或者再开⼀家分店。这样就把⽣产者的产能提⾼到与消费者的产能平衡的位置。
还有另⼀种⽣产者⽐消费者快的情况,⽐如说⼀些⼩超市,他也有外卖服务。因为他的东西都是现成
的,⽤户下完单后,只要按订单装好就可以了。这个时候反⽽是从外边过来的外卖⼩哥要慢的多。再或者是商品准备的时间很短,但是送餐的路途遥远,路况复杂。所以瓶颈到外卖⼩哥⾝上。
这种情况下问题也很清晰了,消费者消耗的速度跟不上⽣产者的产能,那扩充消费者的数量好了。⽐如经常遇到的外卖转单,⼀个外卖⼩哥来不及了,转给了另⼀个外卖⼩哥。同样也能达到⽣产者与消费者的产能均衡。
Step 3. 怎么去实现⽣产者消费者模式
好了,说完了 what 还有 why,那么我们现在接着说怎么去实现⽣产者消费者模式,不再废话直接上代码。
⾸先我们写⼀个⽼板类:
3.1 Boss.java (⽼板)
/**
* fshows
* Copyright (C) 2013-2019 All Rights Reserved.
*/
ample.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
ifyAll();
try {
System.out.println("⽼板开始休息了");
this.tables.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
String goods = "⽜⾁⾯" + tables.size();
System.out.println("⽼板做了⼀碗" + goods);
tables.addLast(goods);
}
}
}
}
然后我们再写⼀个外卖⼩哥类,但是尴尬的是发现不知道外卖⼩哥英⽂怎么写,查了⼀下结果如下
这个 brother 总感觉怪怪的,但是我读书少,他骗我也不知道,就⽤这个吧。要是有英语⼤神可以留⾔回复⼀下正确怎么写。
3.2 TakeawayBrother.java (外卖⼩哥)
/**
* fshows
* Copyright (C) 2013-2019 All Rights Reserved.
*/
ample.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("催⽼板赶快做外卖");
ifyAll();
try {
System.out.println("⼀边玩⼿机⼀边等外卖");
this.tables.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
String goods = veFirst();
System.out.println("外卖⼩哥取餐了" + goods);
}
}
}
}
事件发⽣总归有⼀个地⽅吧,⼀般⽼板把外卖给到外卖⼩哥都是在店铺⾥,最后我们再加⼀个店铺场景类吧
3.3 StoreContext.java (店铺)
/**
* fshows
* Copyright (C) 2013-2019 All Rights Reserved.
*/
ample.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 后,他醒来后还会接着判断⼀次,不满⾜就接着等待,这样就避免了虚假唤醒这种问题。
第三个注意点 ifyAll()。关于第⼆个问题,⼤家可能要说了,出现问题是因为我们同时通知了两个⽣产者造成的,java ⾃带了⼀个唤醒单个线程的notify ⽅法为什么不⽤,反⽽⽤唤醒所有线程的 notifyAll ⽅法。这是因为 notify 唤醒线程是随机的,也就是说你唤醒的可能是⽣产者也可能是消费者。⽐如说你是⽣产者,你⽣产够了,你想唤醒消费者,但是不幸的是你唤醒了另⼀个⽣产者,另⼀个⽣产者⼀觉醒来,发现菜都做完了,就接着睡,如果⽣产者⼀直唤醒的都是⽣产者,那么程序就会进⼊假死状态,消费者永远都处于等待状态。
其它
1. 项⽬代码
因为篇幅有限,⽆法贴完所有代码,如遇到问题可到上查看源码。
2. 参考链接