美文网首页it
CountDownLatch和CyclicBarrier

CountDownLatch和CyclicBarrier

作者: 我可能是个假开发 | 来源:发表于2023-02-25 16:58 被阅读0次

    一、背景-对账系统处理逻辑

    用户通过在线商城下单,会生成电子订单,保存在订单库;之后物流会生成派送单给用户发货,派送单保存在派送单库。为了防止漏派送或者重复派送,对账系统每天还会校验是否存在异常订单。

    对账系统流程.png
    • 查询订单
    • 查询派送单
    • 对比订单和派送单
    • 将差异写入差异库
    while(存在未对账订单){
      // 查询未对账订单
      pos = getPOrders();
      // 查询派送单
      dos = getDOrders();
      // 执行对账操作
      diff = check(pos, dos);
      // 差异写入差异库
      save(diff);
    } 
    

    二、利用并行优化对账系统

    查询未对账订单 getPOrders() 和查询派送单 getDOrders() 采用并行处理;因为这两个操作并没有先后顺序的依赖。这两个最耗时的操作并行之后,执行过程如下图所示。对比单线程的执行,同等时间里,并行执行的吞吐量近乎单线程的 2 倍。


    并行优化.png
    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);
    } 
    

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

    三、CountDownLatch 实现线程等待

    定义

    CountDownLatch可以设置一个计数器,通过countDown()方法进行减1操作,使用await()方法等待计数器不大于0,再继续执行await()方法之后的语句。

    并行执行的方案中,while 循环里面每次都会创建新的线程,而创建线程可是个耗时的操作。所以最好是创建出来的线程能够循环利用,所以可以使用线程池就能解决这个问题。

    创建一个固定大小为 2 的线程池,之后在 while 循环里重复利用。
    主线程如何知道 getPOrders() 和 getDOrders() 这两个操作什么时候执行完。前面主线程通过调用线程 T1 和 T2 的 join() 方法来等待线程 T1 和 T2 退出,但是在线程池的方案里,线程根本就不会退出,所以 join() 方法已经失效了。最直接的办法是弄一个计数器,初始值设置成 2,当执行完pos = getPOrders();这个操作之后将计数器减 1,执行完dos = getDOrders();之后也将计数器减 1,在主线程里,等待计数器等于 0;当计数器等于 0 时,说明这两个查询操作执行完了。等待计数器等于 0 其实就是一个条件变量,用管程实现起来也很简单。

    使用CountDownLatch实现:

    // 创建2个线程的线程池
    Executor executor = Executors.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);
    }
    

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

    四、CyclicBarrier 实现线程同步

    定义

    CyclicBarrier构造方法第一个参数是目标障碍数,如果达到了目标障碍数,才会执行cyclicBarrier.await()之后的语句。可以将CyclicBarrier理解为+1操作。

    前面将 getPOrders() 和 getDOrders() 这两个查询操作并行了,但这两个查询操作和对账操作 check()、save() 之间还是串行的。很显然,这两个查询操作和对账操作也是可以并行的,也就是说,在执行对账操作的时候,可以同时去执行下一轮的查询操作:


    完全并行执行.png

    两次查询操作能够和对账操作并行,对账操作还依赖查询操作的结果,类似生产者 - 消费者,两次查询操作是生产者,对账操作是消费者。既然是生产者 - 消费者模型,那就需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据。

    双队列.png

    订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送单插入派送单队列,这两个队列的元素之间是有一一对应的关系的。两个队列的好处是,对账操作可以每次从订单队列出一个元素,从派送单队列出一个元素,然后对这两个元素执行对账操作,这样数据一定不会乱掉。

    用双队列来实现完全的并行:
    一个线程 T1 执行订单的查询工作,一个线程 T2 执行派送单的查询工作,当线程 T1 和 T2 都各自生产完 1 条数据的时候,通知线程 T3 执行对账操作。线程 T1 和线程 T2 的工作要步调一致,不能一个跑得太快,一个跑得太慢,只有这样才能做到各自生产完 1 条数据的时候,通知线程 T3。

    同步执行.png

    线程 T1 和线程 T2 只有都生产完 1 条数据的时候,才能一起向下执行,也就是说,线程 T1 和线程 T2 要互相等待,步调要一致;同时当线程 T1 和 T2 都生产完一条数据的时候,还要能够通知线程 T3 执行对账操作。

    实现方案

    • 线程 T1 和 T2 要做到步调一致
    • 要能够通知到线程 T3
      利用一个计数器来解决这两个难点,计数器初始化为 2,线程 T1 和 T2 生产完一条数据都将计数器减 1,如果计数器大于 0 则线程 T1 或者 T2 等待。如果计数器等于 0,则通知线程 T3,并唤醒等待的线程 T1 或者 T2,与此同时,将计数器重置为 2,这样线程 T1 和线程 T2 生产下一条数据的时候就可以继续使用这个计数器了。

    使用CyclicBarrier实现

    
    // 订单队列
    Vector<P> pos;
    // 派送单队列
    Vector<D> dos;
    // 执行回调的线程池 
    Executor executor = Executors.newFixedThreadPool(1);
    final CyclicBarrier barrier = new CyclicBarrier(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();
    }
    

    创建一个计数器初始值为 2 的 CyclicBarrier,创建 CyclicBarrier 的时候,传入了一个回调函数,当计数器减到 0 的时候,会调用这个回调函数。

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

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

    五、总结

    CountDownLatch 和 CyclicBarrier 是 Java 并发包提供的两个非常易用的线程同步工具类:

    • CountDownLatch 主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;
    • CountDownLatch 的计数器不能循环利用的,一旦计数器减到 0,再有线程调用 await(),该线程会直接通过。
    • CyclicBarrier 是一组线程之间互相等待,更像是几个驴友之间不离不弃。
    • CyclicBarrier 的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到 0 会自动重置到设置的初始值。CyclicBarrier 还可以设置回调函数。

    相关文章

      网友评论

        本文标题:CountDownLatch和CyclicBarrier

        本文链接:https://www.haomeiwen.com/subject/ajwfldtx.html