【Java&Go并发编程系列】11.条件变量——Condition VS sync.Cond

说明: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 并发编程系列

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值