假设现在有一个对账系统,需要优化一下。它的基本业务是用户通过在线商城下单,然后生成电子订单,保存在订单库;之后物流会生成派送单给用户发货,派送单保存在派送单库。为了防止漏派送或重复派送,对账系统每天会校验是否存在异常订单。
目前的对账系统的逻辑处理是先查询订单,然后查询派送单,之后对比订单和派送单,将差异写入差异库。流程如图所示:
对账系统的代码抽象之后的核心代码如下,就是在一个单线程里面循环查询订单、派送单,然后执行对账,最后将差异写入差异库。
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的计数器是可以循环利用的,而且可以自动重置,同时它还有一个回调函数。