BUAA-OO-Unit2-单元总结

BUAA-OO-Unit2-单元总结

一、锁与同步块

关于线程安全问题,我在第五次作业的时候就已经想好怎么进行处理了。以至于第六、七次作业中并没有对线程安全以及同步块进行过多的考虑。

1、线程选择

首先需要考虑,为什么我们需要线程?我最后再三考虑。需要多线程是为了提高我们程序的运行效率,而运行慢的线程自然而然地就要自己成为一个线程,否则会严重占用系统资源。

而什么是“运行慢的线程”?在作业中,由于生产者消费者模型的建立,运行慢的是输入线程电梯线程

输入线程:关于输入单独开个线程,其实并不是“运行慢”,是因为外界输入交互时间的不确定性,因此需要单独开个线程,对外界输入信息进行实时响应处理。因此,对输入的不确定响应也算一种特殊的“运行慢”。

电梯线程:电梯作为线程是最符合常理的。因为电梯每次开关门、移动一层(楼)都需要耗费0.1s数量级的时间,而这个时间对于CPU而言是“一个世纪”。而这零点几秒的时间能够计算数以千万的数据。因此它“运行慢”,要单独开一个线程。

而调度器是否需要一个线程?

我的答案是不用。

首先,对于调度器来说,如果调度算法复杂度不高的情况下,对每一次请求进来的计算时间相对于电梯线程和输入线程的耗时几乎可以忽略不计。

其次,如果调度器单独作为线程出现,将会生成两个生产消费模型:

输入线程  <---->  调度器  <---->  电梯

于是就需要两个托盘对象。就需要维护更多的共享对象,不仅会使得架构更为复杂,还会更容易出现线程安全的错误。

诚然,调度器作为一个线程能够更好地支持复杂度很高的算法,但是对于OO第二单元来说,并不需要太复杂的算法一样可以取得很好的成绩。

因此,我三次作业均采用两种线程单托盘一对多生产消费模型。(事实证明是可行的,第三次作业强测99.8734分)

2、锁和同步块的设置

由于我让调度器作为托盘对象提供给输入线程和电梯线程使用。因此,我所有的锁和同步块设置都是基于调度器实现的。

调度器对象有且只有一个。(可以采用单例模式,但是因为是自己实现,架构比较清楚,所以并没有重构成单例模式。)

调度器中的属性主要为候乘表容器,在第二大点细说。

在调度器类里的所有方法,均采用Synchronized约束(即对调度器对象进行同步控制),如下所示。

public synchronized void addRequest(PersonRequest personRequest) {
	/* TODO */
}

在电梯线程和输入线程中,如果取出了候乘表容器,并且要对其进行增删操作,则需要启用同步块设置,如下所示。

synchronized (dispatch) {
	/* TODO */
}

总结,这种做法方便好些,而且也不用担心运行效率。课后作业我特别推荐这种做法。同时,由于第五次作业已采用这种方式,因此这里不详细讲述三次作业中的架构的细微差别,主要差别其实在于调度器内部的设计,在第二部分详细展开。

二、调度器设计

1、调度器数据结构分析

调度器的数据结构设计中,需要综合输入线程和电梯线程之间的交互功能进行考虑。在这三次作业中,不难分析出调度器所需要的存储的数据是候乘表——即在电梯之外等待的队列人数。

因此,在第五次作业中,我的候乘表所采用的容器是:

HashMap<Character, HashMap<Integer, ArrayList<PersonRequest>>> waitQueue;

第一层Hashmapkey为楼座,value为每一个楼座的候乘表。

第二层Hashmap(每一个楼座的候乘表),key为楼层,value为每一层的候乘队列。

在第六、七次作业中,我的候乘表所采用的容器是:

HashMap<Integer, HashMap<Character, ArrayList<Person>>> floorWaitQueue;
HashMap<Character, HashMap<Integer, ArrayList<Person>>> buildingWaitQueue;

总体设计同第五次作业,只不过floorWaitQueue为同层的请求,buildingWaitQueue为同座请求。

注:第七次作业虽然有换乘请求,但是我采用拆分请求,流水线完成的方式,因此不需要更改候乘表容器。

2、调度器功能结构分析

调度器的功能结构设计中,主要需要考虑调度器需要提供给输入线程以及电梯线程使用的接口。

第五次作业主要功能设计

addRequest(PersonRequest personRequest):添加请求,主要提供给输入线程。

getCurRequest(char type):得到type座的候乘表,主要提供给电梯线程。

setEnd():由输出线程结束时调用,通知调度器已经没有请求。

isEnd():由电梯线程调用,判断当前输出线程是否结束以及候乘表请求是否完成。

第六次作业主要功能改动

getCurRequest(char building)函数拆分成getBuildingCurRequest(char building)getFloorCurRequest(int floor)两种方法。

第七次作业主要功能设计

在第七次作业中,新增了换乘的请求,因此就需要进行一定的调度策略,从而提高电梯的吞吐量。

关于换乘的功能设计总体而言就是对请求的拆分,并利用电梯流水线完成请求的方式实现换乘功能。

因此对于请求的拆分均在调度器里进行,这里不一一列举实现功能,只说明实现方法。

拆分策略算法

首先,针对候乘表的每一次增加人(请求),都对该人的请求进行拆分。拆分方法是:1、同楼请求不拆分。2、若不是同楼请求,则至少需要一次横向电梯的换乘。那么,就遍历1楼到10楼的电梯信息,找出能够停靠出发楼座和目的楼座的楼层的路径信息。(考虑同层横向电梯的换乘,比如同一层中,横向电梯1能够在A,B停靠,横向电梯2能够在B,C停靠,那么人的请求可以拆分成A->B,B->C,这个利用DFS搜索即可)并通过代价函数计算出最短代价路径。

代价函数设计

需要考虑每条路径上等待人数,电梯运载量,电梯运载速度等参数。因此,就需要知道每一座纵向电梯的参数和每一层横向电梯的参数。但是由于不能去实时知道电梯当前的停靠楼座以及电梯内部人员数量的信息,因此只能对电梯的不变参数进行估算。

估算函数:1、针对每一段请求,电梯接到该人所需要的最多的趟数*电梯运行平均速度*电梯平均运行楼层+当前请求移动层数*电梯运行速度。2、针对所有请求,如有n段请求,则加上(n-1)*0.4s的开关门时间

三、架构分析

第五次作业分析

关于第五次作业的架构设计,关键内容已经在第一、二大点分析得差不多了。关键在于:采用两个线程类的形式:InputThread输入线程类和ElevatorThread电梯线程类(它们都继承了Thread类,来实现多线程运行)。并由Dispatch调度器类作为托盘对象实现生产消费模型。Main类作为电梯模拟进程的主类,负责开启多个线程,并分配托盘对象。接下来系统分析一下每个类的属性和方法:

  • InputThread
    • 属性
      • dispatch:为托盘对象调度器,与Elevator中的调度器对象一样
    • 方法
      • executeInput:对读入进行处理,将读入的请求送入dispatch对象中
      • run:线程运行时方法,调用executeInput
  • Dispatch
    • 在第二点已经说明
  • Elevator
    • 属性
      • requests:电梯内部(已接到)的请求队列
      • dispatch:为托盘对象调度器,与InputThread中的调度器对象一样
      • name:电梯楼座(这个在后两次作业改称为curBuilding
      • type:电梯ID(这个在后两次作业改称为id
      • curFloor:当前楼层
      • highestFloor:电梯所能到达的最高楼层
      • lowestFloor:电梯所能到达的最低楼层
      • capacity:电梯的容量
      • moveTime:电梯运行一层所需要的时间(后两次作业中,横向电梯运行一座所需要的时间)
      • openTime:电梯开门所需时间
      • closeTime:电梯关门所需时间
      • isOpen:电梯是否处在开门状态
      • isUp:电梯是否处在上升状态(用于look算法)
    • 方法
      • run:执行线程
      • execute:开始模拟运行电梯,调用look方法
      • look:电梯运行策略,look算法
      • getOn:接请求,人进电梯
      • getOff:完成请求,人出电梯
      • open:开门
      • close:关门
      • up:上行
      • down:下行

在第五次作业中最关键的就是电梯线程的实现,可以说电梯线程实现好了,在第六、七次作业中就不需要进行大规模的修改,实现代码的可扩展性。

设计要点分析:

  1. 电梯参数化设计——可扩展性。我将电梯的许多限制属性都采用参数化形式的表示,因此只需创建电梯的时候,输入正确的参数,即可保证电梯的正常运行。这就使得电梯的可扩展性得到增强——即配合工厂模式能够创建多种不同的电梯,如自定义配置可到达楼层,电梯容量,电梯开关门时间,电梯运载量,电梯运行时间等等。
  2. 电梯策略设计——look算法。实现了最经典的look算法。

第六次作业分析

大致沿用第五次作业的架构设计,主要有以下几个方面的修改:

  1. 将人的请求封装成Person类。便于关于人的信息的更好的封装性和扩展性。
  2. 将新增的横向电梯与纵向电梯整合成同一个类里(Elevator类)。
  3. 将电梯调度(LOOK方法)后得到的操作封装成枚举类型,具有更好的封装性和扩展性。
  4. 对于电梯的创建采用ElevatorFactory类,工厂模式的创建方法。

第七次作业分析

大致沿用第六次作业的架构设计,主要有以下几个方面的修改:

  1. 人的类里面,将request改为ArrayList类型的requests属性,用于拆分请求。
  2. 调度器类里,增加了许多拆分请求用的函数,具体算法在第二大点中已提到。
  3. 关于结束线程问题,在调度器里增加personNumber属性,每次输入线程输入一个人的请求,则加一,每次电梯线程结束一个人的请求(即所有换乘请求都已完成,该人已到达目的地),则减一。若输入线程结束,且personNumber为0,则结束所有电梯线程。

协作图

四、自己的Bug分析

自认为第二单元做的不好,出现了各种低级Bug。(因为第一单元作业没有出现bug,第二单元每次作业都有bug,自我感觉很差)

第五次作业

在判断当前电梯内是否有请求需要在当前层下电梯的时候,break放在了if语句外面,导致每次判断时均只拿出容器中的一个对象进行判断。导致当电梯内请求增加时出现严重的性能题,最终RTLE了三个强测点,并且过了的点中,一半的测试点性能分爆0。分数:81.7757。十分严重且低级的bug。bug代码如下(修复只需要把break移到if语句块中):

boolean someoneOff = false;
for (PersonRequest personRequest : requests) {
	if (personRequest.getToFloor() == curFloor) {
        someoneOff = true;
    }
	break;
}
第六次作业

在进行关门优化的时候,先判断是否需要wait,再求需要wait的时间。由于赋值语句需要时间,因此这两句的System.currentTimeMillis的值不一样,如果再shouldWait计算的时候,System.currentTimeMillis恰好等于closeTime+lastOpenTime,则第二句赋值waitTime将会出现负数,最后sleep参数为一个负数,报异常错误。最终强测错一个点。分数:93.7987。(修复只需要计算出waitTime,在通过waitTime是否为负判断是否需要sleep)

boolean shouldWait = System.currentTimeMillis() <= closeTime + this.lastOpenTime;
long waitTime = closeTime + lastOpenTime - System.currentTimeMillis();
if (shouldWait) {
    try {
        sleep(waitTime);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
第七次作业

虽然在本地复现不了,而且bug修复阶段交上去就过了。因此推测是高并发情况下出错。由于锁分配的不确定性,因此有可能是当前判断电梯要上行,但是突然进行了大量更新,导致电梯策略转变成下行,因此运行到了第0层。所幸这个bug在互测的时候发现,强测并没有测出来。分 b数:99.8734。

只需要限制电梯最低运行到第1层即可修复。

五、互测策略

采用随机数据生成评测机+手动构造针对性数据进行测试。

在测试过程中,主要遇到的bug均为线程安全bug,有好几次都是本地能hack到,但是提交上去hack不到。

总而言之,线程安全太难hack了。

六、心得体会

  1. 虽然第二单元做的不是很理想,但是确实学到了关于多线程的编程实现,这是我之前没有接触过的领域。而且,每次的强测成绩均有进步而且在最难的第三次作业中拿到99.8734。每次作业的强测成绩均有进步。(给自己颁发一个进步奖~)
  2. 多做测试!!多做测试!!多做测试!!
  3. 一个好的架构真的很重要。对于线程安全的处理,我从第五次作业就采用自认为比较好的架构,并且沿用到第七次作业,真的很难得。
posted @ 2022-04-30 11:48  CoolColoury  阅读(29)  评论(1编辑  收藏  举报