309
社区成员
发帖
与我相关
我的任务
分享在电梯多线程调度的三次迭代中,随着需求的增加(单电梯 -> 多电梯调度 -> 动态双轿厢及重置),多线程的并发冲突点不断增多。整个系统的核心难点在于保证共享资源(请求队列、电梯状态、共享井道)的线程安全,同时避免死锁和 CPU 轮询空转。
整个系统中,我主要采用了两种锁机制来应对不同的并发场景:
synchronized): 这是系统中最常用的锁,主要用于保护具有明确归属的共享对象(如 RequestQueue 队列对象、全局 elevators 列表)。选择它的原因是实现简单且符合面向对象思维——谁的数据谁负责同步。F2Lock): 专门为双轿厢模式下的 2 楼(换乘层)防撞设计的锁。由于 2 楼是物理意义上的共享资源,且涉及到两部独立运行的电梯线程的互斥访问,使用独立的 F2Lock 对象进行明确的 acquire() 和 release() 能够更精确地控制物理碰撞临界区。系统中的同步块设置严格遵循了“最小化临界区”和“读写一致性”原则。以下是核心同步块的场景分析:
RequestQueue 实例(即 this)。RequestQueue.java 中的大部分方法(如 addRequest, getOneRequest, waitForNewRequest)。ArrayList 的直接增删改查。当 InputThread(生产者)调用 addRequest 时,同步块保护了 requests.add() 操作不被打断,紧接着的 this.notifyAll() 语句负责唤醒正在等待的电梯。相反,消费者获取请求或发现队列为空时,利用 wait() 挂起,这完美配合了同步块,避免了 CPU 轮询造成的资源浪费。elevators。DispatcherThread.java 中的 dealPassenger 方法。synchronized (elevators) 内部包含了遍历所有 12 部电梯、读取当前运行状态和计算分配分数的语句。这里的锁并不是为了防止乘客被重复分配,而是为了保证在读取状态(尤其是处理 UPDATE 状态动态生成备用轿厢时)列表的拓扑结构是一致的,防止遍历时遇到并发修改异常或读取到空指针。F2Lock。ElevatorThread.java 中的 moveFloor 和 moveInDoubleMode 方法。nextFloor == 2 时执行 f2Lock.acquire(),离开 2 楼后执行 f2Lock.release()。被这两个操作包裹的中间语句是 Thread.sleep(400) 和 currentFloor = nextFloor。这段语句在物理上模拟了电梯占用 2 楼的过程。这种锁与语句的绑定极其直接,临界区严格限制在“电梯处于 2 楼的生命周期”内,确保同一时刻同一井道只有一部电梯能执行这段语句。myQue。ElevatorThread.java 中的重置处理方法(如 dealcaserepAc 等)。synchronized (myQue) 内部包含了打印 MAINT1-BEGIN 以及调用 returnQueuedPassengers() 将未上车乘客退回全局队列的语句。把这些语句打包进一个同步块,保证了“电梯正式进入维修状态”与“拒载并退回乘客”这两个动作在宏观上的原子性,防止调度器在电梯即将进入维修的瞬间继续向其专属队列中塞入新乘客。随着三次作业的迭代,同步块的作用范围其实是在不断缩小的。一开始可能是粗粒度地锁住整个系统,但为了性能,最终演变成了目前的数据分离与细粒度锁:全局调度器只锁分配逻辑,电梯内部只锁自身队列,特定楼层互斥使用专属锁。这种解耦不仅提高了并发度,也大大降低了死锁的风险。
在三次电梯作业的迭代中,系统的调度核心从最初简单的单电梯“接单”,演变成了复杂的“多电梯动态协同调度”。为了平衡各个电梯的负载并追求更优的性能表现,我采用了宏观与微观相结合的调度体系。
我的调度器(DispatcherThread)在系统中扮演着“交通枢纽”的角色,完美契合了生产者-消费者模型,并在线程间起到了关键的解耦作用。
InputThread 是第一层生产者,它将解析到的请求放入全局共享的 waitQueue。调度器作为第一层消费者,不断从 waitQueue 中通过 getOneRequest() 获取请求。如果队列为空且未输入结束,调度器会因 wait() 而阻塞,不浪费系统资源。elevators 列表获取状态快照,计算最佳电梯,并将乘客放入目标电梯的专属队列(elevatorQueue)。对于特殊的工人请求(如检修、改造、回收),则根据指令中指定的 ID 直接精确投递。电梯线程作为第二层消费者,从自己的专属队列中取客运行。三次作业的性能评价指标从单纯的“运行时间”,逐渐过渡到“运行时间 + 耗电量”的综合考量。为此,我的调度策略分为两层:宏观分配(Dispatcher)与微观运行(Strategy)。
微观层面:局部 LOOK 算法(Strategy.java) 单部电梯的运行采用了标准的 LOOK(ALS)算法变种。电梯不会盲目走到顶或底,而是通过 hasReqAhead() 判断当前方向前方是否还有需求(包括车厢内的目的地和车厢外的呼叫地)。
宏观层面:权重评分动态分配机制(calculateScore) 这是调度器分配乘客的核心算法,通过给每部电梯打分来决定归属。满分为基准分,按以下几个维度进行扣分或加分,巧妙地平衡了时间与电量:
score -= elevator.getWorkload() * 250;score -= distance * 10;score += 150 或 score -= 150-10000)剔除了它们无法到达的楼层请求,保证了分配的有效性,避免电梯接到不可能完成的任务而产生逻辑死锁。总结: 总体而言,这套调度策略没有采用死板的静态分配(如对 ID 取模),而是基于电梯实时状态的动态评分。它通过惩罚高负载来争取“时间得分”,通过奖励顺路和惩罚远距离来争取“电量得分”,是一种在复杂并发场景下鲁棒性极强、且能兼顾多维度性能指标的实用启发式调度算法。
经历了三次电梯作业的迭代,我从最初对多线程的一无所知,到后来能够熟练运用各种锁机制,最大的体会是:多线程编程不仅是加锁的技术,更是一种对数据流动和对象生命周期严格把控的艺术。而层次化设计,则是这门艺术的基石。
在单线程时代,我们只需要关心逻辑是否正确;但在多线程的电梯系统中,时间成为了一个不可控的维度。任何一个共享变量的非原子操作,都可能引发灾难性的并发 Bug(如把同一名乘客分配给两部电梯,或者系统无法正常终止)。
结合三次作业的迭代,我对线程安全的理解经历了以下几个层次:
synchronized)保平安。 在架构中,RequestQueue 是最核心的共享资源(被输入线程、调度器线程和电梯线程共同读写)。我深刻体会到了将共享资源封装为线程安全类的必要性。通过在 RequestQueue 的方法上加 synchronized 关键字,将内部的 ArrayList 操作变为原子操作,使得外部调用者(如电梯和调度器)不需要关心底层的并发冲突,实现了“高内聚”。while(true) 死等队列的错误,导致 CPU 占用率飙升(CTLE)。通过熟练掌握 wait() 和 notifyAll(),我的系统实现了优雅的事件驱动:当队列为空时,调度器或电梯主动让出 CPU 进入等待池;当新乘客到来或系统输入结束时,再由队列精准唤醒。这种“按需唤醒”是多线程高效运行的关键。synchronized 已经无法满足需求。F2Lock。电梯仅在跨越 2 楼的生命周期内持有该锁,最大程度地保留了双轿厢的并发性能。AtomicInteger totalRequests。利用底层的 CAS(Compare-And-Swap)机制实现了线程安全的计数,避免了因为频繁加锁解锁全局计数器带来的性能损耗,优雅地解决了系统安全退出的判定问题。如果在第一次作业就把调度逻辑、电梯运行逻辑、输入逻辑揉在一个类里,那到了第三次作业加入“重置”和“双轿厢”时,代码必定会面临重构甚至重写的命运。我的架构之所以能平稳迭代,得益于严格的层次化设计与单一职责原则(SRP)。
我的系统主要分为三个清晰的层次:
DispatcherThread + InputThread 这一层只负责“揽客”和“分发”。调度器不关心电梯内部是怎么运行的,它只看电梯暴露出的一些宏观状态(当前楼层、工作量、是否维修中)。这种设计使得新增调度策略(比如从随机分配改为按分数分配)时,完全不需要修改电梯的内部代码。ElevatorThread 电梯线程是纯粹的“打工人”,它内部维护了一个状态机(NORMAL, REPAIR, UPDATE, DOUBLE 等)。当收到改造指令时,电梯只需改变自身状态,将乘客退回,并自行孵化出备用电梯。电梯的重置与分裂逻辑被完美封装在这一层,没有让调度器去承担这些繁琐的物理限制逻辑。Strategy 我将电梯“下一步该往哪走”、“该不该开门”的逻辑完全抽离到了一个纯静态的策略类中。ElevatorThread 只需要把自己的当前状态(当前楼层、方向、内外队列情况)传给 Strategy,然后无脑执行返回的 Advice(UP, DOWN, OPEN, WAIT)。这使得微观的 LOOK 算法被彻底解耦,即便未来需要换成更复杂的扫描算法,也只需替换这个工具类。总结来说,层次化设计的本质就是“画地为牢”: 让数据只在规定的通道流动,让每一个类只做自己分内的事。这样在面对三次作业极其复杂的业务堆叠时,才能做到牵一发而不动全身。
在三次作业的迭代中,我遇到过几个非常典型的并发 Bug,它们在最终的代码中都得到了彻底的修复:
while(isEmpty()) { continue; } 死循环等待。这导致线程疯狂占用 CPU 资源空转。wait() 和 notifyAll() 机制。如最终代码中 RequestQueue 的 getOneRequest() 所示,当队列为空且未结束时,当前线程调用 this.wait() 进入阻塞池交出 CPU 控制权;直到 InputThread 放入新请求调用 notifyAll(),线程才会被重新唤醒。for-each 循环或者常规 for 循环遍历 passengersInside 列表,并在循环内部直接调用 passengersInside.remove(p) 来让乘客下电梯。由于 ArrayList 不是并发安全的,且在迭代过程中直接修改结构,破坏了内部的 modCount,从而抛出异常。Iterator(迭代器)进行安全删除。在 ElevatorThread 的 dropArrivedPassengers 和 kickAllPassengersOut 方法中,我都规范地使用了 Iterator<Passenger> it = passengersInside.iterator(); it.remove();,完美规避了这个问题。F2Lock,并且极度缩小了该锁的临界区。在 moveInDoubleMode() 方法中,仅仅在 nextFloor == 2 到离开 2 楼的这一小段物理移动时间内上锁。不让 F2Lock 与其他任何同步块(如 synchronized(myQue))发生嵌套,从根本上打破了死锁的环路等待条件。多线程的 Bug 往往“测不准”——平时运行好好的,一交上去就挂,加了断点它又不复现了。经过这三次作业的折磨,我总结出了一套行之有效的 Debug 组合拳:
TimableOutput 的日志打印是多线程 Debug 的第一生产力。IN、OUT、ARRIVE 等关键字,可以瞬间校验“是否有人没下电梯”、“是否有电梯瞬移”、“双轿厢是否相撞”。wait() 没有被唤醒。JConsole 工具连接正在运行的 Java 进程,直接查看 “线程 (Threads)” 面板。JConsole 会直接标红死锁的线程,并清晰地展示出是谁持有了什么锁、又在等待什么锁。这对于排查 synchronized 嵌套导致的死锁堪称神器。All(挂起所有线程)改为 Thread(仅挂起当前触发断点的线程)。[0.0] 投入 50 个前往不同楼层的乘客,测试分配器的瞬间抗压能力和线程创建开销。UPDATE 或 REPAIR 指令,刻意刁难电梯的状态切换逻辑,检验退客机制(kickAllPassengersOut 和 returnQueuedPassengers)是否会丢失乘客。相比于第一单元的内容,第二单元堆大模型的依赖程度较为减弱,大部分情况下采用的都是自行尝试理解任务需求,并在大模型的更正下进行初次的独立尝试,大模型进行评判之后指导我进行优化;在debug方面还是比较依赖大模型,特别是代码体量变大的情况下,亟需大模型的辅助。主要用gemini 3.1pro作为辅助工具。
感觉多线程任务难度较大,在debug阶段非常痛苦,不太容易发现具体的错误发生点,希望将来能够以更合适,更方便学习的方式进行多线程任务的学习与挑战。