说明:Java & Go 并发编程序列的文章,根据每篇文章的主题或细分标题,分别演示 Java 和 Go 语言当中的相关实现。更多该系列文章请查看:Java & Go 并发编程系列
在前文讲《互斥锁》的时候,使用了自动售卖机作为例子进行了代码的演示。先回顾之前的代码场景。
代码场景:假设有一台自动售卖机,售卖和补货操作不能同一时间进行。即:
- 当用户在购物时,就不能执行补货操作;
- 当执行补货操作时,用户也不能购物;
- 如果有多个用户需要购物,也只能依次执行。
本文接着用这个例子演示条件变量。并增加如下代码场景:
- 售卖机初始可售量为5
- 售卖机最大可售量为5,当售卖机的可售量小于5时,会通知后台(补货员)售卖机的货架有空余,但是补货员只有在可售量为0的时候才进行补货,一次补货数量为5
- 当补货操作完成时,会通知用户已有货物可卖
- 期间有10个用户购物和一次补货操作
假设没有条件变量这个工具,我们看下只基于锁如何实现上述需求(暂时忽略通知,只实现当满足特定条件的时候进行特定的操作)。
首先,为了演示执行锁的动作的次数,自定义一个锁工具类 MyLock,继承 ReentrantLock,扩展了 lock() 方法增加了计数功能,即每执行一次 lock() 操作计数器就加1。MyLock 还提供了 getLockTimes() 方法执行 lock() 操作的次数:
static class MyLock extends ReentrantLock {
private final LongAdder counter;
public MyLock() {
super();
counter = new LongAdder();
}
@Override
public void lock() {
super.lock();
counter.increment();
}
private long getLockTimes() {
return counter.sum();
}
}
修改前文中介绍的自动售卖机 VendingMachine ,基于自定义的锁 MyLock。提供一个打印执行锁动作次数的方法 printLockTimes()。再看 sale() 和 stock() 方法,由于必须要分别满足以下条件才能执行对应的操作,所以会在一个循环里面不断去尝试:
- sale():剩余可售量大于0
- stock():剩余可售量等于0
修改后的代码如下:
// VendingMachine 表示自动售卖机
static class VendingMachine {
// 使用锁来保证,售卖和补货不能同时进行
private final MyLock lock;
private final int maxSize;// 最大可售量
private int remainder;// 剩余可售量
public VendingMachine() {
lock = new MyLock();
maxSize = 5;
remainder = maxSize;// 初始可售量为5
}
// 售卖
public void sale() {
while (true) {
lock.lock();// 获得锁之后才能往下执行
try {
if (remainder == 0) {
continue;
}
System.out.printf("[%s] 开始购物...\n",
Thread.currentThread().getName());
TimeUnit.MILLISECONDS.sleep(200);
remainder--;// 可售量减1
System.out.printf("[%s] 购物完成,剩余可售量: %d\n",
Thread.currentThread().getName(), remainder);
break;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();// 解锁
}
}
}
// 补货
public void stock() {
while (true) {
lock.lock();// 获得锁之后才能往下执行
try {
if (remainder > 0) {
continue;
}
// 当可售量为0时,结束 while 循环,进入补货流程
System.out.printf("[%s] 开始进货...\n",
Thread.currentThread().getName());
TimeUnit.MILLISECONDS.sleep(500);
remainder = maxSize;// 一次性补满货
System.out.printf("[%s] 进货完成,可售量: %d\n",
Thread.currentThread().getName(), remainder);
break;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();// 解锁
}
}
}
public void printLockTimes() {
System.out.printf("锁的次数:%d\n", lock.getLockTimes());
}
}
运行程序执行10个用户购物和一次补货的操作。
public static void main(String[] args) throws InterruptedException {
// 创建一个自动售卖机
VendingMachine machine = new VendingMachine();
// 为了演示所有新创建的线程执行完毕之后再退出主线程
CountDownLatch latch = new CountDownLatch(11);
List<Thread> threadList = new ArrayList<>();
// 使用10个线程模拟10名用户执行购物动作
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
machine.sale();
latch.countDown();
});
t.setName("Customer-" + i);
threadList.add(t);
}
// 创建一个线程模拟补货员执行一次补货动作
Thread deliverymanThread = new Thread(() -> {
machine.stock();
latch.countDown();
});
deliverymanThread.setName("Deliveryman");
threadList.add(deliverymanThread);
// 借用 parallel 方法并行地去启动线程
threadList.stream().parallel().forEach(t -> t.start());
latch.await();// 等待以上线程执行完成
machine.printLockTimes();
System.out.println("End.");
}
运行结果:
[Customer-2] 开始购物...
[Customer-2] 购物完成,剩余可售量: 4
[Customer-1] 开始购物...
[Customer-1] 购物完成,剩余可售量: 3
[Customer-4] 开始购物...
[Customer-4] 购物完成,剩余可售量: 2
[Customer-0] 开始购物...
[Customer-0] 购物完成,剩余可售量: 1
[Customer-9] 开始购物...
[Customer-9] 购物完成,剩余可售量: 0
[Deliveryman] 开始进货...
[Deliveryman] 进货完成,可售量: 5
[Customer-8] 开始购物...
[Customer-8] 购物完成,剩余可售量: 4
[Customer-5] 开始购物...
[Customer-5] 购物完成,剩余可售量: 3
[Customer-7] 开始购物...
[Customer-7] 购物完成,剩余可售量: 2
[Customer-6] 开始购物...
[Customer-6] 购物完成,剩余可售量: 1
[Customer-3] 开始购物...
[Customer-3] 购物完成,剩余可售量: 0
锁的次数:36
End.
从运行结果来看,程序正确的执行了购物和入货操作,即购物的前提条件是可售量大于0,入货的前提条件是可售量为0。再看最后执行加锁的次数为36,重新运行了几次程序,这个数字是几十到几百不等,实际上这个次数只要大于等于11都是正常的结果,最小为11是因为有10个购物动作和1个入货动作。
这里重复执行锁操作实际上是一种资源的浪费。 这个时候条件变量就派上用场了。使用条件变量先让不满足条件的线程挂起,由另外的线程在满足一定条件时通知并唤醒该线程。用在本文的例子,就是说:
- 当用户购物的时候,如果可售量为0,则挂起该线程。等待入货操作之后(可售量不为0),唤醒购物线程。
- 当入货线程进来时,如果可售量不为0,则挂起。用户购物后(货架有空余)则会通知后台(补货员)补货,但是这里做了一个假设,补货员不需要货架一有空余就补货,而是只有在可售量为0的时候才进行补货。
由于条件变量只能单向的通知,这里需要购物线程通知入货线程,入货线程通知购物线程,双向的通知需要两个条件变量。
「Java」 Condition
以下为基于条件变量的实现:
新增 Condition 类型的成员变量 notEmpty,notFull。
修改 sale()、stock(),将外层 while 循环去掉,将 if 条件语句修改为 while 语句,并在 while 循环里面添加相关条件变量的 await() 方法(至于这里为什么要用 while 循环,看完代码再补充说明)。表示当不满足指定条件时执行等待操作。
// VendingMachine 表示自动售卖机
static class VendingMachine {
// 使用锁来保证,售卖和补货不能同时进行
private final MyLock lock;
private final Condition notEmpty;// 表示货架不为空
private final Condition notFull;// 表示货架不满,有空位
private final int maxSize;// 最大可售量
private int remainder;// 剩余可售量
public VendingMachine() {
lock = new MyLock();
notEmpty = lock.newCondition();// 需要基于锁来创建条件变量
notFull = lock.newCondition();// 需要基于锁来创建条件变量
maxSize = 5;
remainder = maxSize;// 初始可售量为5
}
// 售卖
public void sale() {
lock.lock();// 获得锁之后才能往下执行
try {
while (remainder == 0) {
notEmpty.await();// 剩余可售量为0,等待有货可售
}
System.out.printf("[%s] 开始购物...\n",
Thread.currentThread().getName());
TimeUnit.MILLISECONDS.sleep(200);
remainder--;// 可售量减1
System.out.printf("[%s] 购物完成,剩余可售量: %d\n",
Thread.currentThread().getName(), remainder);
// 购买之后可售量是未满的状态,通知补货员进行补货
// 这里只有一个补货员,所以可以使用 signal 单发通知
notFull.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();// 解锁
}
}
// 补货
public void stock() {
lock.lock();// 获得锁之后才能往下执行
try {
while (remainder > 0) {
notFull.await();// 剩余可售量大于0,继续等待
}
// 当可售量为0时,结束 while 循环,进入补货流程
System.out.printf("[%s] 开始进货...\n",
Thread.currentThread().getName());
TimeUnit.MILLISECONDS.sleep(500);
remainder = maxSize;// 一次性补满货
System.out.printf("[%s] 进货完成,可售量: %d\n",
Thread.currentThread().getName(), remainder);
notEmpty.signalAll();// 通知所有等待的用户补货完成,有货可售
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();// 解锁
}
}
public void printLockTimes() {
System.out.printf("锁的次数:%d\n", lock.getLockTimes());
}
}
运行结果:
[Customer-2] 开始购物...
[Customer-2] 购物完成,剩余可售量: 4
[Customer-0] 开始购物...
[Customer-0] 购物完成,剩余可售量: 3
[Customer-3] 开始购物...
[Customer-3] 购物完成,剩余可售量: 2
[Customer-9] 开始购物...
[Customer-9] 购物完成,剩余可售量: 1
[Customer-8] 开始购物...
[Customer-8] 购物完成,剩余可售量: 0
[Deliveryman] 开始进货...
[Deliveryman] 进货完成,可售量: 5
[Customer-5] 开始购物...
[Customer-5] 购物完成,剩余可售量: 4
[Customer-7] 开始购物...
[Customer-7] 购物完成,剩余可售量: 3
[Customer-6] 开始购物...
[Customer-6] 购物完成,剩余可售量: 2
[Customer-4] 开始购物...
[Customer-4] 购物完成,剩余可售量: 1
[Customer-1] 开始购物...
[Customer-1] 购物完成,剩余可售量: 0
锁的次数:11
End.
从运行结果来看,符合预期。跟之前没有基于条件变量的实现相比,只有一处有差异,就是执行加锁动作的次数,这里无论运行多少次都是11。这是因为条件变量在这里,实际上是起到了“协调”的作用,避免了无谓的锁的争抢。
再来说说在执行条件变量的 await() 方法时,为什么要在一个循环里面。这里是为了保证,当一个线程被唤醒时,相关的条件不一定成立。比如说,条件变量 notFull 需要满足可售量为0时才进行补货,当线程被唤醒时,不一定满足该条件,可能是在可售量为[0,4]之间被唤醒。再比如说,如果补货完成之后,给10个用户发起了通知,这时只有5个可以购买成功,另外5个无货可买。所以,这里的循环非常有必要。
「Go」sync.Cond
以下代码是基于 Go 语言条件变量 sync.Cond 的实现:
// VendingMachine 自动售卖机
type VendingMachine struct {
lock sync.Mutex // 互斥锁,开箱即用的类型
notEmpty sync.Cond // 表示货架不为空
notFull sync.Cond // 表示货架不满,有空位
maxSize uint8 // 最大可售量
remainder uint8 // 剩余可售量
}
// 售卖,参数 name 为用户名称
func (vm *VendingMachine) sale(name string) {
vm.lock.Lock() // 获得锁之后才能往下执行
for vm.remainder == 0 {
vm.notEmpty.Wait() // 剩余可售量为0,等待有货可售
}
fmt.Printf("[%s] 开始购物...\n", name)
time.Sleep(time.Millisecond * 200)
vm.remainder-- // 可售量减1
fmt.Printf("[%s] 购物完成,剩余货物数量: %d\n", name, vm.remainder)
vm.lock.Unlock() // 解锁
// 购买之后可售量是未满的状态,通知补货员进行补货
// 这里只有一个补货员,所以可以使用 signal 单发通知
vm.notFull.Signal()
}
// 补货,参数 name 为补货员名称
func (vm *VendingMachine) stock(name string) {
vm.lock.Lock() // 获得锁之后才能往下执行
for vm.remainder > 0 {
vm.notFull.Wait() // 剩余可售量大于0,继续等待
}
// 当可售量为0时,结束 for 循环,进入补货流程
fmt.Printf("[%s] 开始进货...\n", name)
time.Sleep(time.Millisecond * 500)
vm.remainder = vm.maxSize
fmt.Printf("[%s] 进货完成,剩余货物数量: %d\n", name, vm.remainder)
vm.lock.Unlock() // 解锁
vm.notEmpty.Broadcast() // 通知所有等待的用户补货完成,有货可售
}
// NewVendingMachine 新建一个自动售卖机
func NewVendingMachine() *VendingMachine {
vm := &VendingMachine{}
vm.notEmpty.L = &vm.lock // 需要基于锁来创建条件变量
vm.notFull.L = &vm.lock // 需要基于锁来创建条件变量
vm.maxSize = 5
vm.remainder = vm.maxSize // 初始可售量为5
return vm
}
func main() {
// 创建一个自动售卖机
vm := NewVendingMachine()
// 为了演示所有新启用的 goroutine 执行完毕之后再退出主 goroutine
var wg sync.WaitGroup
wg.Add(11) // 11表示一共启用了11个 goroutine 需要等待结束
// 启用10个 goroutine 模拟5名用户执行购物动作
for i := 0; i < 10; i++ {
go func(i int) {
defer wg.Done()
vm.sale(fmt.Sprintf("Customer-%d", i))
}(i)
}
// 启用1个 goroutine 模拟补货员执行一次补货动作
go func() {
defer wg.Done()
vm.stock("Deliveryman")
}()
wg.Wait() // 等待以上 gorountine 执行完成
fmt.Println("End.")
}
运行结果:
[Customer-4] 开始购物...
[Customer-4] 购物完成,剩余货物数量: 4
[Customer-3] 开始购物...
[Customer-3] 购物完成,剩余货物数量: 3
[Customer-0] 开始购物...
[Customer-0] 购物完成,剩余货物数量: 2
[Customer-1] 开始购物...
[Customer-1] 购物完成,剩余货物数量: 1
[Customer-7] 开始购物...
[Customer-7] 购物完成,剩余货物数量: 0
[Deliveryman] 开始进货...
[Deliveryman] 进货完成,剩余货物数量: 5
[Customer-5] 开始购物...
[Customer-5] 购物完成,剩余货物数量: 4
[Customer-8] 开始购物...
[Customer-8] 购物完成,剩余货物数量: 3
[Customer-6] 开始购物...
[Customer-6] 购物完成,剩余货物数量: 2
[Customer-2] 开始购物...
[Customer-2] 购物完成,剩余货物数量: 1
[Customer-9] 开始购物...
[Customer-9] 购物完成,剩余货物数量: 0
End.
运行结果也是符合预期。
拓展
比对 Java 和 Go 语言的两种基于条件变量的实现方式,有一个最大的区别:Go 语言里面条件变量的通知 Signal() 和 Broadcast(),并没有在锁的保护下执行,而是在 Unlock() 之后执行。
具体的原因首先我们需要看一下 sync.Cond 的 Wait() 方法,代码如下。从第4行可以看到,Wait() 首先会执行一次解锁的操作(这个时候其他 Goroutine 就有机会执行锁定操作了),然后让当前的 Goroutine 处于等待状态,等待相关条件变量的通知。如果当前的 Gorountie 被唤醒,则会进行锁定操作。
func (c *Cond) Wait() {
c.checker.check()
t := runtime_notifyListAdd(&c.notify)
c.L.Unlock()
runtime_notifyListWait(&c.notify, t)
c.L.Lock()
}
总结来说,就是处于等待状态的 Goroutine,在被其他 Goroutine 唤醒时会尝试解锁,所以执行唤醒操作的通知方法 Signal() 和 Broadcast(),总是应该在解锁之后才执行,这样被唤醒的 Goroutine 可以更顺利的执行锁定操作。
而 Java 的实现方式与此不同,实际上是参考了 MESA 模型,由于篇幅有限,后续有机会再介绍。
更多该系列文章请查看:Java & Go 并发编程系列