生产者-消费者模式 Producer-Consumer

118 阅读13分钟

生产者-消费者问题(Producer-Consumer Problem)是并发领域的经典问题之一,描述了多个线程在共享缓冲区的情况下如何协作。其中,生产者负责生产数据并放入缓冲区,而消费者则负责从缓冲区中取出数据进行消费。

生产者-消费者问题的关键在于生产者和消费者之间的协调和同步,要确保以下几点:

⨳ 生产者在缓冲区满时需要等待,以避免向缓冲区添加数据导致溢出。

⨳ 消费者在缓冲区空时需要等待,以避免尝试从空缓冲区中取出数据。

⨳ 生产者向缓冲区添加数据后需要唤醒等待中的消费者线程。

⨳ 消费者从缓冲区取出数据后需要唤醒等待中的生产者线程。

在引出生产者-消费者模式之前,先来看两个有关生产者-消费者问题的相关的两个模式。

守护性暂挂模式 Guarded Suspension

守护性暂挂(Guarded Suspension)模式:某个线程在执行特定操作前需要满足一定的条件(守护条件),而条件未满足时将该线程暂停运行,直到条件满足后再继续执行。

Guarded 是被守护、被保卫、被保护的意思;Suspension 则是 暂停的意思。

我们可以使用上篇《同步锁 synchronized》学习的 waitnotify 来挂起和唤醒线程。

检查条件:如果条件不满足,调用 wait(),挂起线程。

等待通知:另一个线程修改了条件后,调用 notifyAll(),唤醒等待的线程。

重新检查条件:被唤醒的线程再次检查条件,条件满足时继续执行。

守护性暂挂模式中最重要的角色就是被守护的对象(Guarded Object),内有守护条件被守护的方法改变守护条件的方法

被守护的方法guardedMethod):当线程执行被守护方法时,若守护条件成立,则可以立即执行;当守护条件不成立时,就要进行等待。

改变守护条件的方法stateChangingMethod):用于改变守护条件,如果守护条件成立,则唤醒等待的线程。

被守护对象

package com.cango.thread.producerconsumer.guarded;

public class GuardedObject {
    // 守护条件
    private boolean condition = false;

    // 被守护的方法
    public synchronized void guardedMethod() throws InterruptedException {
        // 判断守护条件是否成立
        while (!condition) {
            System.out.println("守护条件不成立,"+Thread.currentThread().getName()+" 线程挂起...");
            wait(); // 等待,直至条件满足
        }
        // 被守护的执行逻辑
        System.out.println("条件满足,线程继续执行...");
    }

    // 设置守护条件
    public synchronized void stateChangingMethod(boolean value) {
        this.condition = value;
        if (condition) {
            notifyAll(); // 条件满足,通知等待线程
        }
    }

}

上述代码很好理解吧,condition 是共享资源,对共享资源的访问需要加锁。

guardedMethod() 是一个受条件保护的方法,只有当 conditiontrue 时才能继续执行。当 conditionfalse 时,线程会调用 wait() 暂时挂起。

至于 Thread类 提供的两个让线程进入阻塞的方法 —— sleepjoin() 都不会释放锁,也不会进入等待队列。更不用说已经废弃的 suspend 方法了,不会释放锁就意味着 guardedMethod 将永远持有这把锁,condition 代表的守护条件也永远不会成立。

那为啥使用 while 循环判断守护条件是否成立而不是 if 判断呢?

原因在于,条件等待队列_waitSet 中的线程被唤醒时会进入 入口等待队列_entryList,然后重新竞争锁,谁知道该线程唤醒的时刻到该线程重新获取锁的时刻,这段时间守护条件有没有被再次改变,所以需要再次验证一下。

其实这个while 循环判断有个别致的名字——“多线程if”。在单线程环境中,if 作为一种条件分支语句,只有一个线程在执行程序,条件一旦不满足,也不会再有其他线程改变它,因此不会有等待的必要。但多线程环境中,条件可能由其他线程在未来某一时刻改变,因此当前线程等待条件的变化就显得很必要。

stateChangingMethod 方法的作用是改变守护条件,如设置守护条件为 ture,则唤醒所有在条件等待队列中的线程。

那为啥使用 notifyAll 唤醒线程而不使用 notify 呢?

前文讲了 notify 唤醒顺序是先进先出,其实说的不严谨,线程调度策略由 JVM 实现决定,HotSpot 虚拟机中的 notify 的唤醒规则是顺序的,但不代表其他 JVM 就是顺序的,而且就算可以保证先进先出也最好不要使用 notify 唤醒线程。

假设有多个消费者线程等待消费数据,只有生产者线程负责生产数据。当缓冲区中有数据时,生产者线程调用 notify() 来唤醒一个消费者线程。如果使用 notify(),唤醒的可能是一个不适合执行任务的线程,例如唤醒了一个没有资格处理当前条件的线程(例如,生产者线程),此时它会再次调用 wait()。其他消费者线程仍然在等待,导致 线程饥饿死锁

归根结底,还是能用 notifyAll 就用 notifyAll

还有一点需要注意,notifyAll() 要放在方法的最后一句执行,为了确保在唤醒其他等待线程之前,当前线程已经完全执行完了必要的状态修改,从而避免唤醒的线程在检查条件时看到一个不一致或尚未完全更新的状态。

客户端

客户端没啥可说的,就是多个线程模式执行守护方法,有线程和改变守护条件。

// 守护对象
GuardedObject guardedObject = new GuardedObject();

// 多线程执行守护对象的守护方法
new Thread(()->{
    try {
        guardedObject.guardedMethod();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
},"ThreadA").start();

new Thread(()->{
    try {
        guardedObject.guardedMethod();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
},"ThreadB").start();


// 模拟等待一段时间后条件满足
Thread.sleep(2000);
System.out.println("**********");

// 设置守护条件为真
guardedObject.stateChangingMethod(true);

输出结果如下:

守护条件不成立,ThreadA 线程挂起...
守护条件不成立,ThreadB 线程挂起...
**********
条件满足,线程继续执行...
条件满足,线程继续执行...

可以看到,初始状态下,守护条件为 false,当线程A和线程B启动运行后,会挂停在判断守护条件是否成立处,然后等待2s后,客户端线程设置守护条件为 true,线程A和线程B就可以继续执行被守护的逻辑了。

守护性暂挂是一种等待条件变化并挂起线程的常用模式,在并发编程中广泛应用。如:

资源管理:线程等待某些资源可用时,才能继续执行操作。

事件驱动的系统:等待某个事件发生(如网络响应、用户输入)后,才能触发后续的处理逻辑。

可以思考一下,生产者-消费者模式要求“生产者在缓冲区满时需要等待”、“消费者在缓冲区空时需要等待”,这算不算一种守护性暂挂呢?

回避模式 Balking

回避(Balking)模式:当守护条件不成立时需要立即中断处理,只有守护条件成立的情况下才能执行目标处理。

balk 是畏缩、回避的意思,在棒球的专业术语中,当垒上有跑垒员时,投手已踏上投手板但中途停止投球的行为也被成 Baik。

Balking 模式算是守护性暂挂的一种特例,只是当条件不满足时不再暂挂,而是退出。

所以改变一个守护对象(Guarded Object)中的代码即可实现 Balking 功能。

public class BalkingObject {

    // 守护条件
    private boolean condition = false;

    // 被守护的方法
    public synchronized void guardedMethod() {
        // 判断守护条件是否成立
        while (!condition) {
            System.out.println("守护条件不成立,"+Thread.currentThread().getName()+" 线程退出...");
            //wait(); // 等待,直至条件满足
            return;
        }
        // 被守护的执行逻辑
        System.out.println("条件满足,线程继续执行...");
    }

    // 设置守护条件
    public synchronized void stateChangingMethod(boolean value) {
        this.condition = value;
//        if (condition) {
//            notifyAll(); // 条件满足,通知等待线程
//        }
    }
}

如上,被守护的方法(guardedMethod)中,守护条件不成立时不再 wait,而是直接 return

balk 的方式有很多,可以返回 false,可以抛出异常,这里是直接 return

被守护的方法没有了 wait,也就不需要唤醒,所以改变守护条件的方法中的唤醒操作注释掉了。

生产者-消费者模式 Producer-Consumer

有了前面的铺垫,理解生产者-消费者模式就很简单了。

前边两个设计模式有个共同的特点就是守护条件(Guarded Condition)。如果用守护条件理解生产者与消费者模式,那就更清晰了:

⨳ 生产者的守护条件是缓冲区未满,如果守护条件不满足就需要暂挂(Suspension)。

⨳ 消费者的守护条件是缓冲区未空,如果守护条件不满足就需要暂挂(Suspension)。

image.png

生产者-消费者组成角色如下:

Data(数据):Data 由 Producer 生成,供 Consumer 使用;

Producer(生产者):Producer 生成 Data ,并将其传递给 Channel;

Consumer(消费者):Consumer 从 Channel 中获取 Data 并使用;

Channel(通道):Channel 保管从 Producer 获取的 Data ,还会响应 Consumer 的请求,传递 Data。

数据 Data

public class Data {
    private String name;
    private int age;
    public Data(String name,int age){
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Data{" +
                "name='" + name + ''' +
                ", age=" + age +
                '}';
    }
}

通道 Channel

生产者与消费者模式中最重要的角色就是 Channel,它相当于二者之间的“桥梁”,它管理着 Data 的存取。

public class Channel {

    // Data 集合
    private final int MAX_CAPACITY = 10;
    private List<Data> container = new ArrayList<>(MAX_CAPACITY);

    // 将 Data 放入集合
    public synchronized void put(Data data) throws InterruptedException {
        while(container.size() ==MAX_CAPACITY){ // 集合已满
            wait();
        }
        container.add(container.size(),data);
        System.out.println("生产者["+Thread.currentThread().getName()+"] 生产了:"+data);
        notifyAll();
    }


    // 从集合中 取Data
    public synchronized Data take() throws InterruptedException {
        while(container.isEmpty()){    // 集合已空
            wait();
        }
        Data data = container.remove(container.size()-1);
        System.out.println("消费者["+Thread.currentThread().getName()+"] 消费到了:"+data);
        notifyAll();
        return data;
     }

}

⨳ 生产者调用 put 方法,向集合中存放 Data 的守护条件是集合未满;

⨳ 消费者调用 take 方法,从集合中取 Data 的守护条件是集合未空;

上述代码将 Data 存放在数组中,可根据实际情况选择 StackQueue 替换存放 Data 的容器。

JUC对条件等待队列实现的更精细化。

生产者 Product

生产者可以生产 Data,并将其交给 Channel 存放。

public class Product implements Runnable{

    private Channel channel;

    public Product(Channel channel){
        this.channel = channel;
    }
    @Override
    public void run() {
        for(int i =0;i<10;i++){
            Data data = new Data(Thread.currentThread().getName(),i);
            try {
                TimeUnit.SECONDS.sleep(1); // 睡1s,模拟生产过程
                channel.put(data);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

消费者 Consumer

消费者从 Channel 中获取生产者生产的 Data,进而消费。

public class Consumer implements Runnable{

    private Channel channel;

    public Consumer(Channel channel){
        this.channel = channel;
    }
    @Override
    public void run() {
        while(true){
            try {
                Data data = channel.take();
                TimeUnit.SECONDS.sleep(1); // 睡1s,模拟消费过程
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }
}

客户端 Client

生产者A生产者B 分别生产了 10Data,供消费者A使用。

// 生产者与消费者共享一个渠道
Channel channel = new Channel();
Product product = new Product(channel);
Consumer consumer = new Consumer(channel);

// 生产者A 与 生产者B 一起生产
new Thread(product,"PA").start();
new Thread(product,"PB").start();

// 消费者A 消费产品
new Thread(consumer,"CA").start();

输出结果如下:

生产者[PB] 生产了:Data{name='PB', age=0}
消费者[CA] 消费到了:Data{name='PB', age=0}
生产者[PA] 生产了:Data{name='PA', age=0}
生产者[PB] 生产了:Data{name='PB', age=1}
消费者[CA] 消费到了:Data{name='PB', age=1}
生产者[PA] 生产了:Data{name='PA', age=1}
生产者[PB] 生产了:Data{name='PB', age=2}
生产者[PA] 生产了:Data{name='PA', age=2}
消费者[CA] 消费到了:Data{name='PA', age=2}
...

源码鉴赏

JDK 之 Timer

Timer 是一个调度任务的工具类,用于安排一个或多个定时任务在未来某个时间点或以固定的时间间隔执行。

关于 [Timer](https://juejin.cn/post/7398461918906990602) ,再讲 JDK使用手册时详细讲过,这里只截取执行任务线程(TimerThread) ,当队列(TaskQueue)中没有新任务的时候进行等待的代码:

synchronized(queue) { 
    // Wait for queue to become non-empty 
    while (queue.isEmpty() && newTasksMayBeScheduled) 
        queue.wait();

这是不是守护性等待呢?

JDK 之 BlockingQueue

JDK的 BlockingQueue 是典型地对生产者-消费者模式的实现,它通过锁和条件队列机制来保证多个线程可以安全地进行 put(生产)和 take(消费)操作。

BlockingQueue 关于数据的存取有两个守护性暂挂的方法:

put(E e):向队列尾部写入新的数据,如果队列已满则阻塞直到有空间可用。

take():从队列头部获取数据,如果队列为空则阻塞直到有元素可用。

还有三个 Balk 的操作:

add(E e):向队列尾部写入新的数据,当队列已满时不会进入阻塞,但是该方法会抛出队列已满的异常。

offer(E e):向队列尾部写入新的数据,当队列已满时不会进入阻塞,并且会立即返回 false

poll():从队列头部获取数据并且该数据会从队列头部移除,当队列为空时,该方法不会使得当前线程进入阻塞,而是返回 null 值。

还有两个半暂挂半Balk的操作:

offer(E e, long timeout, TimeUnit unit):向队列尾部写入新的数据,当队列已满时在指定的时间内进入阻塞,如超时后,队列还是已满的,则返回 false

poll(long timeout, TimeUnit unit):从队列头部获取数据并且该数据会从队列头部移除,如果队列为空则进入阻塞,,如超时后,队列还是空的,则返回 false

BlockingQueue 接口常见的实现类有 ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue ,这些实现类在使用时具有一些差异:

ArrayBlockingQueue:底层是数组实现的队列,容量有边界;所以有暂挂、Balk 的情况

LinkedBlockingQueue:底层是链表实现的队列,可选择是否有边界;如有边界就有暂挂、Balk 的情况

PriorityBlockingQueue:底层是数组实现的队列,容量无边界;没有暂挂、Balk 的情况

无边界的意思其实是容量为 Integer.MAX_VALUE

下面以 ArrayBlockingQueueputtake 方法,看一下生产者与消费者的守护性暂挂:


/** Condition for waiting takes */
private final Condition notEmpty;

/** Condition for waiting puts */
private final Condition notFull;

public void put(E e) throws InterruptedException { 
    final ReentrantLock lock = this.lock; // 获取队列的锁 
    lock.lockInterruptibly(); // 使用可中断方式获取锁 
    try { 
        while (count == items.length) // 如果队列已满 
            notFull.await(); // 在notFull条件队列上等待,直到队列不满 
        enqueue(e); // 将元素添加到队列中 
    } finally { 
        lock.unlock(); // 释放锁 
    } 
}

public E take() throws InterruptedException { 
    final ReentrantLock lock = this.lock; // 获取队列的锁 
    lock.lockInterruptibly(); // 使用可中断方式获取锁 
    try { 
        while (count == 0) // 如果队列为空 
            notEmpty.await(); // 在notEmpty条件队列上等待,直到队列不为空 
        return dequeue(); // 从队列中移除并返回一个元素 
    } finally { 
        lock.unlock(); // 释放锁 
    } 
}

代码还是很清晰的。

总结

无论是 Guarded Suspension 模式的 Guarded Object 还是 Producer-Consumer 模式的 Channel,都是将守护条件,与对守护条件的操作封装在一个类中,将 lockwaitnotify 操作封装到一个类中(如 Guarded ObjectChannel)。

这样做的好处就是其他类不需要考虑线程安全问题,直接使用即可。

如果用设计模式来考虑生产者-消费者,Channel 的作用就是解耦生产者和消费者,当生产者生成数据速度快于消费者处理数据时,数据可以暂时存储在缓冲区中,消费者可以根据自己的处理能力依次消费。当消费者快于生产者时,消费者会等待生产者生成新的数据,这样避免了空消费或不必要的忙等。