生产者-消费者模型
什么是生产者-消费者模式
比如有两个进程A和B,它们共享一个固定大小的缓冲区,A进程产生数据放入缓冲区,B进程从缓冲区中取出数据进行计算,那么这里其实就是一个生产者和消费者的模式,A相当于生产者,B相当于消费者
- 生产者线程:“生产”产品,并把产品放到一个队列里;
- 消费者线程:“消费”产品。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XyZ6Flwo-1621336191811)(/Users/chenjialun/Library/Application Support/typora-user-images/image-20210518182517956.png)]
有了这个队列,生产者就只需要关注生产,而不用管消费者的消费行为,更不用等待消费者线程执行完;消费者也只管消费,不用管生产者是怎么生产的,更不用等着生产者生产。
为什么要使用生产者消费者模式
在多线程开发中,如果生产者生产数据的速度很快,而消费者消费数据的速度很慢,那么生产者就必须等待消费者消费完了数据才能够继续生产数据,因为生产那么多也没有地方放啊;同理如果消费者的速度大于生产者那么消费者就会经常处理等待状态,所以为了达到生产者和消费者生产数据和消费数据之间的平衡,那么就需要一个缓冲区用来存储生产者生产的数据,所以就引入了生产者-消费者模式
简单来说这里的缓冲区的作用就是为了平衡生产者和消费者的处理能力,起到一个数据缓存的作用,同时也达到了一个解耦的作用
模型的优点
- 解耦:将生产者类和消费者类进行解耦,消除代码之间的依赖性,简化工作负载的管理
- 异步:对于生产者和消费者来说能够各司其职,生产者只需要关心缓冲区是否还有数据,不需要等待消费者处理完;同样的对于消费者来说,也只需要关注缓冲区的内容,不需要关注生产者,通过异步的方式支持高并发,将一个耗时的流程拆成生产和消费两个阶段,这样生产者因为执行put()的时间比较短,而支持高并发
- 什么是异步呢?比如说你和你女朋友打电话,就得等她接了电话你们才能说话,这是同步。但是如果你跟她发微信,并不需要等她回复,她也不需要立刻回复,而是等她有空了再回,这就是异步。
- 支持分布式:生产者和消费者通过队列进行通讯,所以不需要运行在同一台机器上,在分布式环境中可以通过redis的list作为队列,而消费者只需要轮询队列中是否有数据。同时还能支持集群的伸缩性,当某台机器宕掉的时候,不会导致整个集群宕掉
- **调整并发数:由于生产者和消费者的处理速度是不一样的,可以调整并发数,给予慢的一方多的并发数,**来提高任务的处理速度
但是呢,生产者和消费者之间也不能完全没有联系的。
- 如果队列里的产品已经满了,生产者就不能继续生产;
- 如果队列里的产品从无到有,生产者就得通知一下消费者,告诉它可以来消费了;
- 如果队列里已经没有产品了,消费者也无法继续消费;
- 如果队列里的产品从满到不满,消费者也得去通知下生产者,说你可以来生产了。
所以它们之间还需要有协作,
- 最经典的就是使用
Object
类里自带的wait()
和notify()
或者notifyAll()
的消息通知机制。 - 上述描述中的等着,其实就是用
wait()
来实现的; - 而通知,就是
notify()
或者notifyAll()
。
那么基于这种消息通知机制,我们还能够平衡生产者和消费者之间的速度差异。
生产者-消费者模式的应用场景
-
Excutor任务执行框架:
-
- 通过将任务的提交和任务的执行解耦开来,提交任务的操作相当于生产者,执行任务的操作相当于消费者
- 例如使用Excutor构建web服务器,用于处理线程的请求:生产者将任务提交给线程池,线程池创建线程处理任务,如果需要运行的任务数大于线程池的基本线程数,那么就把任务扔到阻塞队列(通过线程池+阻塞队列的方式比只使用一个阻塞队列的效率高很多,因为消费者能够处理就直接处理掉了,不用每个消费者都要先从阻塞队列中取出任务再执行)
-
消息中间件activeMQ:
-
- 双十一的时候,会产生大量的订单,那么不可能同时处理那么多的订单**,需要将订单放入一个队列里面,然后由专门的线程处理订单。这里用户下单就是生产者**,处理订单的线程就是消费者;再比如12306的抢票功能,先由一个容器存储用户提交的订单,然后再由专门处理订单的线程慢慢处理,这样可以在短时间内支持高并发服务
-
任务的处理时间比较长的情况下:
-
- 比如上传附近并处理,那么这个时候可以将用户上传和处理附件分成两个过程,用一个队列暂时存储用户上传的附近,然后立刻返回用户上传成功,然后有专门的线程处理队列中的附近
wait()/notify()/notifyAll()
接下来我们需要重点看下这个通知机制。
-
wait()
方法是用来让当前线程等待,直到有别的线程调用notify()
将它唤醒,或者我们可以设定一个时间让它自动苏醒。调用该方法之前,线程必须要获得该对象的对象监视器锁,也就是只能用在加锁的方法下。而调用该方法之后,当前线程会释放锁。(提示:这里很重要,也是下文代码中用while
而非if
的原因。) -
notify()
方法只能通知一个线程,如果多个线程在等待,那就唤醒任意一个。 -
notifyAll()
方法是可以唤醒所有等待线程,然后加入同步队列。 -
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qSF8A1UI-1621336191814)(/Users/chenjialun/Library/Application Support/typora-user-images/image-20210518183257531.png)]
这里我们用到了 2 个队列:
- 同步队列Q1:对应于线程状态中的
Runnable
,也就是线程准备就绪,就等着抢资源了。 - 等待队列Q3:对应于线程状态中的
Waiting
,也就是等待状态。
**这里需要注意,从等待状态线程无法直接进入 Q2,而是要先重新加入同步队列,再次等待拿锁,拿到了锁才能进去 Q2;一旦出了 Q2,锁就丢了。**在 Q2
里,其实只有一个线程,因为这里我们必须要加锁才能进行操作。
synchronized配合实现
这里我首先建了一个简单的 Product
类,用来表示生产和消费的产品,
public class Product {
private String name;
public Product(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
Main
主函数里我设定了两类线程,并且这里选择用普通的 ArrayDeque
来实现 Queue
,更简单的方式是直接用 Java 中的 BlockingQueue
来实现。BlockingQueue
是阻塞队列,它有一系列的方法可以让线程实现自动阻塞,常用的 BlockingQueue
有很多。这里为了更好的理解并发协同的这个过程,我们先自己处理。
public class Test {
public static void main(String[] args) {
Queue<Product> queue = new ArrayDeque<>();
for (int i = 0; i < 100; i++) {
new Thread(new Producer(queue, 100)).start();
new Thread(new Consumer(queue, 100)).start();
}
}
}
Producer
public class Producer implements Runnable{
private Queue<Product> queue;
private int maxCapacity;
public Producer(Queue queue, int maxCapacity) {
this.queue = queue;
this.maxCapacity = maxCapacity;
}
@Override
public void run() {
synchronized (queue) {
while (queue.size() == maxCapacity) { //一定要用 while,而不是 if,下文解释
try {
System.out.println("生产者" + Thread.currentThread().getName() + "等待中... Queue 已达到最大容量,无法生产");
wait();//释放了锁 从执行变成等待
System.out.println("生产者" + Thread.currentThread().getName() + "退出等待");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (queue.size() == 0) { //队列里的产品从无到有,需要通知在等待的消费者
queue.notifyAll();
}
Random random = new Random();
Integer i = random.nextInt();
queue.offer(new Product("产品" + i.toString())); //生产物品
System.out.println("生产者" + Thread.currentThread().getName() + "生产了产品:" + i.toString());
}
}
}
其实它的主逻辑很简单,我这里为了方便演示加了很多打印语句才显得有点复杂。我们把主要逻辑拎出来看:
public void run() {
synchronized (queue) {
while (queue.size() == maxCapacity) { //一定要用 while,而不是 if,下文解释
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (queue.size() == 0) {
queue.notifyAll();
}
queue.offer(new Product("产品" + i.toString()));
}
}
}
过程:
- 生产者线程拿到锁后,其实就是进入了
Q2
阶段。首先检查队列是否容量已满,如果满了,那就要去Q3
等待; - 如果不满,先检查一下队列原本是否为空,如果原来是空的,那就需要通知消费者;
- 最后生产产品。
这里有个问题,为什么只能用 while
而不是 if
?
其实在这一小段,生产者线程经历了几个过程:
- 如果队列已满,它就没法生产,那也不能占着位置不做事,所以要把锁让出来,去
Q3 - 等待队列
等着; - 在等待队列里被唤醒之后,不能直接夺过锁来,而是要先加入
Q1 - 同步队列
等待资源; - 一旦抢到资源,关门上锁,才能来到
Q2
继续执行wait()
之后的活,但是,此时这个队列有可能又满了,所以退出wait()
之后,还需要再次检查queue.size() == maxCapacity
这个条件,所以要用while
。
那么为什么可能又满了呢?
因为该线程没有一直拿着锁,在被唤醒之后,到拿到锁之间的这段时间里,有可能其他的生产者线程先拿到了锁进行了生产,所以队列又经历了一个从不满到满的过程。
总结:在使用线程的等待通知机制时,一般都要在 while
循环中调用 wait()
方法。
Consumer
public class Consumer implements Runnable{
private Queue<Product> queue;
private int maxCapacity;
public Consumer(Queue queue, int maxCapacity) {
this.queue = queue;
this.maxCapacity = maxCapacity;
}
@Override
public void run() {
synchronized (queue) {
while (queue.isEmpty()) {
try {
System.out.println("消费者" + Thread.currentThread().getName() + "等待中... Queue 已缺货,无法消费");
wait();
System.out.println("消费者" + Thread.currentThread().getName() + "退出等待");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (queue.size() == maxCapacity) { //消费者要开始消费了,通知生产者可以开始了
queue.notifyAll();
}
Product product = queue.poll();
System.out.println("消费者" + Thread.currentThread().getName() + "消费了:" + product.getName());
}
}
}
结果如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zyahNOrq-1621336191817)(/Users/chenjialun/Library/Application Support/typora-user-images/image-20210518184500859.png)]