BUAA-OO-第二单元总结

北航计算机学院面向对象第单元总结

一、第一次作业总结

  • UML类关系图

 

  • UML类协作图

  • 同步块的设置和锁的选择

第一次作业架构简单,采用两级生产者-消费者模式,其一为Input-Dispatcher组合,其二为Dispatcher-Elevator组合。因此涉及到的共享数据只有两个组合分别共用的托盘,只需在读写两托盘的数据时加锁即可,具体如下:

 1 synchronized (mainPeopleQueue) {
 2     if (mainPeopleQueue.isOver() && mainPeopleQueue.getRequests().isEmpty()) {
 3         // 如果上级生产者线程停止,并且托盘中没有数据,即可退出循环,准备终止线程
 4         break;
 5     }
 6     if (mainPeopleQueue.getRequests().isEmpty()) {
 7         try {
 8             mainPeopleQueue.wait();
 9             continue;
10         } catch (InterruptedException e) { e.printStackTrace(); }
11     }
12     // 读取托盘数据后清空
13     requests = new ArrayList<>(mainPeopleQueue.getRequests());
14     mainPeopleQueue.getRequests().clear();
15     mainPeopleQueue.notifyAll();
16 }      

此外作业中有提到官方提供的输出包是线程不安全的,需要同学们自行保证其被多个线程调用时输出符合逻辑。因此在电梯使用输出包进行输出时,也需要使用同步代码块以保证线程安全,使用的锁为Elevator类创建的一个静态Object类。具体如下:

1 synchronized (KEY) {
2     TimableOutput.println("CLOSE-" + toString());
3 }

 

  • 调度器设计与线程交互

    • 调度器设计

本次作业没有涉及到多电梯问题,因此调度器只需根据需求所处的楼座分配到对应的电梯即可。

    • 线程交互

Input线程将读取到的需求添加到托盘,Dispatcher线程从该托盘中取出需求并存入二级生产者-消费者的托盘中,最后再由不同楼座的Elevator线程取出对应楼座的需求并完成需求即可。Input线程结束后即向Dispatcher发出结束信号,Dispatcher确认托盘内没有需求后结束线程,并向Elevator发出结束信号,Elevator完成所有需求后结束线程。

  • 分析自己程序的bug

本次作业在强测和互测中均未被发现bug。

  • 分析自己发现别人程序bug所采用的策略

总体上还是采用写评测机的方法,对同学代码进行大量随机数据测试。但和第一单元作业不同,第二单元作业涉及到多线程运行,这种方法对多线程程序进行评测确实存在一定的局限性,难以复现一些bug,即使发现存在问题,也需要阅读相关代码才能知道错误。向助教请教后,应该使用多个线程运行这些程序,对其进行线程压力测试,这样比较容易复现出某些线程相关的问题。

第一次作业中发现的唯一一个bug是没有对官方的输出包进行安全处理。

二、第二次作业总结

  • UML类关系图

  • UML类协作图

  • 同步块的设置和锁的选择

第二次作业主要增加了横向电梯,考虑到第三次作业需要增加换乘功能,第二次作业中选择了两级调度器的设计,即由一级调度器将需求初步分配给横向/纵向调度器,再由二级调度器根据对应的调度策略分配给电梯。

因此共有五处线程之间的交互,需要对共享对象进行读写,分别为:

    • Input和一级调度器之间  
    • 一级调度器和横向调度器之间  
    • 一级调度器和纵向调度器之间  
    • 横向调度器和横向电梯之间  
    • 纵向调度器和纵向电梯之间

 

使用的锁即为对应的共享对象,同步代码块的形式与第一次作业类似,此处仅给出横向电梯对共享数据读写时的代码。

 1 synchronized (mainQueue) {
 2     if (mainQueue.isOver() && mainQueue.isEmpty() && isEmpty()) {
 3         // 横向调度器停止,并且托盘为空,并且电梯内没有需求,即可跳出循环,准备结束线程
 4         break;
 5     }
 6     if (mainQueue.isEmpty() && isEmpty()) {
 7         try {
 8             mainQueue.wait();
 9             continue;
10         } catch (InterruptedException e) {
11             e.printStackTrace();
12         }
13     }
14     dispatch();
15     mainQueue.getRequests().clear();
16 }

 

  • 调度器设计与线程交互

    • 调度器设计

采用二级调度器的形式,一级调度器根据需求方向分配给不同的二级调度器,二级调度器再根据各自的调度策略选择分配给对应的电梯。具体调度策略如下。

纵向调度器(优先级由高至低)

  1. 判断添加该请求后,电梯运行全程是否会超载。将不会超载的电梯,添加至候选列表。如所有电梯均会超载,则返回所有电梯。
  2. 对上述候选列表中的电梯进行判断,判断该需求方向是否与电梯当前运行方向一致,电梯是否在不转向的前提下能到达需求的起始位置(即判断需求是否满足可搭乘当前电梯)。若不是,将电梯移出候选列表。若所有电梯均不满足,则返回原始候选列表。
  3. 将需求分配给候选列表中任务最少的电梯。

横向调度器(优先级由高至低)

  1. 判断该需求是否在电梯运行前方两个单位距离以内。将满足条件电梯添加至候选列表。
  2. 对候选列表中的电梯进行判断,需求的方向与电梯当前运行方向是否一致。
  3. 将需求分配给候选列表中任务最少的电梯。
    • 线程交互

Input线程将读取到的需求添加到托盘,Dispatcher线程从该托盘中取出需求并分配给对应的二级调度器,二级调度器根据调度策略将其需求分配给对应的电梯线程即可。Input线程结束后即向一级调度器发出结束信号,一级调度器确认托盘内没有需求后结束线程,并向二级调度器发出结束信号,二级调度器确认托盘内没有需求后结束线程,并向对应运行方向电梯发出结束信号,电梯完成所有需求后结束线程。

  • 分析自己程序的bug

本次作业在强测和互测中被发现一个bug,横向调度器在某些情况下会连续开两次门。这个bug和作业整体的架构、逻辑无关,单纯是因为自己的疏忽大意造成的。尽管这个bug出现归根结底是源自自己的疏忽,但还是不得不想吐槽一下checkstyle的要求。

起初是因为checkstyle报错表示某个方法超过60行,所以我将部分语句封装成了方法。结果checkstyle又报错方法内部不能更改传入的实参值。于是在为修正checkstyle后的一波改动后,终于搞忘了在方法外部手动更改标记门是否打开状态的一个变量,一波操作让我强测wa了四个点。(欲哭无泪)

checkstyle的初衷当然是好的,为了让我们习惯良好的编程风格,但对于其中的一些规则是否合适我保持怀疑态度。比如强制要求属性权限为private,这导致涉及到父类、子类的设计时,需要频繁地使用getter和setter方法,但protected这个权限符不就是为此而设计的吗。包括本次的不能修改方法实参的值等,我也不能理解为什么不能这么做0.0

跑了个小题,希望不要被助教gank. (qaq

  • 分析自己发现别人程序bug所采用的策略

所用策略与第一次作业基本相仿,但尝试去阅读了一些同学的代码,并针对阅读时发现的问题人为构造一些数据。

发现的Bug:

    • 线程终止混乱,或提前终止,或无法终止
    • 没有对输出增加锁
    • 分多次进入电梯,结果需求队列没有及时更新,导致某乘客多次进入电梯

三、第三次作业总结

  • UML类关系图

  • UML类协作图

  • 同步块的设置和锁的选择

第三次作业主要增加了需要换乘的需求,因此采用了流水线架构,具体表现在在第二次作业的基础上,增加了电梯向一级调度器回传请求的功能。故相较于第二次作业,增加了电梯与一级调度器之间的共享对象(实际上是Input、Dispatcher、Elevator三个线程共用一个共享对象)。这里给出电梯回传需求使用的同步代码块。

 

1 synchronized (backQueue) {
2     backQueue.addRequest(new NewPersonRequest(curFloor, request.getRealToFloor(),
3             name, request.getRealToBuilding(),request.getPersonId(),
4             request.getRealToFloor(),request.getRealToBuilding()));// 更新需求的起始点,并回传需求
5     backQueue.notifyAll(); //及时唤醒一级调度器,以免input线程结束后Dispatcher持续wait
6 }

 

  • 调度器设计与线程交互

    • 调度器设计

由于本次作业中为电梯增加了很多参数,如容量、运行速度等,通过调度策略来实现优化绝大部分场景的难度过高,因此二级调度器相对第二次作业并没有增加额外的调度条件。

而对于换乘而言,考虑到动态路径规划难度相对较高,同时多次换乘的效果不一定优于一个路径相对较长,但换乘次数少的的路线。综合考虑难度和效果,本次作业选择在需求初次进入一级调度器时就完成路径规划(即静态拆分需求)。因此需要为一级调度器添加一些决定需求进入横向还是纵向调度器的调度策略。具体如下。

  1. 检查起点层和终点层是否存在联通两楼座的电梯。若有选择运行速度最快的电梯。
  2. 检查起点层和终点层之间是否存在联通两楼座的电梯。若有选择运行速度最快的电梯。
  3. 检查起点层和终点层两端,距起点层或终点层最近的楼层否存在联通两楼座的电梯。若有选择运行速度最快的电梯。
    • 线程交互

Input线程将读取到的需求添加到托盘,Dispatcher线程从该托盘中取出需求并分配给对应的二级调度器,二级调度器根据调度策略将其需求分配给对应的电梯线程即可。Input线程结束后即向一级调度器发出结束信号,一级调度器确认任务计数器counter值为0时结束线程,并向二级调度器发出结束信号,二级调度器确认托盘内没有需求后结束线程,并向对应运行方向电梯发出结束信号,电梯完成所有需求后结束线程。

  • 分析自己程序的bug

本次作业在互测中被发现一个bug,是因为对线程终止的判断逻辑写得过于粗糙和混乱,虽然本地测验没有线程出问题的情况,但在评测机的高强度打击下还是寄了。

原有的判定逻辑修复bug过于困难,最后还是选择使用任务计数器记录目前未完成的需求量,与课上实验中流水线架构类似,直到Input线程结束并且counter值为0时再终止Dispatcher线程,其余线程终止条件与第二次作业一致。

 

  • 分析自己发现别人程序的bug

本次互测共发现以下bug:

  1. 经过调度策略筛选后,候选的电梯列表为空,但仍然取Arraylist.get(0),于是出现了空指针的错误。
  2. 线程终止条件考虑不周,最后出现线程无法终止的情况。
  3. 线程终止条件考虑不周,最后出现线程提前终止的情况。

四、总结与体会

本单元作业是初次接触多线程的程序设计,尽管对线程间交互、同步的认识还远远不够,但本单元作业确确实实加深了我对线程互斥、线程同步等问题的理解。恰好OS也在将线程方面的知识,无疑让我的OS理论课体验好了不少。但是从最后一次作业中出现的线程终止混乱的问题也能看出,我对于多线程程序的设计还很不成熟,希望以后能找到一些训练场景,加强工程化设计的能力。

本次作业也让我更加体会到一个逻辑清晰的架构和适合的设计模式对代码开发的重要性。一个清晰的架构能够帮助程序员快速定位bug所处的模块,也能够在尽量减少原代码变动的基础上迭代更新功能。尤其是在多线程的程序里,本来多线程程序就已经很难调试了,如果架构混乱,逻辑不清,想定位线程问题的Bug更是难上加难。而另一方面,正是学习到消费者-生产者模式、流水线模式后,我才能在设计初期根据这些模式的经典样例,找到设计思路,并顺着这个线索延伸。否则很容易像第一单元第一次作业那样一点思路都没有,面对复杂的问题手足无措。

三次作业我都写了评测机,尽管逻辑上来说能够评判绝大部分会出现的问题,但对线程问题还是不能很好地进行复现。也正是这个原因导致我轻信了自己代码的正确性,最后在互测中被五花八门的方式找到现成问题。在咨询助教后得知,测试线程安全问题应该并发跑点,对程序的线程进行压力测试,在高并发下会比较容易测出线程安全问题。

 

希望同学们剩下的作业都能顺利通过~

 

 

 

 

posted @ 2022-04-27 20:07  Kazeya_y  阅读(18)  评论(2编辑  收藏  举报