2024春-OO课程Unit2总结

前言

第二单元的作业的主题是电梯调度,主要涉及到多线程的使用与互相关联。第一次作业主要实现六个互相独立的电梯,同时对每个请求设定了要乘坐电梯的序号;第二次作业放开了乘坐策略,同时增加了Reset策略;第三次作业主要实现了双轨电梯,即互相影响的两个线程。第二单元涉及各种陌生的多线程知识,功能的实现难度不大,但共享对象的访问、原子操作的拆分、线程的休眠、停止时无法唤醒线程使其停止等问题较难纠正。同时由于多线程的随机性,出现bug无法复刻的问题,导致debug十分困难。本篇总结主要是记录一下三次作业的架构设计、各种优缺点分析,以及自身一点心得体会。


一、作业架构分析

Homework1

题目重点重述

第一次作业主要实现6个电梯的调度,并且在本次作业中,每个请求都有其要求的电梯序号。因此问题转变为6个相互独立的请求序列,每个电梯负责一个请求序列,互不影响。输入的解析形式是[Time]personId-FROM-fromFloor-TO-toFloor-BY-elevatorId,并按照请求响应种类输出电梯动作ARRIVEOPENCLOSE,乘客动作INOUT

程序UML图

hw2-1UML图

线程InputThreadScheduleThread共享一个wholeRequests请求池,读入全部请求至请求池并按照调度策略分配到各电梯的任务池中。线程ScheduleThreadElevatorThread共享一个tasks任务池,按照调度策略分配到电梯的任务后,电梯根据自身运行策略选择请求执行。第一次作业指定请求的电梯,因此调度策略即按照请求电梯序号分配即可。运行策略暂定为电梯内有乘客,优先满足目标楼层最近的乘客;当电梯内没有乘客而任务池中有请求时,优先前往有乘客的最近楼层;当电梯内没有乘客且任务池中没有请求时,电梯执行等待操作(即该线程对于共享对象tasks上锁进行wait()操作)。

复杂度

类复杂度表格如图所示:其中OCavg指代平均操作复杂度,OCmax 指代最大操作复杂度,WMC 指代加权方法复杂度

ClassOCavgOCmaxWMC
Elevator4.31143
InputThread234
Main222
RequestQueue1.25310
ScheduleThread2.3357
TestMain222

可以观察到,Elevator类具有极高的复杂度,这是因为其内部含有多种判别方法(如电梯运行方向判断、电梯是否停止并转运判断、进入电梯人选判断等)。这些判断方法含有多次遍历判断,以期寻找到最优解,因此复杂度较高。

重构

由于第一次的架构设计较为不合理,部分类代码过于冗长且重复,动作归属混乱且偏向面向过程。因此对整体架构重构,记录如下:

hw2-1UML图改

ClassOCavgOCmaxWMC
Customer115
Elevator234
ElevatorAction2.36726
ElevatorScheduler5910
InputThread234
Main222
RequestQueue1.73319
ScheduleThread2.3357

可以发现,将Elevator中的动作和电梯调度器提出,可以降低原类的复杂度。同时,由于动作归属更加明确,架构逻辑也更加清晰。

Homework2

题目重点重述

第二次作业在第一次作业的基础上,去除了乘坐特定电梯的限制。即不再为每位乘客划分特定电梯,而是采用估测分数、划分最优电梯的方法,达到局部最优解,并在为电梯分配任务后发出Receive标志。同时增加了Reset指令[Time]RESET-Elevator-elevatorId-peopleLimit-moveTime,当电梯在运行时,发出Reset指令,电梯停止运行,电梯中的乘客尽快离开,电梯重置参数,并将电梯全部任务返回总任务池,重新分配。由于除去Reset指令,划分出去的任务无法重新划分,因此理论上仅能达到局部最优解。

程序UML图

hw2-2UML图

本次作业中,对整体架构进行了进一步的修改,使得动作归属更加明确,逻辑更加清晰,并保障在线程类中除去run()方法并无过多无关方法。本次作业中,新增ResetThread线程,获得Reset指令并进行分配,允许Elevator使用wholeRequests请求池,将Reset()操作加入ElevartorThread的运行流程之中,先使用leaveAllPeople()让电梯内人员在当前楼层下电梯,再使用returnResetPeople()将该电梯的全部乘坐请求返回wholeRequests中,等待重新分配。新增GlobalScheduler类,获得每个电梯的分数,并进行局部最优的电梯分配。新增Observer类,内部全部属性方法均为全局静态,用来总体观察当前指令执行情况。当输入一条指令时未执行指令数量加1,执行完成一条指令时未执行指令数量减1,输入完成时执行inputEnd()方法。当number == 0end == true时可以唤醒全部线程并结束程序。

复杂度

类复杂度表格如图所示:其中OCavg指代平均操作复杂度,OCmax 指代最大操作复杂度,WMC 指代加权方法复杂度

ClassOCavgOCmaxWMC
Customer115
ElevatorAction2732
ElevatorScheduler3.751015
ElevatorThread234
GlobalScheduler2.545
InputThread356
Main222
Observer114
RequestQueue1.79325
ResetThread246
ScheduleThread3.33710

可以观察到,相比于第一次作业,大部分类复杂度降低,证明架构调整取得了较好的效果。同时也发现部分类最大操作复杂度和加权方法复杂度有一定提高,这是因为加入了新的有一定复杂度的方法(如在ElevatorScheduler中加入了判断是否进行开关门操作的方法,而这在上一次作业中是在ElevatorAction中的)。应该指出,类复杂度仍然较高,还有很大优化空间。

Homework3

题目重点重述

第三次作业在第二次作业的基础上,增加了重置普通电梯为双轨电梯的Reset操作:[Time]RESET-DCElevator-elevatorId-exchangeFloor-peopleLimit-moveTime。该Reset操作与普通Reset操作基本相同,只是在Reset操作结束后,关闭普通电梯,增加双轨电梯,该双轨电梯分为A轿厢和B轿厢,A轿厢的运动范围是[1, exchangeFloor],B轿厢的运动范围是[exchangeFloor, 11],两个轿厢相互独立,但不能同时处于交换楼层。

程序UML图

hw2-3UML图

本次作业中,为了降低双轨电梯新增线程的难度,对原架构又进行了一定的调整,进行了一定的集合和简化。主要是新增了一系列关于双轨电梯的类,如DoubleElevatorThread双轨电梯单轿厢的运行线程类,DoubleElevator双轨电梯宏观调控类(负责查询两个轿厢的状态),DoubleElevatorAction双轨电梯单轿厢的动作类。同时,将原ElevatorThread进行修改,未进行DoubleCarReset操作时,是正常的单轿厢运行线程;进行DoubleCarReset操作后,是双轨电梯任务分配线程,主要功能是在ScheduleThread将任务池中请求分配进入各电梯tasks后,将tasks中请求分配进入两个轿厢的任务序列tasksAtasksB中(实际上这样的设计固然能降低程序结束时线程未结束的问题,但从逻辑上看是极其不合理的,应该将其分为两个线程)。

复杂度

类复杂度表格如图所示:其中OCavg指代平均操作复杂度,OCmax 指代最大操作复杂度,WMC 指代加权方法复杂度

ClassOCavgOCmaxWMC
Customer115
DoubleElevator114
DoubleElevatorAction3.571350
DoubleElevatorThread234
ElevatorAction2.63950
ElevatorThread4612
GlobalScheduler2.545
InputThread3.567
Main222
Observer114
RequestQueue1.69327
ResetThread349
ScheduleThread3.33610

可以观察到,本次作业中部分类的复杂度极高,这是因为为了正确性将部分方法进行了集合,并且出现了较为面向过程的问题。这部分还需要对架构进行进一步的优化,使得逻辑更加清晰,复杂度更低。


二、架构重点实现分析

Homework1

第一次作业程序中主要有三个实现的重点:

  • 各线程间请求队列共享: 主要是线程InputThreadScheduleThread共享一个wholeRequests请求池和线程ScheduleThreadElevator共享一个tasks任务池。执行过程中,这两个共享请求队列需要作为锁存在于线程间,保证不会产生对于请求池isEndisEmpty等属性的不一致。

  • Elevator操作实现: 在程序中,电梯主要有acquireGoal()move()transfer()(内含enterPeopleleavePeople操作)等操作以及isFull()isEmpty()needTransfer()等判断。这些方法共同构成了获得任务池后运行的电梯线程。

  • Elevator运行策略: 本次作业不需要实现调度策略,仅需要自行实现电梯运行策略。本次作业的运行策略设计为:电梯内有乘客,优先满足目标楼层最近的乘客;当电梯内没有乘客且任务池中有请求时,优先前往有乘客的最近楼层;当电梯内没有乘客且任务池中没有请求时,电梯执行等待操作(即该线程对于锁tasks进行wait()操作)。

Homework2

第二次作业程序中主要有三个实现重点:

  • 电梯Reset操作: 电梯Reset操作主要有以下几个步骤:isReset标志置true以防止在Reset过程中再获得指令、全部电梯内乘客下电梯(通过inpeople.isEmpty()标志判断是否需要进行这一步)、电梯任务池中全部指令返回总任务池、更改电梯参数、isReset标志置false并执行Observer.subRequest()方法(本次Reset指令成功执行,未执行操作数减1)。
  • Observer静态全局观察类: 本次作业实现了Observer类,以此来判断程序是否执行完成,各线程可以停止。类之中属性全部设置有static属性,可以在全局中使用。也可以在电梯线程无可执行指令时唤醒调度器线程,通过判断wholeRequests.isEmpty()wholeRequests.isEnd()elevatorTasks.isEmpty()elevatorInPeople.isEmpty()elevatorResetRequest == null均为true来决定是否结束程序(实际上后面三个对象都是private属性,需在电梯内部实现判断,笔者只是为了表述方便)。
  • 电梯调度策略: 本次作业不再强制指定运输乘客的电梯,自行设置调度策略。受限于完成时间,笔者本次仅采取了一种简单的电梯得分策略,并按照得分最高电梯赢得竞争的原则分配。本次作业中,将targetFloornowFloormoveTimeinPeople.size()tasks.size()作为评判属性,以层次分析法分配权重(实际上层次分析法主观性极强,需要不停尝试),并由GlobalScheduler计算获得最终得分并分配给最佳电梯。

Homework3

第三次作业程序中主要有三个实现重点:

  • DoubleCarReset操作后双轿厢的启动: 本次作业实现了DoubleElevator类,其中的begin()方法用于启动两个电梯线程。具体操作为在接收到Reset指令后,创建新的DoubleElevator对象,并调用begin()方法。具体实现如下:

    	//在接收到Reset指令后,创建新的doubleElevator并启动线程
    		if (request instanceof DoubleCarResetRequest)
            {
                resetEnd = true;
                DoubleElevator doubleElevator = new DoubleElevator(id, peopleLimit, moveTime, bothFloor,
                        queueA, queueB, tasks, taskPool);
                doubleElevator.begin();
            }
    
    	//双轿厢线程的启动
    		public void begin()
        	{
            	DoubleElevatorThread elevatorThreadA = new DoubleElevatorThread("A", elevatorA);
            	elevatorThreadA.start();
            	DoubleElevatorThread elevatorThreadB = new DoubleElevatorThread("B", elevatorB);
            	elevatorThreadB.start();
        	}
    
  • 双轿厢电梯动作: 双轿厢电梯可看作是两个同质的轿厢,因此只需要实现单个轿厢的动作即可。单个轿厢动作与普通电梯电梯基本相同,不同点主要有以下几点:换乘楼层有电梯时不可前往并休眠一定时间、部分乘客强制要求在换乘楼层下电梯、inPeople.size() == 0 && tasks.getSize() == 0成立时不可在换乘层休眠。

  • 双轿厢监控器: 双轿厢并不独立,之间会互相影响,因此设立监控器,随时观测两个轿厢的状态。两个轿厢间主要的影响是不能同时位于换乘层,又可以分为换乘层有轿厢时另一轿厢不可前往、有轿厢前往换乘层时另一轿厢不可前往、位于换乘层轿厢还未离开换乘层时另一轿厢不可前往(实际上这条条件较为宽松,因为两轿厢速度相同,当换乘层轿厢离开的同时另一轿厢前往换乘层不会发生冲突)。设置have属性代表换乘层是否有轿厢,当轿厢确定换乘层为目标时have设置为true,轿厢完全离开换乘层时have设置成false,在读取和设置时将have上锁,保证不会出现线程冲突的问题。


三、Bug分析及程序优化

Homework1

Bug分析

本次作业中,由于对于锁的理解不深,出现了ConcurrentModificationException的错误。这是因为未将遍历过程化为原子操作,在遍历的过程中发现了锁改变,导致遍历范围改变的问题,问题代码如下:

		for (PersonRequest request : tasks.getRequests()) //getRequests()方法具有synchronized属性
        {
            if (Math.abs(request.getFromFloor() - nowFloor) < delta) 
            {
                delta = Math.abs(request.getFromFloor() - nowFloor);
                goalRequest = request;
            }
        }

getRequests()方法是个synchronized方法,但可以发现,每次循环均要访问getRequests()方法,在访问过程中会上锁开锁,在开锁后tasks中的requests属性会发生变化,故getRequests()的结果会不断变化,因此导致循环范围改变,产生错误。

优化策略

设计最初时,缺省了一定的程序鲁棒性以及对于性能的追求。现对作业过程中所做优化进行记录:

  • 迭代器的使用: 当乘客离开电梯时,有可能出现电梯内所有乘客都要离开电梯的情况,对应到程序中就是需要逐一去除inPeople集合中的全部元素。使用遍历+remove的方法具有一定危险性,因此可以替换为迭代器删除的方法,具体代码如下:

    		if (!inPeople.isEmpty())
            {
                Iterator<PersonRequest> iterator = inPeople.iterator();
                while (iterator.hasNext())
                {
                    PersonRequest request = iterator.next();
                    if (request.getToFloor() == nowFloor)
                    {
                        iterator.remove();
                        int personId = request.getPersonId();
                        TimableOutput.println("OUT-" + personId + "-" + nowFloor + "-" + id);
                    }
                }
            }
    
  • 电梯运行策略优化: 当前策略显然仍不是最优解,如以下情况:电梯处于1楼,此时任务池加入请求[1.0]1-FROM-10-TO-3-BY-1[1.0]2-FROM-9-TO-2-BY-1,按照当前策略只会进入2号,之后向2楼出发。显然在电梯未满的情况下,让1号进入一同向2楼出发更优。可以可以通过比较直接向目标楼层出发和完成另外一条请求的收益(即直接向目标楼层出发再返回让1号进入需要经过的楼层和让1号进入再向目标楼层出发需要经过的楼层)。

Homework2

Bug分析

本次作业中,主要出现了死锁的问题。当电梯线程完成全部任务并输入已经结束时,唤醒调度器线程,调度器线程为所有电梯线程设置结束标志,并唤醒全部电梯线程,最终程序结束。这个思路会出现某一电梯线程无法结束的问题。经过很长一段时间debug后,终于找到了问题:当调度器被唤醒并设置结束标志之后,电梯又进入了休眠状态。问题代码如下:

	//调度器线程整体流程
	public void run()
    {
        while (true)
        {
            //调度器线程结束判断
            if (Observer.allEnd())
            {
                for (int i = 0; i < queues.size(); i++)
                {
                    queues.get(i).setEnd(true); //为每个电梯线程设置结束标志,并唤醒电梯线程
                }
                break;
            }
            ......
        }
    }

	//电梯线程整体流程
	public void run()
    {
        while (true)
        {
            if (action.finished()) //判断电梯是否停止,并唤醒调度器流程
            {
                break;
            }
            action.work(); //work()方法含有如果tasks为空则线程休眠的功能
        }
    }

经过排查,发现当电梯线程位于action.finished()方法时,唤醒调度器,两个线程同时进行。当调度器线程为电梯setEnd(true)时,电梯线程位于action.work()方法内,但还未到判断tasks是否为空的位置。当调度器完成setEnd(true)操作后,电梯线程到达判断tasks是否为空的位置,并判断为空休眠线程,导致最终程序无法停止,从而引发RTLE的问题。解决方案也很简单,在判断tasks是否为空的位置加上条件语句if(!isEnd()),表示如果没有结束可以休眠,如果已经获得结束标志不可以休眠。

优化策略

设计最初时,由于听取过多不同关于架构的意见(所以还是要坚持自己的想法,毕竟别人也不知道你是怎么设计的,有些建议会带来负面效果),加之自身理解不深,导致debug的方向出现偏差,从而引发最终时间、提交次数不够的问题,也就没有实现自己关于总体调度器的一些设想。现对作业过程中所做的未做的优化进行记录:

  • CPU运行时间缩减: 笔者进行设计的最初妄图逐个击破,先添加Reset指令,便随意设计了一个分配方案(按照乘客的序号分配,第i号乘客分配给i % 6 号电梯)。测试时发现出现了ScheduleThread线程CPU使用时间过长的问题,先入为主地认为是在分配完所有任务后ScheduleThread线程没有进入休眠而是进行轮询消耗大量时间,因此更改了众多方法中的notifyAll()方法(后期发现不是这个问题后给自己增添了许多工作量)。实际上是因为从总任务池中取出指令分配时,需要判断当前是否有电梯可以接受分配,如果不能分配需要将指令放回任务池。而如果使用上文的分配方法,仅查询一个电梯,可分配概率会大幅度降低,导致需要不停进行取出、放回的操作,从而增长CPU使用时间。
  • 影子电梯: 即在分配指令时,按照当前电梯状态,计算为此电梯分配与不分配该指令的分数差。可以按照课程组给出的性能分得分方式: s = 15 × ( 0.3 r ( T r u n ) + 0.3 r ( M T ) + 0.4 r ( W ) ) s=15×(0.3r(T_{run})+0.3r(MT)+0.4r(W)) s=15×(0.3r(Trun)+0.3r(MT)+0.4r(W))获得各电梯的分数差。得分方式也可以简化为仅与运行时间 T r u n T_{run} Trun、耗电量 W W W相关,并对得分进行归一化处理: s = 0.6 × 1 T r u n + 0.4 × 1 W s = 0.6 \times \frac{1}{T_{run}} + 0.4 \times \frac{1}{W} s=0.6×Trun1+0.4×W1。而影子电梯即是设置以当前电梯状态为初始状态的虚拟电梯,该电梯基本操作与普通电梯相同,但不进行真实的延时操作,模拟真实电梯完成两次任务序列的过程,并将两个变量表示成仅与移动次数 N m o v e N_{move} Nmove、转运次数 N t r a n s f e r N_{transfer} Ntransfer的函数: T r u n = t m o v e _ p e r f l o o r × N m o v e + 0.4 × N t r a n s f e r T_{run} = t_{move\_perfloor} \times N_{move} + 0.4 \times N_{transfer} Trun=tmove_perfloor×Nmove+0.4×Ntransfer W = 0.4 × N m o v e + 0.2 × N t r a n s f e r W = 0.4 \times N_{move} + 0.2 \times N_{transfer} W=0.4×Nmove+0.2×Ntransfer
  • 分配限制: 本次作业中,发现了一种较为影响程序性能的特殊情况。即当六部电梯仅有一部电梯不处于isReset状态时,此时调度器会将全部任务分配到该电梯的任务池中。由于分配入任务池中的请求无法取出重新分配,导致当其余五部电梯结束Reset后总任务池中不存在请求,在之后程序的进行中其余电梯会一直处于休眠状态。解决方法很简单,将在电梯分配任务时判断是否可以分配的条件改为!isReset && (inPeople.size() + tasks.getSize() < 10)

Homework3

Bug分析

第三次作业暂无bug。

优化策略

第三次作业基于第二次作业的架构,对于性能并无太大影响,因此未对性能进行优化。作业设计中,对于程序的泛用性具有一定的思考,现对该可能的优化做一定的记录:

  • 电梯轿厢泛用性设计: 前两次作业电梯均为普通单轿厢电梯,第三次作业电梯为双轿厢电梯,其中每次调控的原子对象实际上是单个电梯轿厢。因此我们可以设置电梯轿厢动作,上层设置电梯调度器,宏观观察多个轿厢,进行调控。每个轿厢具有运行楼层上下限,在上下限之间运动,在换乘楼层进行换乘操作。

四、心得体会

挺过开学的三板斧,又被到期中这段时间的各项事情狠狠地扇了一巴掌。不得不说,各科课程的难度逐渐增加,又涉及到各种其他奇奇怪怪的事情,导致没有足够的精力去设计更加科学的框架、去实现预想的更加高效的调度方法、去对程序进行更加极限的普适性鲁棒性测试。书山有路勤为径,学海无涯苦作舟。学习的生涯必然是痛并快乐着的,希望自己能挺过这些痛苦,翻过这些山,爬过这些坎,站在山顶上欣赏日出吧。

小小发泄之后,就谈谈获得的一些经验吧:

  • 架构设计的重要性。 本次作业架构设计不是很合理,所以在第一次作业完成后又对整体架构进行了重构调整,让各个类更加简洁清晰,逻辑也更加严密。
  • 讨论的重要性。 三次作业中加入了一节讨论课,在这节课和不同同学讨论之后,对锁、线程的休眠唤醒等多线程操作有了更深刻的认识,对于处理中可能遇到的细节性问题也有了更多的想法,对开阔视野和思路极有帮助。
  • 测试的重要性。 测试测试测试,重要的事情说三遍。课程组鼓励自建评测机,通过多组数据测试才能更加完全验证程序的正确性,这也是未来工程所要求的能力。(由于笔者的拖延症,第三次作业留给测试的时间很短,导致互测被爆杀)

至于对课程的建议,本单元是真没什么想法了。公众号上发的推送对于多线程介绍很清晰详细,作业描述、各项课程支援的说明都很明白,作业的承接跨度也做得很好,应该说很难再吹毛求疵了。

最后,还是要感谢助教wsj学长的帮助。也希望在之后的学习中能再接再厉,继续进步。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值