OO第二单元-多线程

考虑到三次作业的迭代性,我将详细的文件结构度量分析UML协作图等都放在了task3的部分里,前两次就简单略过了。

task1

初识多线程时的个人思考

线程涉及

  • 获取输入的线程:new Thread(new MyInput(sceduler))
  • 调度器线程:new Thread(scheduler)
  • 每个电梯控制器的线程:new Thread(ec)
    • 在开关门时,主线程中又新建了线程:负责电梯的开关门
    • 主线程负责控制:人的进出

提醒自己:为什么有的方法/代码块必须要加synchronized

同步(即带有synchronized的)代码块/方法,其需要设置为同步,是因为,该方法/代码块所操作的数据有可能被不同的线程在同时操作。

如果不设置为同步,那就:

  • 无法使用wait、notify,也就不得不让CPU轮询。
  • 还可能引发读写问题、输出问题等。

想清楚这个,我们就可以:

  • 只对必要的方法/代码块加同步限制
  • 在同步方法/代码块的必要位置添加notify/noyifyAll

而非盲目的随意添加。


性能优化点

  • 在电梯没有运送任务的时候,让它移动到程序手动设定的默认楼层。(例如,这10层有请求的频率相同,那么在没有客户来的时候,让电梯静静地待在1层不如静静地待在5层好)
    • 当没有新任务时,此时无需让它在移动到默认楼层,线程直接结束。

注意点

  • 无需考虑关门前是否所有想要进/出的人都已经完成了进/出,因为开关门的期间很长(0.4s),而capacity才为6人(又不是600000),肯定能够保证所有人在0.4s内都完成了进出

  • 注:之后,在和同学讨论的过程中,我发现同学们大多都采用:开门 -> sleep0.4s -> 乘客进出 -> 关门,而不是像我一样一个进程开关门、一个进程让乘客进出。由于CPU在程序中占用的时间比例很小,所以前者的方法也并不会带来很大的性能损失,反而由于没有引入新的进程,会让整个算法更加清晰明了。

    有趣的是,我在本次作业的bug也与这个点有关。详见后文。

UML类图

image-20220502161208719

度量分析

image-20220502141139112

bug分析

bug

本次强测、互测错了好几个点,都是因为电梯超速。我一开始百思不得其解,后来,注意到是在电梯开关门的时候有bug:

电梯在某层停下来时,我的实现方案是:

// ElevatorController类的方法
private void halt() {
    Debug.println("halt!");
    new Thread(() -> {
        open();
        close();
    }).start();

    // 先出
    ...
    // 后进
    ...

    synchronized (this) {
        if (isPassable()) {
            try {
                Debug.println("正在等待着关门..");
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

就是说,在halt()函数中:

  • 电梯停下来后,单独开一个线程:按照开门时间、关门时间时间执行开门和关门,关门结束后notify
  • halt()函数的线程里:进行乘客先进、后出的操作,然后等待关门(因为电梯处理的乘客进出的时间一定小于开关门时间,所以,此时一定会wait),等着关门时notify自己,然后halt()执行结束

其中,关门的函数如下:

// ElevatorController类的方法
private void close() {
    elevator.close();
    setPassable(false);
    MyOutput.println(String.format(
        "CLOSE-%c-%d-%d", getBlock().getBlockId(), curFloor, elevator.getId()));
    Debug.println("电梯门close时,更新状态:");
    updateState();
}
// ElevatorController类的方法
private synchronized void setPassable(boolean passable) {
    this.passable = passable;
    notifyAll();
}

bug就出在这里:

  • 调用setPassable()关门后,母函数halt()结尾处的wait()完毕,然后halt()函数执行结束。
  • 开始执行新一轮的run(),即开始上下移动(此时还没有输出CLOSE信息),导致下一次ARRIVE的时间和CLOSE信息之间差得不足moveTime(电梯超速),就会wa

事实上,应该先输出CLOSE信息,再进行上下移动,才是正确的。

bug修复

只需将setPassable()放在输出语句的后面。确保先输出CLOSE信息,再setPassable(),然后才notify,然后halt()才结束、开始接下来的新一轮run()

// ElevatorController类的方法
private void close() {
    elevator.close();
    MyOutput.println(String.format(
        "CLOSE-%c-%d-%d", getBlock().getBlockId(), curFloor, elevator.getId()));
    setPassable(false);
    Debug.println("电梯门close时,更新状态:");
    updateState();
}

task2

UML类图

image-20220502161140530

度量分析

image-20220502141725043

bug分析

bug

hack point:

[1.1]ADD-floor-512-1
[4.0]63-FROM-E-1-TO-D-1
[4.0]391-FROM-C-1-TO-A-1
[4.2]101-FROM-A-1-TO-B-1
[4.2]118-FROM-B-1-TO-E-1

我的部分错误输出:

[   4.2490]ARRIVE-B-1-512
[   4.4500]ARRIVE-C-1-512
[   4.6510]ARRIVE-D-1-512
[   4.8510]ARRIVE-E-1-512
[   5.0520]ARRIVE-A-1-512
[   5.0530]OPEN-A-1-512
[   5.0570]IN-101-A-1-512
[   5.4540]CLOSE-A-1-512
[   5.6540]ARRIVE-B-1-512
[   5.6550]OPEN-B-1-512
[   5.6560]OUT-101-B-1-512
[   6.0560]CLOSE-B-1-512
[   6.2560]ARRIVE-C-1-512
[   6.4570]ARRIVE-D-1-512
[   6.6580]ARRIVE-E-1-512
[   6.8580]ARRIVE-A-1-512
[   7.0580]ARRIVE-B-1-512
[   7.2590]ARRIVE-C-1-512
[   7.4600]ARRIVE-D-1-512
...

产生的问题

  • 从A座1层到B座1层后,更新电梯状态,由于在电梯右面的C座1层有人向上电梯,所以此电梯的状态仍为Rightwards
  • 电梯状态为Rightwards,然而在B座1层有还在等电梯的118号想左行(118-FROM-B-1-TO-E-1),所以118号乘客无法进入电梯
  • 从B座1层到C座1层后,更新电梯状态,由于在电梯右面的E座1层有人向上电梯,所以此电梯的状态仍为Rightwards
  • 电梯状态为Rightwards,然而在C座1层有还在等电梯的391号想左行(391-FROM-C-1-TO-A-1),所以391号乘客无法进入电梯
  • ...

这样,电梯就会陷入一直右行的状态,但是想要左行的在等电梯的人无法进入电梯,程序陷入死循环,118、391、63号Person根本无法进入电梯,最终TLE

究其原因,是因为,我在编写横向电梯的代码时,Person对象进入电梯的标准沿用了和纵向电梯一样的标准,为:电梯方向和请求方向一致才能进入电梯。没有考虑到横向电梯和纵向电梯还有一个很大的差别在于:

  • 纵向电梯到最高层就会反向,不会出现所在楼层的上面一直有PersonRequest请求进入电梯
  • 横向电梯的路线是循环的,所以可能出现所在楼座的右侧可能一直有PersonRequest请求进入电梯

bug修复

  • 对于横向电梯的请求,放宽其进入电梯的标准。从原来的“电梯方向和请求方向一致才能进入电梯”变为“直接进入电梯”。

这回,我的程序输出:

[   4.2430]ARRIVE-E-1-512
[   4.2440]OPEN-E-1-512
[   4.2490]IN-63-E-1-512
[   4.6450]CLOSE-E-1-512
[   4.8450]ARRIVE-D-1-512
[   4.8450]OPEN-D-1-512
[   4.8460]OUT-63-D-1-512
[   5.2460]CLOSE-D-1-512
[   5.4460]ARRIVE-C-1-512
[   5.4470]OPEN-C-1-512
[   5.4470]IN-391-C-1-512
[   5.8470]CLOSE-C-1-512
[   6.0470]ARRIVE-B-1-512
[   6.0480]OPEN-B-1-512
[   6.0480]IN-118-B-1-512
[   6.4480]CLOSE-B-1-512
[   6.6490]ARRIVE-A-1-512
[   6.6490]OPEN-A-1-512
[   6.6490]OUT-391-A-1-512
[   6.6500]IN-101-A-1-512
[   7.0500]CLOSE-A-1-512
[   7.2510]ARRIVE-E-1-512
[   7.2510]OPEN-E-1-512
[   7.2520]OUT-118-E-1-512
[   7.6520]CLOSE-E-1-512
[   7.8520]ARRIVE-A-1-512
[   8.0520]ARRIVE-B-1-512
[   8.0530]OPEN-B-1-512
[   8.0530]OUT-101-B-1-512
[   8.4530]CLOSE-B-1-512

task3

UML类图

image-20220502161022540

UML协作图

image-20220502223333311

注:

  • 里面只显示主要逻辑,一些具体的、设计诸多条件判断的已经省略。
  • 横向电梯和纵向电梯类似,上图仅展示横向电梯。

我的换乘策略

        如图所示,有个人想p0->p3.那就需要先p0->p1
        ____________________________________
        |      |      |      |      |      |
        |      |      |______|      |      |
        |      |      |__p3__|      |      |
        |      |      |      |      |      |
        |______|      |______|      |      |
        |__p1__|      |__p2__|      |      |   // 中转层:hecTsf所在层数(若hecTsf为null,则中转层为1)
        |      |      |      |      |      |
        |______|      |      |      |      |
        |__p01_|      |      |      |      |
        |      |      |      |      |      |
        |______|      |      |      |      |
        |__p0__|      |      |      |      |
        |      |      |      |      |      |
        |______|______|______|______|______|
    
        如果hecTsf不为null,则p1应该尽可能接近p3所在层(尽量一步到位,避免出现p0->p01之后,新的PersonRequest又进行p01->p1)

文件结构

image-20220502150234475

度量分析

Class Metrics

image-20220502145116876

Main类里的圈平均复杂度有些高。我认为原因是:我直接在Main.main()方法里通过for循环来实现了加入电梯。

MyInput类里的圈平均复杂度有些高。我认为原因是:出现了较多的if-else。这也无可非议,因为该类和Scheduler类的instance直接关联,只有通过条件判断才能将不同类型的指令(PersonRequest、横向ElevatorRequest、纵向ElevatorRequest)交给Scheduler的instance。

ElevatorController和HorElevatorController(两者相似,下面仅展示ElevatorController的类内方法)的类总圈复杂度有些高。我认为原因是:

  • 类的方法太多啦!且个人感觉在这个开门、关门、进人、出人等等的一系列处理,有点“面向过程”了...这里面还出现了很多private方法。如果要针对面向过程进行优化的话,需要重构。只是,我当时就在写代码的时候,思路很清晰,就按照这种写法来写了。
  • image-20220502151322987

Method Metrics

image-20220502145318160
image-20220502145409848

其中,ElevatorController和HorElevatorController的updateState()的iv(G)和v(G)和CogC较高。我认为原因是:

  • 该方法的功能就是一个有限状态机,实现了电梯运行状态的切换。所以设计逻辑会较复杂。
  • 调用了一些其他方法
  • 方法行数多
  • 我自己还做了一个“默认层”的优化,(详见task1的"性能优化点"一节),致使其更加复杂一些

此外,ElevatorController和HorElevatorController的run()的CogC和ev(G)较高。我认为原因是:

  • 同样涉及到了和电梯状态有关的switch-case
  • 调用了一些其他方法

bug分析

有一个强测点RTLE了。我猜测应该是自己的换乘思路比较简单。

我在本地测试了很多遍,都是比时间限制(155s)要短十秒以上。于是在bug修复时,我尝试将原版重新提交了一遍,又全部ac了。而且这次比时间限早了15秒左右。可见多线程的在竞争分配上的一些不同(比如第一次执行时A电梯抢到了a人,第二次执行时B电梯抢到了a人),就会导致很显著的差别。

评测机与他人bug

和一些同学组团完善了评测机(用python写的),主要包括如下部分:

  • 测试样例生成
  • java程序运行脚本
  • 正确性检测
  • 程序性能判定

互测hack其他roommate的方式主要还是利用评测机“轰炸”。常见的问题有:

  • 时间戳不递增
  • 电梯超速(开关门之间、关门与arrive之间、arrive与arrive之间等时间差低于要求)
  • RTLE
    • 大部分是程序设计有bug导致无法正常结束(人上不去、电梯一直空转)
    • 小部分是性能较差,在规定时间内没有运行结束(这个没有在互测中体现出来,主要是在强测中有所体现)
  • CTLE,轮询

总结

这一单元,相比多项式解析单元,代码量和复杂度会有所下降。但是,多线程本身带来的诸多问题却非常值得我们重视。

  • 上课时了解到同一段代码每一次执行的结果都可能不同,会取决于操作系统对各个线程的时间片分配
  • 对于共享数据的处理,要用同步代码块、加锁来保证线程安全(原子操作的概念)
  • 刚开始做本单元第一次作业时,我在讨论区分享的关于“官方输出包线程不安全,会怎么样呢”的思考(虽然这里貌似没有“对于共享数据的读写”,但是我想,广义上来讲,输出控制台就是“共享数据”,每个线程都在向控制台输出,也就是在写入、改变共享数据)
  • 第一次作业出现的仅仅由于两行代码的先后顺序反了,带来的“电梯超速”问题
  • 多线程的调试不方便,解决办法是:建立Debug类,然后在适当地方加入Debug.printf()来调试
  • ...

总之,这一单元的“玄学”bug比上一单元多了很多,究其原因就是多线程引发的CPU在调度、分配时间片上的问题。让我感叹有趣的同时,也从一个全新的视角领悟到:原来软件、程序和我们的硬件、操作系统的关联,是如此紧密。

posted @ 2022-05-02 16:15  wlc000  阅读(31)  评论(0编辑  收藏  举报