【多线程】CountDownLatch 和 CyclicBarrier:如何让多线程步调一致?

假设现在有一个对账系统,需要优化一下。它的基本业务是用户通过在线商城下单,然后生成电子订单,保存在订单库;之后物流会生成派送单给用户发货,派送单保存在派送单库。为了防止漏派送或重复派送,对账系统每天会校验是否存在异常订单。

目前的对账系统的逻辑处理是先查询订单,然后查询派送单,之后对比订单和派送单,将差异写入差异库。流程如图所示:

            在这里插入图片描述

对账系统的代码抽象之后的核心代码如下,就是在一个单线程里面循环查询订单、派送单,然后执行对账,最后将差异写入差异库。

while(存在未对账订单){
	// 查询未对账订单
	pos = getPOrders();
	// 查询派送单
	dos = getDOrders();
	// 执行对账操作
	diff = check(pos,dos);
	// 差异写入差异库
	save(diff);
}

利用并行优化对账系统

要想优化上面所说的对账系统的性能,首先要找到它的瓶颈所在。

目前的对账系统由于订单量和派送单量很大,所说义查询未对账订单 getPOrders() 和查询派送单 getDOrders() 相对较慢,如何优化下呢?现在它是一个单线程的(如图),优化的话我们首先可以想到 是否可以利用多线程并行处理
              在这里插入图片描述
从图中可以看出该系统的瓶颈:查询未对账订单 getPOrders() 和查询派送单 getDOrders() 是否可以平行处理?可以。因为这两个操作没有先后顺序依赖,那么我们让它俩并行执行后,如图所示。可以看出,相比于单线程的执行过程,同等时间里,并行执行的吞吐量几乎是单线程的2倍。

              在这里插入图片描述
现在,优化思路我们已经想好了,如何用代码实现呢?

我们先创建两个线程 T1 和 T2,并行执行查询未对账订单 getPOrders() 和查询派送单 getDOrders() 这两个操作。在主线程中执行对账操作 check() 和差异写入 save() 两个操作。不过需要注意:主线程需要等待线程 T1 和 T2 执行完才能执行 check() 和 save() 操作,所以我们可以通过调用 T1.join() 和 T2.join() 来实现等待,当 T1 和T2 线程退出时,调用 T1.join() 和 T2.join() 的主线程就会从阻塞态被唤醒,从而执行之后的 check() 和 save() 操作。

while(){
	// 查询未对账订单
	Thread T1 = new Thread(() -> {
		pos = getPOrders();
	});
	T1.start();
	// 查询派送单
	Thread T2 = new Thread(() ->{
		dos = getDOrders();
	});
	T2.start();
	// 等待T1,T2结束
	T1.join();
	T2.join();
	// 执行对账操作
	diff = check(pos,dos);
	// 差异写入差异库
	save(diff);
}

用 CountDownLatch 实现线程等待

上面的优化过程中,while循环里每次都会创建新的线程,这也是一个耗时操作,所以还可以继续优化:要是创建的线程可以循环利用就好了。这时可使用线程池来解决它。

康康代码吧。首先创建一个固定大小为2的线程池,之后在 while 循环里重复利用。不过这样看似完美,但是依然有个问题:主线程如何知道 getPOrders() 和 getDOrders() 这两个操作什么时候执行完?前面主线程通过调用 T1 和 T2 的 join() 方法来等待线程 T1 和 T2 退出,但是在线程池的方案里,线程不会退出,所以 join() 方法就失效了。

// 创建2个线程的线程池
Executor executor = Executor.newFixedThreadPool(2);

while(存在未对账订单){
	// 查询未对账订单
	executor.execute(() -> {
		pos = getPOrders();
	});
	// 查询派送单
	executor.execute(() -> {
		dos = getDOrders();
	});

	// 如何实现等待???

	// 执行对账操作
	diff = check(pos,dos);
	// 差异写入差异库
	save(diff);
}

如何解决这个问题呢?聪明的你可能已经想到了计数器。没错,弄一个计数器,初始值设置为2,执行完 pos = getPOrders(); 后,计数器减1,执行完 dos = getDOrders(); 后,计数器再减1,在主线程里,等待计数器等于0;当计数器等于0时,说明两个查询操作执行完了。

不过,在Java并发包里已经提供了实现类似功能的工具列:CountDownLatch,我们可以直接使用。所以更建议下面这种方式。

下面的代码示例中,在 while 循环里,首先创建了一个 CountDownLatch,计数器初始值等于2,之后在 pos = getPOrders(); 和dos = getDOrders(); 两条语句后面对计数器执行减1操作,这个减1操作是通过调用 latch.countDown(); 来实现的。在主线程中,通过调用 larch.await() 来实现对计数器等于 0 的等待。

// 创建2个线程的线程池
Executor executor = Executor.newFixedThreadPool(2);

while(存在未对账订单){
	// 计数器初始化为2
	CountDownLatch latch = new CountDownLatch(2);
	
	// 查询未对账订单
	executor.execute(() -> {
		pos = getPOrders();
		latch.countDown();
	});
	// 查询派送单
	executor.execute(() -> {
		dos = getDOrders();
		latch.countDown();
	});

	// 等待两个查询操作结束
	latch.await();

	// 执行对账操作
	diff = check(pos,dos);
	// 差异写入差异库
	save(diff);
}

进一步优化性能

前面我们用 CountDownLatch 实现线程等待,而且 getPOrders() 和 getDOrders() 这两个查询操作并行执行,但是他俩和对账操作 check() 和 save() 之间还是串行的。他们之间能也并行么?也就是再执行队长操作的时候,同时执行下一轮查询操作。我们用形象的方式理解一下:
              在这里插入图片描述
根据需求我们可以了解到:队长操作依赖于查询操作的结果,就像 生产者-消费者 模式,两次查询操作是生产者,对账操作是消费者。既然是生产者-消费者 模式就需要有个队列,来保存生产者生产的数据,而消费者聪这个队列中取出数据进行消费。

如图是针对这个对账项目设计的队列。两个队列的元素之间有对应关系。订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送但插入派送单队列,这两个队列的元素之间有一一对应的关系。两个队列的好处是,对账操作可以每次聪订单队列出一个元素,聪派送但队列出一个元素,然后对这两个元素执行对账操作,是不是井井有条的?
                 在这里插入图片描述
下面再来看看如何用双队列实现完全的并行。一个线程T1 执行订单查询工作,一个线程T2 执行派送单查询工作,当线程T1 和T2都各自生产完1条数据时,通知线程T3执行对账操作。

下图就描述了上面的过程:线程T1和T2只有都生产完1条数据时,才能一起向下执行,也就是说,线程T1 和线程T2的工作步调要一致,相互等待。同时它们都生产完一条数据,还得能通知线程T3执行对账操作。
                  在这里插入图片描述

用 CyclicBarrier 实现线程同步

上面的过程有两个问题:

  • 线程T1 和T2 如何做到步调一致?
  • 怎样做到能够通知线程T3?

Java并发包里提供了相关的工具类:CyclicBarrier

下面的代码中先创建了一个计数器初始值为2的 CyclicBarrier,需要注意的是创建 CyclicBarrier 的时候,传入了一个回调函数,当计数器减到0的时候,会调用这个函数。

线程T1负责查询订单,当查出一条时,调用 barrier.await() 来将计数器减1,同时等待计数器变为0;线程T2负责查询派送单,当查出一条时,调用 barrier.await() 将计数器减1,同时等待计数器变为0;当T1 和T2 都调用 barrier.await() 的时候,计数器会减到0,此时T1和T2就可以执行下一条语句了,同时会调用barrier的回调函数来执行对账操作。

CyclicBarrier 的计数器有自动重置功能,当减到0的时候,会自动重置你设置的初始值

// 订单队列
Vector<P> pos;
// 派送单队列
Vector<D> dos;
// 执行回调的线程池
Executor executor = Executor.newFixedThreadPool(1);
final CyclicBarrier barrier = new CyclicBarriere(2,()->{
	executor.execute(()->check());
});

void check(){
	P p = pos.remove(0);
	D d = dos.remove(0);
	// 执行对账操作
	diff = check(p,d);
	// 差异写入差异库
	save(diff);
}

void checkAll(){
	// 循环查询订单库
	Thread T1 = new Thread(()-> {
		while(存在未对账订单){
			// 查询订单库
			pos.add(getPOrders());
			// 等待
			barrier.await();
		}
	});
	T1.start();
	// 循环查询运单库
	Thread T2 = new Thread(()->{
		while(存在未对账订单){
			// 查询运单库
			dos.add(getDOrders());
			// 等待
			barrier.await();
		}
	});
	T2.start();
}

总结

CountDownLatch 和 CyclicBarrier 是 Java 并发包提供的两个非常易用的线程同步工具类,它们的区别是:CountDownLatch 主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等所有游客到齐才能去下一个景点;同时它的计数器不能循环利用,一旦计数器减到0,再有线程调用 await() ,该线程会直接通过。而 CyclicBarrier 是一组线程之间相互等待CyclicBarrier的计数器是可以循环利用的,而且可以自动重置,同时它还有一个回调函数。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杨幂等

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值