OO第二单元作业总结——多线程电梯调度


一、同步块的设置与锁的选择

  • 第一次作业

    • 由于第一次作业的需求较为简单,只有一部电梯,因此仅有电梯线程(包括它的状态类和策略类)与输入线程之间的同步关系。

    • 电梯线程与输入线程的共享对象是等待队列WaitingQueue,InputThread-Elevator构成一对生产者-消费者的关系。

    • 锁的选择上,第一次作业只对共享队列WaitingQueue加锁,保证多个线程对等待队列的修改是互斥的。

    • 同步块的设置上,一方面,当输入线程有请求到达时,需要向共享对象WaitingQueue添加新请求,因此对添加新请求的语句块加锁[synchronize(WaitingQueue)];另一方面,当电梯线程开门进人时,需要遍历WaitingQueue的所有请求,将所有符合进电梯要求的请求提取出来加入到电梯内置队列LiftingQueue中,因此对电梯线程中的遍历WaitingQueue的语句加锁,保证了线程安全。


  • 第二次作业

    • 锁的选择上,本次作业除了对所有涉及到修改共享等待队列的语句块加锁以外,还对调度器类加了锁。对调度器加锁是出于架构的考虑:因为我的新请求到来时会立刻将其分配到相应的电梯,为了保证“接收请求—分配请求”这一操作的原子性,我的这种加锁方式保证了一次分配一个请求,不会对整体的性能造成损失。

    • 同步块的设置上,本次作业由于架构的变化,线程与共享对象的关系比第一次作业稍复杂:

      • 输入线程与调度器线程共用一个总队列GeneralQueue,输入线程向其中添加新请求,调度器线程从其中接收新请求,两个线程仅在此处会出现同步问题,因此我对输入线程添加与调度器接收的语句块加锁
      • 调度器线程与每个电梯线程都共用一个等待队列WaitingQueue,调度器负责分发,而电梯线程负责接收,同样地,我只在涉及到这两个操作的语句块上加锁

  • 第三次作业

    • 第三次作业增加了不同类型的电梯,设置了一个总调度器和三个子调度器(分别管理三类电梯)。

    • 锁的选择上,第三次作业和第二次作业基本相同,对所有涉及到修改共享等待队列的语句块加锁以外,还对所有的调度器类加了锁。对调度器加上类锁同样是为了保证“接收请求—分配请求”的原子性,保证一个请求到达时,能从总调度器开始,经过子调度器逐层分配到具体的电梯,一步到位。这种加锁方式同样不会对性能造成很大的损失,保证了请求分配的有序性,同时由于我的调度是从高层向底层线性逐级分配,没有锁相互嵌套的情况,因此出现死锁的风险也比较低。

    • 同步块的设置上,本次作业由于架构进一步改变,线程与共享对象的关系比第二次作业稍复杂:

      • 输入线程与总调度器线程共用一个总队列GeneralQueue,输入线程向其中添加新请求,总调度器线程从其中接收新请求,两个线程仅在此处会出现同步问题,因此我对输入线程添加与调度器接收的语句块加锁
      • 总调度器与几个子调度器分别共用等待队列,总调度器负责分发,子调度器负责接收并进行下一步的分配,两个线程仅会在此处出现同步问题,因此我对涉及到总调度器分发请求和子调度器接收请求的语句块加锁。
      • 子调度器与电梯线程之间的同步问题与前两次作业相同,此处不再赘述。

二、调度器设计


​ 在这个部分我将重点阐述三次作业调度器的层次设计,以及在新请求到达时调度器是如何将其顺利分配到具体的电梯中的。此外,后两次作业中的线程结束都是通过调度器来实现的,我将简单讲述如何在多级调度器的架构中确保所有线程能准时结束。


  • 第一次作业

    • 第一次作业架构如下图所示:
  • 第一次作业是我第一次接触到多线程编程,课程组给的提示是最好要有一个调度器线程。于是我第一版的代码是有输入线程、电梯线程和调度器线程三个线程的,但是由于线程安全、锁、同步块等概念理解得不是很清楚,因此这一版的代码被我弄得一团糟,到周日早上我决定把调度器线程删掉重新写,讲所有的实现逻辑都集中到了电梯线程里,顺利通过了第一次的公测和互测。现在反思起来,我第一版代码失败的原因有两个:
    • 对多线程编程不熟悉,锁的选择和同步块的设置不合理,导致出现了许多死锁以及线程安全问题。
    • 对调度器的职能界定不清楚,认为调度器应该管电梯出人和进人,事实上调度器只需要负责请求的分配就可以了。
  • 线程结束的问题上,我利用了两个线程与共享对象的紧密联系,通过设置wait-notify语句来实现对线程进行及时地终止。如上图所示,当输入线程读入NULL时,会给共享对象一个结束标志。当共享对象结束标志置位时,如果电梯仍在运行,则运送完所有乘客后会自动跳出循环,结束线程;否则WaitingQueue会notify电梯线程,对是否应该结束进行一次判断,结束电梯线程。实现部分代码如下:
  • InputThread部分:
 synchronized (waitingQueue) {  //读入新请求request
                if (request == null) {
                    waitingQueue.over();  //结束标志置位
                    waitingQueue.notifyAll();
                    break;
                } else {
                    //Add the new Request to the WaitingQueue.
                }
            }
  • Elevator部分:
 while (true) {
            if (waitingQueue.noWaiting()
                    && waitingQueue.isEnd()
                    && liftingQueue.isEmpty()) {
                break;		
            }
     		synchronized (elevator.getWaitingQueue()) {
                try {
                    elevator.getWaitingQueue().wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //代码主体
        }

  • 第二次作业

    • 第二次作业增加了多部电梯的需求,架构如下图所示:
  • 第二次作业由于有多部电梯,因此加上了调度器Scheduler类,负责请求的分配电梯自身的开门、关门和移动的逻辑直接复用第一次作业

  • 在增加调度器之前,我考虑一个问题:调度器本身有没有必要设计成一个线程?如果调度器作为一个线程而存在,那必然会使程序的线程交互更复杂,对于锁和同步块的设置也要考虑得更多,容易出现bug。如果只将调度器作为一个普通的类,虽然程序复杂度下降了,但是可能会影响后续的可扩展性,调度器的调度方式会被局限。综合考虑两种方案的优缺点后,最终我决定不将调度器设计成一个线程,仅作为一个普通的类而存在。如此一来,我的请求分配方式也就随之确定了:当有请求到达时立即将其分配到相应的电梯中(这是最简便的一种方式,当然还可以利用缓冲队列进行其他更多的设计)。

  • 可以结合上面的图示了解我的调度器的工作方式:首先输入线程向总队列中添加新请求,此时直接调用调度器的dispatch()方法对新请求进行分配,将其加入到对应的等待队列WaitingQueue中,这一过程是加锁的,保证了分配操作的原子性。电梯线程接收到新请求后开始运行,当等待队列没有请求且内置队列也没有待完成的请求时,电梯线程会交出等待队列的锁,进入等待状态,直到新请求到达再次被唤醒。

  • 线程结束的处理上,既然结构是层次化的,那就用层次化的方式使线程结束。输入线程结束工作后,会将输入结束的信号逐级传递给电梯。电梯线程接收到结束信号后,处理完手头上待完成的所有请求后即可退出线程。


  • 第三次作业

    • 第三次作业的架构如下图所示:
  • 第三次作业为了更好地实现调度功能,我设计了一个总调度器和三个子调度器,子调度器分别管理三种类型的电梯。子调度器直接复用第二次作业的Scheduler类,保证了同种电梯调度的正确性
  • 第三次作业依然采用分层调度的架构,减轻单个调度器的代码压力,每个调度器只要“各司其职”就行了。总调度器掌握三种类型电梯的大致情况,据此将请求分别调度给A、B或C类型的电梯;子调度器掌握该种类型电梯的大致情况,据此将请求调度给它所管辖的几部电梯,保证请求在逐层调度过程中的有序性。
  • 对于换乘的方式,我采用“调度倒置”的方式。当电梯无法将某个请求送达时,将新的请求直接送给总调度器(单例模式yyds!!!)。这种换乘的新请求也是总调度器的请求来源之一,与输入线程增加的请求本质上是一样的;此外,在总调度器接收请求时,还传入一个“输入来源”参数,保证了换乘时不会再分配到相同类型的电梯而造成死循环。
  • 对于线程结束的问题,与第二次作业的实现方式大致相同:逐层传递结束信号。但是唯一不同的是,第三次作业要统计是否还有未完成的需要换乘的请求,只有当输入结束、无未完成的换乘请求时,才会发出结束标志信号。如果不加上这一条件,会出现换乘电梯提前下班的尴尬情况,出现错误。

  • 总结

    • 第一次作业需求比较简单,调度器这个角色作用不太明显。
    • 第二次作业涉及到多部电梯,需要调度器来平衡各个请求的去向。
    • 第三次作业涉及到多种类型的电梯,需要调度器来平衡各种类型电梯的吞吐量,使电梯利用率尽可能达到最高。
    • 与此同时,调度器这个角色还具有隔离输入逻辑和业务逻辑的功能,处理输入线程交过来的新请求,同时将电梯具体的运行逻辑完全交给电梯的本身,增强了程序的可扩展性。

三、第三次作业架构设计的可扩展性

  • UML类图



  • 类之间的创建关系

    • 在主线程MainClass中,创建了输入线程InputThread及其管理的总队列WaitingQueue。

    • 所有的调度器都通过单例模式创建,因此不在任何其他任何一个类中创建,不作为任何一个类的属性。

    • 在输入线程InputThread中创建电梯Elevator类,是因为需要在通过输入模式来确定电梯的运行模式;同时增加电梯时,也需要在输入线程内创建电梯线程。

    • 在电梯线程中创建了内置运行队列LiftingQueue和等待队列WaitingQueue。

    • 运用状态模式建模,将电梯的状态抽象成Up、Down和Stop三个类,分别实现Status抽象接口,在电梯中创建出这三个状态类。

    • 运用策略模式建模,将电梯的策略分为Random、Morning和Night三个策略类,分别实现Mode抽象接口,在电梯中创建出这三个策略类。

  • 从UML类图分析第三次作业的可扩展性

    • 第三次作业存在一定的设计缺点:没有运用工厂模式创建电梯类和调度器类,将创建电梯的逻辑直接暴露在输入线程中(氦qwq还不是因为图省事)。为之后的扩展留下了隐患,如果电梯类型个数急剧增加,就会使我的代码逻辑更复杂。因此我认为现有的代码应该用工厂模式封装创建电梯的过程
    • 从类图中可以看出第三次作业的设计具有非常强的层次性,分层次的调度使每个调度器的代码压力减小,使调度器的可扩展性大大提高。从第二次到第三次作业就经过了一次架构上的改变,但是并没有修改第二次作业中已经实现的调度器逻辑,只是在第二次作业的基础上加上了总调度器,代码复用性强。因此仅从功能实现方面来看,第三次作业的可扩展性是非常强的
    • 但是从性能设计的角度来看,第三次作业对于调度算法的改变的扩展性并不是非常强。简言之,就是要在原有的代码上实现另一种调度算法,扩展起来是有一定难度的。原因在于虽然代码已经实现了分层调度,但是层与层的调度并没有设置缓冲区,所有的调度都是一步到位,这样虽然很好地避免了一些线程安全问题,但是也为以后的算法扩展埋下了一定的隐患。

  • UML协作图

​ 本次作业没有把调度器作为线程,因此线程之间的交互比较简单粗暴(



  • UML时序逻辑图分析:

    • 在主线程中仅仅创建了输入线程。

    • 在输入线程中创建了电梯线程,根据输入设置电梯的模式。同时,电梯在创建以后需要在对应的调度器进行“注册登记”,具体的代码逻辑是:

          public synchronized void register(int id, WaitingQueue waitingQueue) {
              queueHashMap.put(id, waitingQueue);
          }
      

      就是将电梯的id和它对应的等待队列通过register方法“告知”对应的调度器,以便调度器后续的调度。

    • 在处理线程结束时,首先是告知总调度器,再由从调度器通过over()和notifyAllWaiting()方法告知所有的子调度器,该收工啦。具体的代码实现是:

      总调度器:

          public void over() {
              Ascheduler.getScheduler().over();
              Bscheduler.getScheduler().over();
              Cscheduler.getScheduler().over();
          }
          
          public void notifyAllWaiting() {
              Ascheduler.getScheduler().notifyAllWaiting();
              Bscheduler.getScheduler().notifyAllWaiting();
              Cscheduler.getScheduler().notifyAllWaiting();
          }
      

      子调度器:

          public void over() {
              for (int i : queueHashMap.keySet()) {
                  synchronized (queueHashMap.get(i)) {
                      queueHashMap.get(i).over();
                  }
              }
          }
          
              public void notifyAllWaiting() {
              for (int i : queueHashMap.keySet()) {
                  synchronized (queueHashMap.get(i)) {
                      queueHashMap.get(i).notifyAll();
                  }
              }
          }
      
  • 从UML时序图的角度来分析第三次作业的可扩展性

    • 第三次作业的输入线程过于臃肿,调用过多,扩展性不强。应该使用工厂模式将必要的创建和初始化逻辑封装在一个类中,避免出现像这样代码混乱不好扩展的情况。
    • 电梯线程的运行模式较为固定且内置运行逻辑较为复杂,如果电梯的运行逻辑需要改动,则牵涉的模块会比较多。对于可能增加的奇奇怪怪的电梯运行方式而言,第三次作业的可扩展性并不强。

  • 总结

    • 第三次作业对于调度器和电梯之间的调度协作具有很强的扩展性,这得益于我三次作业的层次化设计。但是由于电梯运行方式较为固定,调度方式也较为单一,对于可能新增加的奇奇怪怪的运行需求或调度算法需求,第三次作业的可扩展性并不强——原因在于我将电梯的运行逻辑和各种判断逻辑都混放在了一个策略类里,代码量大且非常复杂,扩展起来并不容易。归根结底还是我面向对象的设计思想还不够强,在设计的时候为了图省事而把电梯的运行模式以一种面向过程的形式给出,导致代码不好扩展,这是我以后设计时需要注意的地方。

四、自己程序的bug

  • 本次电梯系列三次作业在公测和互测中均没有出现bug,下面分析我三次作业在课下测到的bug。


  • 第一次作业

    • 由于第一次接触多线程编程,一开始调度器线程与电梯线程弄得一团糟,出现了大量线程安全问题(bug太多来不及记下来就直接重构了)。重构之后没有出现很大的bug,都是一些笔误造成的错误。


  • 第二次作业

    • 电梯运行逻辑直接复用第一次作业,只增加了调度器。由于调度器也不是一个单独的线程,调度算法也非常简单且无脑,第二次作业非常轻松,只出现了线程无法结束的bug,后来经过不断的print找到了根结所在:在输入结束后没有及时notify所有的电梯线程,有些线程提前进入空闲状态就会一直等待,不会结束


  • 第三次作业

    • 第三次作业的子调度器直接复用第二次作业,只增加了总调度器的代码(调度算法同样非常简单且无脑,就是平衡分配)。因此第三次作业除了笔误以外,只出现了电梯线程提前结束线程无法结束的bug(不是死锁就是没唤醒)。(结束线程永远滴鬼qwq)

    • 电梯线程提前结束:是指还有换乘请求的情况下,要换乘到的电梯由于输入结束且处于空闲状态而提前结束了,导致请求没有执行完(因为电梯已经下班了,人就被困在电梯里)。为了解决这个问题,我设置了一个统计换乘请求个数的属性,当输入结束、没有换乘且处于空闲状态以后,电梯就可以光荣退休啦!

    • 线程无法结束:还是第二次作业的老问题,因为多加了一层调度器,所以也要相应的多加一次notify,否则会出现像第二次作业一样的bug。


  • 总结

    • 由于本次作业架构比较简单,线程交互较简单,层次化设计明显,因此没有出现死锁的bug和其他线程安全问题,唯一要注意的是在层次化的设计中,要搞清楚共享对象之间的wait-notify的关系,以免出现线程无法结束的bug。

五、发现别人程序bug所采用的策略

  • 主要采用手动构造极端数据的方式来hack同屋人的代码,但是发现的都是一些无法在互测中hack的bug(例如作业1的RTLE,超过tmax但不超过210秒)。
  • 以第一次作业为例,在我构造普通数据的过程中发现了一个人的电梯运行得特别慢。细看代码和他的输出才发现,他的电梯不符合ALS电梯设计的基本要求,即在上去接人的过程中,遇到下行的请求也会捎带进来,而且会先执行这个下行的请求。于是我一高兴就构造了一个极端数据:
[1.0]Random
[1.0]1-FROM-1-TO-20
[1.0]2-FROM-15-TO-1
[1.0]3-FROM-16-TO-1
[1.0]4-FROM-17-TO-1
[1.0]5-FROM-18-TO-1
[1.0]6-FROM-19-TO-1
  • 本地一测确实也超过了ALS的最大时间了,比傻瓜电梯还傻瓜。结果一提交,并没有hack成功,才意识到互测的最大时间是210s,白高兴一场了,但是也体验到一把手动构造hack别人的乐趣,没有依靠程序自动生成。

  • 本次电梯涉及到多线程的线程安全问题,有些bug通过随机生成确实很难复现出来,但是如果抓住程序的某个弱点,根据这个弱点有针对性地手动构造极端数据,说不定比一味的自动数据狂hack收获要大些。

  • 本单元的测试策略与第一单元的测试策略差异非常大,由于第一单元的表达式求导输入比较静态,因此只需要从输入本身下功夫,有针对性地自动生成数据效果比较明显。而本单元的输入是实时交互的,输入中多了一个时间维度,而往往线程安全问题的bug都是与时间相关联的,因此要用程序自动生成数据效果并不是太明显,需要阅读代码本身找到其潜在的线程安全问题,再据此手动构造极端数据或者用程序针对于此生成极端数据,取得的效果会更明显。


六、心得体会

  • 线程安全!线程安全!线程安全!

    • 多线程单元最敏感的问题就是线程安全问题。在经历了第一次捣鼓线程弄得一团糟以后,我才意识到线程之间的交互是一门大学问,锁和同步块不能随便加,也不能少加。要考虑共享对象本身可能会出现的线程安全问题,经过反复分析后再在合适的位置加上synchronize同步块。

    • 在多线程编程中每new出一个对象都要扪心自问:这个对象被哪几个类共享?这个对象会被几个线程同时修改吗?这个类在什么时候需要加上锁?这是我在三次作业编程过程中反复问自己的几个问题,可见线程安全的重要性。只有在编程中把每个对象的线程安全的细节问题考虑得更加周全,才不会在程序完成以后疯狂de线程安全的bug(这样的bug往往不好复现)。


  • ”整体性能的优化不单纯取决于算法的提高“

    • 这是荣老师在电梯系列作业总结课上说的一句话,我听到的时候深有同感,于是立马就把它记在我手机的备忘录里。

    • 这次我的电梯调度算法非常佛系(也可以说没有算法):简单来说就是平衡各个电梯的吞吐量,尽量让每个电梯载的人数达到相对的平衡(最后出来的性能也意外地好)。但是在这之前,我也尝试过模拟电梯的做法,就是在分配每个请求之前都把各种分配可能模拟出来,得出一个局部最优解,就把请求分给那个可以使局部达到最优的电梯。结果出来使我懊恼,我做了这么多,性能非但没有提升,反而在一定程度上拖慢了我电梯的运行;后来我仔细琢磨原因:每一步的局部最优解往往得不到全局最优解,因为请求的到来是随机的,因此在后面的请求到来之前,前面的局部最优解在一定程度上是没有什么参考价值的。因此我果断把写了一晚上的模拟电梯扔掉,回归原来的佛系平衡分配方法,加了一些小小的优化,最终出来的性能结果也十分可观。

    • 我的这种平衡算法在第三次作业的优势更加明显。由于第三次作业的性能分加上了乘客的总等待时间(我感觉这个占性能的大头),简单地凭借模拟电梯很难算出所有乘客的总等待时间(以我个人的能力),更别说求局部最优解了。因此我在第三次作业也是求取相对平衡,根据总载客量和移动速度确定三种类型电梯的吞吐量之比,据此分配到来的请求,保证三种类型的电梯都能被充分调度,这样在直观上就能减少乘客的等待时间,结果出来亦是如此。

    • 因此本次电梯作业的最大感想是:如果没有一个更好的架构和更高的视角来看待这个问题,与其抠破头皮写一个很复杂的算法,不如假设你完全不懂算法,你作为决策者会如何使电梯性能尽可能地高,换个视角考虑问题或许能有不一样的收获


  • “好的设计往往都是make sense的”

    • 这也是荣老师课上强调的一句话,经过这次电梯作业,我深以为然。

    • 就拿我第一次作业来说,在我第一次接触多线程,就兴冲冲地写了一堆调度器线程和电梯线程的交互,结果出锅了。原因是我没有回归实际考虑调度器的实际功用,调度器根本不关心电梯是怎么跑起来的,不关心电梯什么时候进人出人,只关心请求会被哪个电梯处理(也就是分配),仅此而已。因此我的调度器线程严重地“越俎代庖”了,与电梯的职能出现了交叉重叠,完全不make sense,代码一团糟,导致ddl最后一天极限重构

    • 在设计第三次作业之前,就听到荣老师提示要分层调度。当时不以为意,但当我真正去下手写的时候,才发现如果仅由一个调度器去调度所有的电梯,它的调度逻辑一定是非常复杂的,而且一定不好扩展。想到现实中的调度系统大都是“分而治之”,这样的调度不仅井然有序,而且只要每个调度器“各司其职”,就不会出锅(而且错误也很好定位);现实中无论是公司的运作或是工程的调度,都采用这种分层调度的方法:这说明分层本身就是make sense的,合理简洁的层次划分一定会使整个系统的运作更加有序。想到这里,我决定将第三次作业的架构改为分层调度,果然出现的bug也非常少。

    • 对于第三次作业的换乘问题,要不要换乘是大多数同学比较纠结的问题。但是我们不妨这样考虑,设身处地地想,假设你是其中的一个乘客,想必也不会忍受频繁而不必要的换乘,更何况此次作业的性能分加上了乘客的感受(指等待时间),这种将心比心的设想就更有必要了。因此我在第三次作业遵循“非必要不换乘”的原则,在满足平衡原则分配请求的前提下,除非电梯无法将人运到,否则拒不换乘。从情理上来讲也是合理的:换乘一次浪费的是整个电梯里面的乘客的时间,这种时间是没有办法通过简单的电梯速度来弥补的

    • 因此我的最大感受是:在面向对象的设计中,抓住设计的感觉同样非常重要。在严谨的理论知识之外,还需要更多的生活经验和设计经验去支持我的设计,这是在课堂上无法习得的。


  • 各种设计模式的尝试是十分有益的

    • 单例模式:单例模式在这次电梯系列作业中简直帮了我大忙,它省去了我将调度器归入私有属性的麻烦。单例模式不仅在调用的时候非常方便,它最大的好处还在于它的唯一性,不会出现各种new乱飞的情况,大大简化了代码。
    • 状态模式:状态模式在这次电梯系列作业中也帮我减少了代码量,省去了许多if分支和switch分支语句。将状态抽象成一个类,将状态转移的逻辑自然地融入其中,在很大程度上美化了我的代码,也增强了代码的可扩展性。
    • 策略模式:针对于电梯的三种模式,应该采用策略模式建模,这样同样可以省去非常多的switch分支语句,且如果电梯的运行模式有所增加的话,直接新建一个策略类就可以,满足面向对象设计中的开闭原则
    • 观察者模式:本次作业的调度器就是电梯的观察者,通过电梯定时的update使调度器掌握电梯大致的情况,并以此作为调度的依据。
    • 工厂模式:本次作业唯一遗憾的是没有使用工厂模式建模(图省事)。倘若电梯的类型再增加的话,将新增电梯的代码暴露再输入线程中是非常不优雅的,如果用一个工厂类将新建电梯的过程完全包装起来,就可以使代码有更好的可扩展性。

  • 路漫漫其修远兮

    • 经过这次电梯作业,我成长了不少:对多线程编程更加了解,尝试了许多设计模式,感悟到了书本上写的设计原则的好处。面向对象是一种设计思想,是我代码设计路上需要不断思考、不断完善的——路漫漫其修远兮,吾将上下而求索。
posted @ 2021-04-26 09:31  罗宇轩  阅读(237)  评论(1)    收藏  举报