北航OO第二单元总结


本单元主要针对多线程程序设计, 要求我们设计一个电梯调度系统.

一.架构设计

该部分只针对第三次作业的内容进行叙述, 具体迭代部分请看迭代过程

1. 线程设计

线程部分有: 主线程, 输入线程, 电梯线程, 调度线程(双轿厢电梯调度线程)

(1) 主线程

主线程由Main类开启, 总共做两个操作: 一是初始化时间戳, 二是调用Init类的init方法, 这是一个静态方法, 其主要作用是初始化等待队列, 请求队列列表, 构建并开启输入线程, 电梯线程, 调度线程

(2) 输入线程——InputThraed

该线程由Init类构建并开启, 主要用来处理输入, 将请求放入等待队列, 将重置信息交给对应的电梯线程

(3) 调度线程——Schedule

该线程由Init类构建并开启, 其作用是调度等待队列中的请求, 将请求放入调度电梯的请求队列当中

(4) 电梯线程——Elevator

该线程由Init类构建并开启, 其作用是模拟电梯的运行

(5) 双轿厢电梯调度线程——DoubleCarElevator

该线程由Elevator线程在双轿厢重置时构建并开启, 其作用是管理两个轿厢, 并将调度到双轿厢电梯的请求分配给具体的轿厢

2. 线程结束控制

我们将等待队列WaitTable对象、请求队列RequestTable对象设置结束标志位.

(1) 输入线程结束控制

当得到的请求是null时(即输入结束), 将等待队列结束标志位设为true, 并结束线程

(2) 调度线程结束控制

这部分控制相对较为复杂, 比较显然的是, 等待队列结束(结束标志位为true)且等待队列为空是调度线程结束的必要条件, 但是在重置时, 会出现请求重新调度的问题, 显然前面的必要条件不是充分条件, 因为存在如下情况: 等待队列为空且结束, 此时电梯重置将请求返还重新调度, 但此时调度线程已结束, 这会出现问题. 解决方案是将前述必要条件增加一项: 所以请求均已完成. 该需求其实也很容易实现.当请求加入到等待队列则count++, 当请求出电梯时(注意不包括换乘时的出电梯)count–, 前述必要条件只需加上count==0即可
在满足线程结束条件后, 将请求列表中的所有请求队列的结束标志位设true

(3) 电梯线程结束控制

当电梯的请求队列结束且为空时, 电梯线程结束

(4) 双轿厢电梯调度线程结束控制

维护的请求队列结束且为空时, 该线程结束

3. 类设计

UML类图
看起来非常复杂, 接下来我将阐述一些重要的类设计思路
1. Person类 其实PersonRequest这个类基本已经包含了乘客请求的所有信息, 但是毕竟PersonRequest是官方的类, 我们不能改动, 不能在类中增添方法, 不利于代码编写和迭代, 故创建一个新的类Person管理请求, 该类需要包含PersonRequest的所有信息, 并增添诸如获取方向等属性和方法
2. WaitTable类 该类主要维护等待接受调度的Person列表, 用ArrayList装载, 设置标志位以用来结束线程, 还有一个count用来记录未完成的请求的数目, 如前所述也是用来结束线程的
3. RequestTable类 该类类似于WaitTable类, 也是维护一个Person列表. 考虑到电梯的请求列表和电梯里乘客的人的列表的相似性并且还有相同的操作, 我将电梯的请求列表和电梯里的乘客列表都用RequestTable表示. 该类还维护一个Person列表, 该列表为缓冲列表, 需要缓冲列表的原因与Reset有关, 这在之后相关章节会阐述
4. 线程相关类 这个在线程设计部分有讲, 这里不赘述
5. Strategy类 这个类用来处理电梯的运行策略, 我们采用电梯运行与策略分离的模式, 电梯每次即将开始做一个操作时, Strategy获取电梯状态, 并通过计算返回给电梯运行建议, 这里将建议封装成了一个枚举类Advice类, 电梯获取到建议后, 开始运行相应的操作, 这里电梯运行策略采用的是Look策略, 用过的都说好

二.线程同步控制

我在三次作业中均只用到了synchronized锁, 并且在前两次作业中只对方法加了锁, 在第三次作业由于某种实现原因对某个局部代码块上了锁
一般来讲, 有两种书写同步锁的方式

synchronized(object) {
}
// 对某个代码块上object锁
public synchronized void func() {
}
// 对方法上this锁

一个对象锁只能由一个线程获取, 当一个线程运行到一个上锁的代码块后, 需要看能不能获取到锁, 若能, 则拿到锁的所有权, 运行完代码后释放锁. 否则需要等待获得到锁的线程运行完对应代码块并释放锁之后才可能运行到同步块, 这个解决了线程安全问题

拒绝轮询——wait & notifyAll

有些情况下, 线程不必在做任何操作, 除非达到某种条件. 如果此时还让CPU轮询该线程, 极大损耗了CPU时间, 因此这里要引入wait 和 notifyAll操作.
wait和notifyAll都必须在同步块中使用, 且调用这些方法的对象是锁对象, 如果对象搞错会报错
wait将该线程阻塞并立刻释放锁, notifyAll将被该锁阻塞的所有线程全部唤醒

避免死锁

有一个很容易避免死锁的办法, 就是不要在一个锁的同步块中出现其他锁的同步块, 虽然这可能在其他实际场景中无法实现, 但是在本单元作业的需求是可以实现的

具体的同步设计

设计两个线程共享的类都应该有线程安全设计, 这里说明几个重要的同步设计

  1. WaitTable的同步设计
    WaitTable由于是InputThread和Schedule共享的, 所以需要有同步设计
    将WaitTable的所有方法全部上锁, 并写出waitRequest方法, 该方法封装了wait方法
    WaitTable的getRequest方法需要考虑避免轮询的情况, 在等待队列为空但还未结束时或者请求还没有完全处理完时, 应该进行等待, 故此时调用waitRequest方法, 如此可见设计到该wait条件的改变的方法的结尾都应加上notifyAll, 例如addRequest, setEnd(改变结束标志位), sub(count–)
  2. RequestTable的同步设计
    RequestTable由于是Schedule和Elevator共享的, 所以需要有同步设计
    该同步设计与WaitTable类似, 值得说明的是pushBuffer操作(把缓冲池的请求放入电梯的请求队列)可能要考虑notifyAll操作

三.调度策略

在第一次作业没有这个问题, 因为第一次作业指定了乘客上的电梯, 不需要调度器调度
从第二次作业开始, 每位乘客将需要调度器调度, 调度策略很大程度上影响性能
第二次作业采用了一个几乎是最佳的调度策略——影子电梯
第三次作业由于加入了双轿厢电梯, 不知影子电梯如何很好的实现, 于是摆了, 选择了完全随机策略

影子电梯

影子电梯, 顾名思义, 我们将克隆出电梯, 然后模拟电梯的运行, 不过这里的模拟运行, 不需要sleep, 而是用一个long类型的整数记录运行时间. 我们通过模拟运行, 计算出乘客上了这个电梯到目的地所花的时间, 我们选取花时间最少的电梯. 这个应该是所有主流方法中平均运行时间最少的策略了. 这个看起来较难实现, 事实上也确实如此. 我在第二次作业进行了尝试结果导致出现bug

随机策略

拼运气的策略, 实现超简单, 获取到0到5的随机数, 分配给对应电梯即可. 但是性能很差, 而且不幸的是在强测有一点tle(不一定因为随机, 但是这个tle不能稳定复现)

四.线程运行与交互

这个用一个图来说清楚吧(为了简洁明了, 该图省去了一些细节如重置, 还有为了方便, 这里没有采用时序图, 而是用了更直接的流程图表述)

DoubleCarElevator
开启
开启
开启
获取到请求将请求加入
将WaitTable的请求进行调度
Elevator接收RequestTable并运行
重置为双轿厢开启
重置为双轿厢开启
重置为双轿厢开启
调度
调度
获取到null线程结束, waitTable.setEnd
Schedule检测到waitTable空且结束且count==0, 线程结束
Schedule结束时将RequestTable标志位置0
RequestTable空且结束, Elevator线程结束
RequestTable空且结束, DoubleCarElevator线程结束
DE线程结束设置RequestTable标志位为true
DE线程结束设置RequestTable标志位为true
为空且结束, Elevator线程结束
为空且结束, Elevator线程结束
Elevator
Elevator
DoubleCarElevator
RequestTable
RequestTable
Main
InputThread
Schedule
Elevator
WaitTable
RequestTable

五.迭代过程

本单元作业迭代是一次失败的迭代, 经历了两次重构, 在这里我将重构经验和一些教训写在这里

第一次迭代

加入了重置要求和Receive约束.
这里有两点是非常容易错的, 第一点是重置时要返还请求并清空电梯, 这些还没到达目的地的乘客需要重新receive, 第二点是receive必须要在in之前, 第三点是电梯未收到receive请求不能输出arrive, 第四点是电梯重置时不能输出对应的receive
这次进行了第一次重构, 这主要是因为第一次作业的代码在处理乘客请求时搞得非常繁琐, 采用了HashMap还有单个获取模式, 不利于这次迭代, 于是进行了重构
比较好的架构本次迭代其实需要修改的内容不多, 只需在策略类和电梯类增加重置功能, 并在其他类中增加一些细节上的逻辑即可
我在做第二次作业时采用了一个不太好的解决方案, 把每个请求增加一个receive标志位, 判断请求是否输出了receive. 我将输出逻辑放在了RequestTable类里面, 用receive方法封装, 每次schedule时查看调度的电梯是否正在重置, 如果是, 不输出receive, 否则输出receive. 每次电梯进行下一步操作前再进行一次receive, 以将重置时接收到的请求receive一遍.
这样做看似可以实则一坨. 这样做首先会存在线程安全问题, 大大复杂化了程序的时序, 特别容易出现, receive时电梯正在重置的情况

第二次迭代

加入了双轿厢重置请求
这次作业进行了第二次重构, 如前所述, 我之前对重置的处理不好, 于是还是选择了重构
对于重置逻辑, 本次作业我不再采用receive标志位的概念, 而是有一个缓冲池buffer. buffer里面存的是reset时收到的请求, 还没有输出receive. 而RequestTable做addRequest操作时, 判断电梯是否重置, 是则将请求放入缓冲池且不输出receive, 否则输出receive并将请求放入请求队列. reset结束时, 将buffer里面的内容退回到请求列表并输出receive. 这个重置逻辑比较简单, 且不容易出错.
双轿厢重置除重置操作外, 其他操作都与普通重置比较像, 可以做重用

双轿厢电梯实现

大体思路是重置时创建两个新的电梯, 然后将原先的电梯线程关掉
这里有几个值得注意的点, 第一, 双轿厢的两个电梯, 除了最低楼层, 最高楼层, 起始楼层, 还有一个不能同时进入换乘楼层的约束以外, 与普通电梯没两样, 所以完全可以将两个轿厢用Elevator表示, 只需要让Elevator更具一般化, 修改一定的代码即可. 第二, 这里存在重置时请求分配给重置电梯, 但电梯已经开启两个新的电梯线程的情况. 这里采用的策略是调度器进行调度时, 是对RequestTable调度而不是对电梯调度, 电梯只是使用RequestTable. 而双轿厢电梯给他新增一个调度器, 将RequestTable的请求在根据具体逻辑分配给具体的轿厢. 也即两个新的电梯线程的RequestTable是新的, 但是接收原先旧的RequestTable的调度. 这样做的好处是不用修改调度器的调度逻辑
那么如何控制两个轿厢不同时到达换乘楼层呢.
其实有一个很简单的思路, 就是上锁, 将电梯进入换乘楼层, 开关门, 出换成楼层封装成原子的同步块, 这样就保证了只有一个轿厢在换乘楼层

架构迭代分析

这里用重构过的架构来说明.
在两次迭代中, 显然大多数都是对电梯功能的拓展, 所以需要修改的是InputThread(增加接收请求), Elevator(增加对应操作), Strategy(增加对应策略), 而其他地方少有改动
如果有未来的拓展, 如果是单纯的拓展电梯的功能, 那其实按部就班的按照前两次迭代的方法即可. 但如果它要新增电梯种类等复杂迭代操作, 那可能还要新增一些电梯类甚至多开线程, 就像第二次迭代那样

六.bug分析和debug

如前所述, 我的bug集中在第二次作业, 第一次作业强测互测均无bug, 第二次作业两次测试一共被hack了12次, 其bug就是receive实现的线程安全问题和影子电梯实现问题, 这两个在之前都提到了, 这里不赘述了, 第三次作业由于运气问题被hack了tle, 再交一遍就过了
这部分的重点是debug
多线程的debug很难, 尤其是线程安全问题和死锁问题, 比较难debug, 并且单步测试也不行.
所以我们还是使用printf大法吧
如果出现程序没结束, 先看看线程有没有结束, 看是卡在哪个线程, 在线程出口处printf一个标志
如果卡死循环, 可以在循环处输出标志来看
别的不赘述了, 相信大家对printf大法应该都十分熟悉

七.心得体会

在本单元的三次作业中,我深刻体会到了线程安全和层次化设计的重要性。通过设计电梯调度系统这个项目,我对多线程程序设计有了更深入的理解。首先,在架构设计方面,我学会了如何合理地设计不同线程的功能和交互。从主线程、输入线程、电梯线程、调度线程到双轿厢电梯调度线程,每个线程都有其特定的功能和责任。通过合理的线程设计,整个系统能够高效运行并保持线程安全。其次,在线程同步控制方面,我学会了如何使用synchronized锁来确保线程安全。避免死锁、拒绝轮询以及避免多个锁的嵌套是保证程序正确运行的关键。通过合理的线程同步设计,可以有效避免竞态条件和数据不一致的问题。调度策略是另一个重要的方面。在项目中,我尝试了影子电梯和随机策略两种不同的调度策略。通过实践,我了解到不同的调度策略对系统性能的影响,以及如何选择合适的策略来优化系统性能。最后,在迭代过程中,我体会到了重构的重要性。通过不断迭代和重构,我能够改进代码结构、优化算法逻辑、进行层次化设计,提高系统的可维护性和可扩展性。同时,通过bug分析和debug,我学会了如何定位和解决多线程程序中的问题,提升了自己的调试能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值