第三节实验课 —— 生产者-消费者模型
生产者-消费者模型
一个模块产生数据,在另一个模块中处理数据。产生数据的模块称为生产者,处理数据的模块称为消费者。
如果直接在消费者中暴露一个方法,供生产者调用来处理数据,这样在两者之间就有依赖关系(耦合),也不利于利用多线程提高产生数据和处理数据的效率(并发)。这种情况下引入一个数据缓冲,即生产者产生的数据会逐一放入缓冲区,消费者反复从缓冲区取出数据进行处理。
同步互斥
题目设定:生产者负责产生大于20亿的随机数,消费者负责判断这些随机数是否素数
在生产者和消费者之间加入数据缓冲:
public class Buffer {
final private static Queue<Integer> buffer = new LinkedList<>();
private Buffer() {
}
/**
* 向缓冲区加入数字数
* @param number
*/
public static void add(int number) {
buffer.add(number);
}
/**
* 从缓冲区取出数字
* @return
*/
static public int remove() {
return buffer.remove();
}
}
问题就来了,要是消费者去缓冲区取数据的时候,缓冲区没有数据了
java.util.NoSuchElementException
at java.util.LinkedList.removeFirst(Unknown Source)
at java.util.LinkedList.remove(Unknown Source)
at .....
又或者两个消费者来到缓冲区,拿到了一个数据
......
A: 2025511403 Prime
B: 2025511403 Prime
......
前一个问题是同步问题,即在生产者和消费者的处理速度不相同时,发生的上面所说的一类问题;后一个问题是互斥问题,即多个消费者争夺共享资源导致数据重复被处理或其他数据上的错误。
修改一下缓冲区的代码,送数和取数的操作上都加上互斥锁(Java中synchronized关键字),取数时加上是否为空的判断
public class Buffer {
private static Queue<Integer> buffer = new LinkedList<>();
private Buffer() {
}
/**
* 向缓冲区加入数字数
* @param number
*/
public static void add(int number) {
synchronized (buffer) {
buffer.add(number);
}
}
/**
* 从缓冲区取出数字
* @return
*/
static public int remove() {
int number;
synchronized (buffer) {
while (buffer.size() <= 0) {
}//循环判断缓冲区直到缓冲区非空
number = buffer.remove();
}
return number;
}
}
synchronized代码块可以保证代码块中的内容在同一时间只能由一个线程执行,其他线程必须等待进入代码块的线程先执行完,才能执行代码块中的内容。
synchronized代码块需要一个对象作为互斥监视器,线程执行到synchronized代码块时,先检查是否被上锁,如果没有,则线程给对象上锁,接着执行代码块中的内容,最后给对象解锁;如果对象已经上锁了,则说明有其他线程正在执行,则当前线程必须等待其他线程执行完,给对象解锁,然后当前线程才给对象上锁,接着执行。
死锁
这样改完代码之后,同步互斥问题解决了,新问题又产生了,就是线程会死锁。
消费者从缓冲区取数时给缓冲区上锁了,而恰好这时候缓冲区是空的,此时消费者循环判断缓冲区是否空;同时,生产者想要向缓冲区送数,发现缓冲区被上锁了,送不进去。这时候,消费者等待生产者送数,生产者等待消费者让出缓冲区,满足互斥、占有并等待、不可抢占、循环等待四大死锁条件,然后死锁了(操作系统讲的内容)。
(有同学问我,把上面代码判断缓冲非空的地方放到互斥代码块之外不就好了?天真!那样子同步问题依旧。把送数方法的互斥锁去掉呢?照样死锁。)
这样写了个循环判断的代码,其实,即使不死锁,也是白白浪费时间在循环上。
信号量
解决上面的问题,可以用信号量(也是操作系统讲的内容)。
简单点说,信号量就是设置一个变量来指示共享资源的状态,当满足一定条件时,阻塞或者唤醒进程(线程)。
这上面所说的随机数的设定上,缓冲区中数据的数量就可以作为信号量,当信号量<=0时,阻塞消费者,当生产者产生一个随机数放入了缓冲区,信号量+1,可以唤醒消费者了。
再改上面缓冲区的代码,加入Lock和Condition
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Buffer {
private static Queue<Integer> buffer = new LinkedList<>();
private static Lock lock = new ReentrantLock();
private static Condition condition = lock.newCondition();
private Buffer() {
}
public static void add(int number) {
lock.lock();
buffer.add(number);
condition.signal();//给缓冲区加了数,唤醒一条被阻塞线程
lock.unlock();
}
static public int remove() {
lock.lock();
while (buffer.size() <= 0) {//阻塞条件
try {
condition.await();//缓冲区为空,阻塞当前线程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int number = buffer.remove();
lock.unlock();
return number;
}
}
上面代码中的Lock,相当于互斥锁。lock()是上锁,当然互斥部分的结尾要记得unlock()。Condition则相当于信号量,线程由一个Condition阻塞,也得由同一个Condition来唤醒,所以代码中调用await()和signal()的是同一个Condition对象(Condition还有一个方法叫signalAll()是唤醒所有由该信号量阻塞的线程的)。
这样写除了解决同步互斥的问题外,显而易见的是解决了资源浪费的问题,就是避免了消费者在取数时因为缓冲区为空而做循环判断。
我还记得上学期操作系统课上是有这么讲的:
信号量原语
struct semaphore
{
int count;
queueType queue;
}
void semWait(semaphore s)
{
s.count--;
if(s.count < 0)
{
/*把当前进程插入到阻塞队列*/
/*阻塞当前进程*/
}
}
void semSignal(semaphore s)
{
s.count++;
if(s.count <= 0)
{
/*把进程P从阻塞队列中移出*/
/*把进程P加入到就绪队列*/
}
}
使用信号量实现互斥
const int n = /*进程数*/;
semaphore s = 1;
void P(int i)
{
while(true)
{
semWait(s);
/*临界区*/
semSignal(s);
/*其他代码*/
}
}