269
社区成员




title: OO第二单元总结-电梯调度
date: 2025-04-12 20:29:24
已同步更新至个人博客https://s7777777h.github.io/,欢迎围观qwq
有 6 部电梯,在某些时间节点,会有一些乘客发出请求:乘坐指定电梯 e,从楼层 x 到楼层 y,且每个乘客有一个优先级 p。当电梯开门,关门,到达任一楼层,乘客进出电梯时,需要输出特定的信息。每一部电梯的移动速度给定,同时电梯的开关门之间有一定的时间间隔。目标是将所有乘客送到目的地(正确性),且使得系统总运行时间,乘客优先级加权等待时间,电梯耗电量(与开关门次数和移动次数有关)最小化。
乘客不再指定乘坐哪一部电梯,而是由系统自行决定。同时,新增临时调度(SCHE) 事件,要求指定电梯以特定移动速度,从楼层 x 移动到楼层 y ,到达 y 后使所有乘客离开,关门并等待一秒,结束临时调度事件。SCHE-BEGIN 必须在输入 SCHE 后短时间内输出(不能有两次 ARRIVE)。临时调度期间不能有 RECEIVE和中间楼层的开关门。
同时,新增了 RECEIVE 约束,当将乘客分给某个电梯时,要输出一条分配信息,当电梯没有任何的 RECEIVE 时,电梯不能移动(防止自由竞争策略)。
RECEIVE 的解除有两种情况:当乘客离开电梯时会解除其的 RECEIVE ;电梯进入 SCHE 事件时会将所有已经 RECEIVE 的人全部解除。当RECEIVE解除后,乘客必须重新分配电梯,同时电梯如果没有RECEIVE任何人,电梯不能移动。
第三次迭代增加了双轿厢升级事件(UPDATE),其指定两个电梯和一个楼层。发出请求后,两个电梯会停止,放出内部的所有人,取消所有人的 RECEIVE ,接下来两个电梯停止一秒后分别传送到共享楼层的上下楼层位置。接下来两个电梯的速度加倍,且两个电梯的楼层移动范围加以限制,均无法越过共享楼层。同时两个电梯加以限制不能相撞。
注,此处的 Elevator 泛指六个电梯线程,而 ElevatorA,ElevatorB 主要用来展示 UPDATE 时的线程工作状况。
线程分为三部分:输入线程(Main),分配器线程(Distributor),电梯线程(Elevator)。
采用典型的生产者-消费者模式,为托盘建立 waitingList 类,用于存储管理三种类型的 Request。
每个电梯同时自身含有两个waitingList,一个存储已分配,但尚未进入轿厢的乘客,一个存储轿厢内的乘客。
输入线程将请求放在托盘里,分配器线程将托盘里的请求根据一定的规则分配到不同的电梯中。
Tool 类负责一些静态的杂碎工具方法,如将字符串的楼层映射到数字的楼层,以及反转电梯的运行方向等。
Debug 类只有一个静态方法,返回一个布尔真值,当这个真值为1,程序输出日志,方便调试。
用于官方包的 PersonRequest 类没有存储当前楼层的成员,故增加 MyRequest 类,用于存储当前楼层。
由于每个电梯在SCHE和UPDATE事件中为不可分配状态,故需给每一个电梯设立有效位 valid
,在分配时,会检查是否所有电梯都无效,如果是就进入 wait 。在电梯的 valid
被置为 1 时,会叫醒分配器,以保证分配可行。
对于 UPDATE 和 SCHE (以及第一次作业的乘客请求),目标电梯已经指定,所以直接从取出电梯的候乘表,加入并 notify 即可。
对于需要我们分配的请求,我专门创建了 int xxx_distribute(MyRequest request)
方法。对于一个请求,返回分配给的电梯序号。以封装的方式,方便切换分配策略,来比较各种分配策略的优略。
经过综合考虑,本人的分配策略最终采用了基于路程的分配策略,即直接假定电梯做大简谐运动(触顶返回),计算不同电梯距离请求所在楼层的距离,选取距离最小的。
此方法实现简单,且性能相对不劣,但是核心问题在于容易被卡,攻击者可以轻易构造一组数据让你的一部电梯RECEIVE所有人。同时该方法相同数据,每次测试的误差较大(我就因为这个性能 RTLE 了一次)
注意:电梯是否有效需要多次检查,第一次是检查是否有有效电梯,如果没有就 wait ,第二次是分配策略分配只考虑有效电梯,第三次是分配行为前一条语句要再次检查。第三条尤为重要,且务必要将检查和分配套在一个 synchronized 块中,为电梯的 valid 上锁,否则经常会出现以下的情况:电梯在通过有效性检查进入 if 分支,在正准备分配前一瞬间被设为无效,再分配便会出问题,这是典型的 check-act 竞态条件。
电梯调度策略整体采用LOOK策略,在细节上做了一点点改动。
LOOK策略的详细机制我直接搬用 Gemini 2.5 Pro 的介绍。如有需要可以自行参考。
我们可以将 LOOK 策略的运行过程分解为以下步骤:
- 确定初始方向:
- 如果电梯当前是空闲的,它会等待第一个请求(来自轿厢内部或楼层按钮),并根据该请求的位置确定初始运行方向。
- 如果电梯已经在运行中,它会保持当前方向。
- 沿当前方向移动:
- 电梯开始沿着确定的方向移动(例如,向上)。
- 服务同向请求:
- 在移动过程中,电梯会停靠并服务所有满足以下条件的楼层:
- 该楼层有 轿厢内指令 (乘客在电梯内按下的目标楼层按钮)。
- 该楼层有 与电梯当前运行方向一致的层站呼叫 (例如,电梯向上时,停靠发出向上请求的楼层)。
- 同时,电梯会记录下所有 反方向 的请求,以便在改变方向后处理。
- 查找并到达转向点 (关键步骤):
- 电梯持续沿当前方向移动,并不断 "向前看" (Look)。
- 它会找到当前方向上 最远的一个请求 (无论是轿厢内指令还是同向的层站呼叫)。
- 当电梯到达这个 最远请求所在的楼层 并完成服务后,它会再次检查:在这个楼层之外、更远的地方(沿当前方向)是否还有请求?
- 如果没有更远的请求了,那么这个楼层就是本次单向行程的 转向点。电梯就在这里停止继续前进(即使还没到顶层/底层)。
- 改变方向:
- 在转向点,电梯改变其运行方向(例如,从向上变为向下)。
- 沿新方向移动并服务:
- 电梯开始沿新的方向移动。
- 重复步骤 3 和 4:服务新方向上的所有同向请求,并找到新方向上的最远请求点作为下一个转向点。
- 循环往复:
- 电梯就像一个来回摆动的指针,在上下两个 "最远请求点" 之间来回运行,持续服务所有请求。
- 如果没有收到任何请求,电梯会进入空闲状态,等待新的请求。
我将策略和电梯进行分离,单独建立 Strategy 类,每个电梯都有一个实例化的 Strategy 对象,通过 getAdvice 方法,基于电梯的状态和请求列表,给出电梯相应的指令。分为:移动,反转移动,开门,关门,等待,临时调度,轿厢升级七种建议。电梯通过调用相应的功能函数以完成其功能。
加权乘客等待时间时课程作业性能评价的一个重要指标。在最小幅度更改原策略的原则上,我做了些许改动来适配优先级。
具体来说,当一个电梯到达任何一个楼层,从 Strategy 得到 OPEN 的指令时,我会将所有人移出电梯,从电梯外的所有人中选取六个优先级最高的,和电梯运行相同的乘客进入电梯 (我觉得我要被乘客骂死了)。这样能保证对于 LOOK 策略的改动十分小,且不会对电量和总运行时长有较大的影响(理论上会有浮动,但是浮动的期望应当是 0),同时能够尽量满足高优先级的需求。
SCHE 的实现与双轿厢电梯较为类似,但更为简单,这里不在赘述。
双轿厢的启动其实较为复杂,因为需要保证两个电梯都停下,所有人都出来,且电梯关上了门才可以启动电梯。其核心在于启动的同步。这里我设置了主从电梯的概念,用于电梯的启动。由主电梯(选哪个都行)负责输出启动信息。
具体来说,每一个电梯自身有一个updateReady的标志,但是主从电梯的 updateReady 含义不一样。
从电梯:表示已经完成了清空乘客等前置要求,随时可以升级。
主电梯:表示已经输出了 UPDATE-BEGIN ,已经进入了升级状态,标志着升级事件的开始。
在启动时,从电梯在执行前置操作后,将 updateReady 设置为真,等待主电梯 updateReady,在得到主电梯的 updateReady后,执行真正的升级操作,即取消所有等待乘客的 RECEIVE ,设置速度,顶楼和底层,进行一秒的 sleep。
主电梯在执行完前置操作后,等待从电梯的 updateReady 为真,再输出 updateReady 为真,接下来的操作和从电梯一样,只是在末尾输出 UPDATE-END。
电梯的结束同样需要进行上面的同步控制,逻辑几乎相同,这里不再赘述。
PS:结束的同步控制其实我的代码没有做,写博客时才发现有这么个Bug qaq,幸好强测和互测都没出事。
在双轿厢电梯的迭代中,电梯需要增加顶层和底层两个变量,在分配时,只有 当前楼层在顶层和底层之间 才会进行分配。
如果目标楼层在当前电梯的运行范围内,和往常一样调度即可。
如果目标楼层不在运行范围内,则将乘客运到共享楼层后。将乘客放回托盘,接下来由调度器分配即可。
电梯的防撞通过给共享楼层加锁实现,为了让锁更加灵活,这里我使用了唯一一处 RetreentLock ,两个电梯共享一把 sharedFloorLock,当电梯无论通过何种方式进入共享楼层之前,都会上锁;电梯无论通过何种方法离开共享楼层,都会解锁。
为了防止电梯来在共享楼层不走导致另外一个电梯被长期阻塞,我设置了如果电梯内部和外部都没有任务,且在共享楼层,getAdvice 给的建议是 MOVE/REVERSE,而不是 WAIT。显然这种情景以外,电梯都会离开。只要给这种情景加特判即可。
多线程程序的优雅结束需要线程之间的协作交流,这里展示一下我的结束方式。
InputThread:下一个输入为空时自然结束
Distributor:结束需要满足两个条件
(1) 输入线程结束
(2) 输入的所有请求被全部完成 - 设立全局共享变量
Elevator:分配器结束后获得结束许可,在有结束许可,且候乘表和内部为空时结束
三次作业迭代中不需要变化的点:
Elevator 的 move, getOut(使所有乘客出去) 和 getIn(使可以进来的乘客进来) 方法等控制电梯行为逻辑的方法完全不需要变
输入线程的主要逻辑不需要变
三次迭代中需要显著变化的点
第二单元的多线程作业,线程安全相当重要。
我在自己的代码中几乎全文都用的 synchroinzed 块,只有对共享楼层的加锁使用了 RetreeantLock。我强烈建议大家不要学习这一行为,建议大家从最初就使用 RetreeantLock 。它最大的优点是可以自由调整粒度,我可以在一个类的方法中上锁,在另一个类的方法解锁(当然一定要保证上锁后一定会解锁)。这是 synchronized 块做不到的。我的共享楼层加锁就是由于这个原因不得不使用 RetreeantLock,将锁混用实际上时很危险的。
在加锁时,我遵守的原则是,共享变量读写全加锁,不出现嵌套锁(然而还是有一些隐蔽的导致我Debug非常之久),事实上能满足这些,基本上就不会出现死锁问题和数据冲突问题。
写方法时,个人不是很建议直接给方法本身加锁,而是给具体某个读写语句和if块加锁,这样精准加锁可以大大提升性能。这样写出来的方法时安全的,就没有必要在调用它时再加锁,防止死锁。
if(A) then B
** ,如果 A 条件中涉及到一些共享变量,一定要对其加锁我在这一单元中本地测试出现的 bug 主要是隐藏的嵌套锁导致的死锁。通过统一使用线程安全的方法,不在方法外套锁可以很好的解决。
在 hw6 的强测中,我爆了我 OO 生涯中第一个强测点(希望是最后一个qaq),细究其原因竟是电梯策略较慢导致 RTLE (本地测了4000遍相同的数据发现不是死锁)。我的策略会有些时间上的波动,好多样例都 56s / 60s ,偶然出现了一个特别非的卡到了 60s 以上。我还想着保正确性不卷性能,结果性能倒扣我正确性 qaq。
本单元中我自己总结出了一套丰富的 debug 方法论,如有兴趣可以移步文章 多线程Debug经验分享 | s7777777h's Blog。
层次化的设计会让整个迭代变得很舒服。通过设计分工明确的方法,可以精确找到每一次迭代自己要做什么,也能让自己少出 bug。
Unit1 的作业已经能够让我对层次化设计有了成熟的认识,这让我在第二单元的迭代更加如鱼得水,每次的迭代都十分舒适。
线程安全初识感觉非常可怕,十分容易出错,但是在实际编写时,只要心里明白,哪些变量是共享的,然后在读写时注意对其加锁就好。
每一个函数都设计好,使其线程安全,上层函数就能更好拿它们组装出一个线程安全的上层结构,进而形成线程安全的整体。
从小处做起,每一步都很重要。
碎碎念
OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过OOUnit3Unit4强测互测全过