OO第二单元总结

OO第二单元总结

架构分析


整个系统由main(输入)、scheduler、(多个)simulator、(多个)elevator几个线程组成。

调度器线程负责与所有的其它工作线程通信,其它工作线程之间不会有直接通信。

调度器工作流程如下:

avatar

其中在每一步骤开始时,都会检测是否有新的输入。若有,则直接跳到“被唤醒,读取输入”,重新进行调度。

模拟器在初始化时接受一个Function作为传入参数,作为该模拟器的调度算法。只需要为每种调度算法实现一个符合Function接口的实现,就可以实现每次调度选择最优的调度方法。

电梯的行为划分为移动与开门-下人-上人-关门两个原子操作,通过sleep控制时间,时间到达时调用带锁的输出线程输出相应信息。在原子操作结束后可以接受来自调度器的中断。

线程模型


第五次作业

本次作业中线程同步采用了synchronized块与synchronized方法相结合的方法,输出类直接使用了synchronized方法,其余类使用synchronized块进行控制。

本次作业并未实现线程安全的队列,而是直接使用了java内置的List,并把该List作为锁,对向队列添加请求、从队列取出请求和wait用该锁进行同步。

与电梯同步采用了若电梯不处于WAITING状态直接interrupt电梯线程,等待获得电梯队列的锁之后读取电梯队列的内容,再修改相应调度器队列

起初,与模拟器同步采用了与电梯同步相同的模型,但之后遇到了notify模拟器之后,模拟器未抢到锁,调度器直接读取空的输出产生问题。随后修改成了在模拟器内部维护一个awaitTime,模拟器每次被唤醒后会将该值加一,调度器在唤醒模拟器之前会先读取该值,notify之后通过轮询-sleep的方式等待模拟器抢到锁,实现同步。

为改善性能,防止短时间内的大量输入导致频繁重新调度,每次调度器被唤醒后会继续等待一小段时间,在这段时间内若有新的输入则重置等待时间,若无则开始调度。

系统结束运行时,输入线程会为调度器设置finishFlag再唤醒或中断调度器。调度器检测到finishFlag后进行与电梯的同步,等电梯处理完所有请求后,调度器依次为模拟器与电梯设置finishFlag,并唤醒它们,然后退出。

问题

每个线程类虽然都提供了一些可以直接调用的同步方法,但实际上主要逻辑还是通过得到待同步线程的锁对象后,在synchronized块中实现。因此,类与类之间存在强耦合。

并未优雅的处理线程同步问题,甚至采用了轮询-sleep这种写法。

实质上,并未对每个类内部的队列实现封装,在类的外部可以自由修改。

第六次作业

本次作业步子迈的有些大,结果失败了。

在本次作业中,尝试了使用reentrantlock代替synchronized块,并进行了比第五次作业更进一步的封装。

对于电梯线程的同步操作的实现由调度器类移到了电梯类,调度器只需要调用电梯的接口即可。

修改了模拟器线程的同步操作,由轮询-sleep改为了当启动模拟器后调度器会直接await,而模拟器结束模拟后再唤醒调度器。在调度器等待模拟器的期间,若有新的请求到达,也会进行重新调度。

系统结束运行时,采用了轮询-sleep的方式,调度器会等待所有线程处于WAITING状态后,依次设置finishFlag并再次唤醒线程,然后调度器调用await,直到待结束线程结束前唤醒调度器。

问题

轮询-sleep的方式依然不能优雅的结束线程,而且finish方法中调度器调用await完全不必要。

最终,时间不足,没能调试出程序的问题,不得不回滚到第五次作业的版本。

本次作业最主要的问题并不在于线程模型,而在于调度算法,详见调度算法部分。

第七次作业

本次作业直接进行了重构,大部分同步方式都进行了修改。

本次作业由上次的reentrantlock换回了synchronized块。

不同于之前两次作业在调度器的方法中进行与电梯和模拟器的同步的主要逻辑的实现,电梯类与模拟器类均提供了sync和pause方法,调用即可实现暂停线程以及同步。

pause方法的实现原理为检测目标线程是否处于WAITING状态,若否,调用interrupt方法。pause方法不保证目标线程已经暂停,仅仅发出暂停信号。在任一原子操作完成后,目标线程会检测是否有中断。

public void pause() {
    synchronized (lock) {
        if (this.getState() != State.WAITING) {
            this.interrupt();
        }
    }
}

sync方法也会检测目标线程是否处于WAITING状态。若否,则调用wait方法。直到目标线程wait之前,目标线程会notifyall唤醒为自己wait的所有线程。

public void sync() {
    synchronized (lock) {
        syncFlag = true;
        if (this.getState() != State.WAITTING) {
            lock.wait();
        }
        synced = true;
	}
}

synced用于检查是否抛出NoSyncException,syncFlag用于决定目标线程在wait之前是否执行lock.notifyAll()

电梯类进行了完全的封装,不会对外暴露任何可变变量,只能使用电梯类提供的接口,进行启动电梯、获取电梯当前状态、更新电梯状态、同步、暂停电梯、结束电梯六种操作。一切可能产生线程问题的操作都在类内部实现,对外不可见。对于读取电梯状态的方法,在读取之前必须调用sync方法以保证电梯已经停止运行,否则会抛异常。

模拟器也进行了封装。由于模拟器类并没有很强的状态,主要修改为sync与pause相关修改。同样在读取模拟器模拟结果之前必须调用sync,否则会抛出异常。

将结束的判断标志移动到了循环中wait之前,实现了异步的finish方法,调度器只需要调用模拟器和电梯的finish方法之后即可结束,无需等待其它线程结束,其它线程会在完成任务后结束。

调度算法


虽然框架支持同时运行多种调度算法,但由于时间不足,每次仅实现了一种调度算法。

第五次作业

本次作业采用了ssft算法,优先处理距离最短的请求,将未上电梯的人的距离与已上电梯的人的目标楼层的距离排序,如果未满,则直接选最近的请求,如果已满则选择目的地最近的电梯中的请求。

第六次作业

由于ssft算法不易于实现多电梯版本,本次作业原计划采用look算法,并实现了morning模式下的look算法,但在实现random模式下的look算法时,由于复杂度过高失败了。

问题
  • 未进行抽象与封装。在实现过程中,涉及了很多中间量的Map、List等,并未单独做出一个类或方法,而是直接在主要逻辑中进行复杂的运算,导致复杂度飙升。
  • 面向玄学编程。在初版完成之后,算法无法正常工作,但由于复杂度过高,难以debug,就采用了总之先改一点,跑跑看能不能正常工作的方式,给算法打了很多补丁,使算法更加复杂难懂。
  • 初始时过于重视细节。由于过于追求完美,在算法的主要逻辑完成之前就开始揪着细节不放,导致算法的复杂度爆炸。
  • 对数据结构的修改过于复杂。一个列表在整个算法的各个部分传来传去,到最后难以确定被修改成了什么样。

第七次作业

本次作业实现了简单的look算法。

吸取第六次作业失败的教训,本次作业在实现算法时进行了模块划分,为较复杂的中间量建立了类,封装了操作,为模拟电梯也建立了VirtualElevator类,提供与电梯类相似的接口用于模拟接受请求,而非之前直接对List操作。对于封装的各个数据结构,严格做好可变性的控制,对于需要返回List/Map类型的,选择返回拷贝而非原数组。在算法中,抽象出算法的主要步骤,各个部分都做成子方法,顶层模块屏蔽细节。

其它问题


第七次作业

  1. 换乘

    本次作业原计划实现换乘,但由于架构问题,未能实现。

    对于模拟器,本次作业的实现方式为维护一个以200为最小单位的虚拟时钟,每个虚拟电梯采取行动后记录行动时间,时钟每变化一次,依次检查是否有电梯可以行动,若有,则采取行动,并更新电梯行动时间,若无,更新时钟。

avatar

在模拟器中,采用了如果要换乘,换乘的前半段完成后,会将新的请求压入队列,有严格的时序关系,但由于电梯的输出模型沿用了之前的版本,各个电梯的输出是独立的,即电梯会依次读取调度结果中的请求,并在最短时间内依次输出,也就导致可能会先输出请求的后半段换乘,产生错误。

  1. 硬编码的VirtualElevator

    实际上,为了减小工程复杂度,在实现VirtualElevator时,并未考虑到对多种调度算法的支持,而是将换乘规则等直接写入了相应的方法中,可扩展性偏差。理想的方法为VirtualElevator声明为抽象类或接口,在实现调度算法时再实现相应的VirtualElevator的子类。

bug分析


只有第六次作业强测遇到了bug。运行时间超过了210s。产生原因为第六次由于新架构失败,回滚到了第五次的版本,仅仅使用了一部电梯,导致超时。

扩展性分析


UML类图

avatar

UML时序图

sequenceDiagram title: 第七次作业 participant main participant scheduler participant simulators participant elevators main->scheduler: create scheduler->simulators: create scheduler->elevators: create loop true alt new request main->scheduler: add request end alt new request scheduler->elevators: pause elevators-->scheduler: paused scheduler->simulators: simulate simulators-->scheduler: simulate finish scheduler->>elevators: launch end alt input finish main->>scheduler: finish main->main: exit end alt scheduler finish scheduler->>simulators: finish scheduler->>elevators: finish scheduler->scheduler: exit end alt finish simulators->simulators: exit end alt (finish) and (no request) elevators->elevators: exit end end

早期曾为OUTPUT创建单独的线程,但由于线程调度的不确定性,无法准确控制输出时间,改为线程安全的静态方法输出。

最后一次作业支持多种调度算法,可以动态加减不同的调度算法。电梯类型采用了硬编码的形式,通过枚举定义了电梯的类型,如果要支持运行时动态调整,添加新的电梯类型,需要重写电梯类型部分代码。而如果在运行前确定好了所有电梯的种类,无需进行过多的调整,只需修改枚举,重写VirtualElevator相关部分即可。实际运行框架中,电梯类并不依赖电梯类型,只需要按照调度结果运行即可。

但VirturlElevator中有一部分代码固定了调度方式,如果对调度算法有较大调整,需要重写相关内容。

hack方式


主要是构造连续的或输入时间差距很小的请求,以及构造楼层数差距较大的请求。并未成功hack其他人。

改进构想


若要支持换乘,有两种方案:

  • 电梯读取时间戳

    使Elevator可以读取调度方案的时间戳,按照时间戳执行相关内容而非按照最短时间执行。

  • 采用分配器

    在模拟器的模拟过程中,可以得到严格按照时序排列的各个电梯的行动方案,实际上只需要分配器以200ms的时间间隔检测是否需要输出即可,相比于调度器直接将指令分配给各个多电梯,该方案为调度器将指令传递给分配器,分配器再进行输出。相比于多部电梯的方案,该方案的优点为无需维护多个电梯的队列,状态集中在分配器,更加易于同步。

心得体会


多线程编程,最重要的是建立好线程模型,做好封装。实际上,在第七次作业时,我采用了线程模型与调度逻辑分开验证的方式,在验证线程模型时,将所有的功能调用改为输出,插入不同时长的sleep进行线程可靠性的验证。

  • 线程模型

    java原生提供了很细粒度的线程同步,但如果直接使用,可能会由于涉及的细节过多,易于出错,而且很容易产生耦合。而如果对线程进行封装,提供一系列同步与异步的接口,就能有效解除耦合,降低多线程的复杂性。

  • 容器的使用

    状态是万恶之源,在逻辑不清晰时更是如此。java并未提供像C一样的全局变量,也是有着相关的考虑的。在架构较为复杂时,很难确定一个全局变量被系统的哪些部分修改了。而不仅仅是全局变量,容器对象也很可能如此。如果将各种容器直接暴露在外,经过一系列复杂的调用之后,很难确定该容器经过了怎样的修改。有些修改可能是不规范的,破坏了应有的性质,比如对一个排完序的List进行插入。因此应该对有意义、多处使用的容器进行封装,禁止随意读写,以及在非关键性能的部分,可以多使用拷贝,或java8提供的stream。

posted @ 2021-04-27 19:02  kirimiko  阅读(103)  评论(0)    收藏  举报