304
社区成员
发帖
与我相关
我的任务
分享本文约 3000 字,阅读预计需要 10 分钟。
欢迎大家前往 OOpre 2025 与 OO 2026 讨论区阅读我的往期文章。
不手写同步就是最好的同步。
在 hw5 中我完全没有使用 synchronized 关键字,而是完全依赖消息队列 + BlockingQueue 来实现无轮询 + 无死锁的生产者-消费者流水线。
同时,我使用毒丸实现各线程的优雅停止。在 InputHandler->Dispatcher->Elevator 流水线中,前序线程停止时,会向后序线程的消息队列中投递一个 EndRequest(毒丸),后序线程收到后,会完成剩余的工作,释放可能的资源,随后停止,这样就解决了线程安全停止的问题。
hw5 同样没有使用到 wait() 和 notify()。
由于 hw6 我使用了影子电梯,在获取某个电梯的快照时,需要确保数据的一致性,因此我引入了项目中的第一个锁。这个锁仅仅对电梯的一部分数据的写操作和快照创建的读操作加锁,保证创建快照时电梯不能继续写入,以免向影子电梯传入不一致的数据,并不涉及控制流。
控制流仍然依靠消息队列实现。如维护请求依靠 Dispatcher 向 Elevator 的 BlockingQueue 投递 MaintainRequest,并将维护计数加一,Elevator 完成维护后再向 Dispatcher 的 BlockingQueue 投递 MaintainDoneRequest,让 Dispatcher 将维护计数减一。这样所有的阻塞都发生在各个线程私有的消息队列上,实现了架构中无轮询死锁。
hw6 中出现了 Elevator 向 Dispatcher 的回传,使得流水线不再是线性依赖关系,单纯的毒丸也不再适用。由于我的逻辑中,只有进入维修的电梯才会向 Dispatcher 回传请求,因此通过上面的维护计数机制,在输入结束后,Dispatcher 等待所有维护请求结束(此时不会再有请求回来)再结束,就实现了安全停止。同时我把维护计数设置为了由 Dispatcher 私有维护而不是 Dispatcher 和 Elevator 共同维护,避免了潜在的锁竞争。
hw2 仍然没有使用到 wait() 和 notify()。
hw7 要求实现双轿厢电梯不能在 F2 相撞的功能,因此我引入了项目中的第二个锁,一个单独的锁类,并对每个电梯井实例化,以保证两个轿厢只能交替进入 F2。这个锁类的结构也非常简单,无法获取锁时 wait,释放锁时 notifyAll(),这也是项目中唯一一次使用 wait-notify。
其他的控制流继续依赖消息队列,包括电梯升级到双轿厢的 UpdateRequest 和和回收到单轿厢的 RecycleRequest,仍然由 Dispatcher 投递和维护计数,Elevator 返回消息。但由于优化的需要,Dispatcher 不得不使用带延时轮询的设计(在无电梯可用时,Dispatcher 需要暂存请求)。实测没有出现 CPU 超时。
hw7 的线程安全停止只需要加上 Update-Recycle 的计数(仍然只有这时候电梯会回传请求),让 Dispatch 等待输入和两个计数都完成再停止,就解决了线程安全停止问题,总体上是简单延续 hw6 的思路。
我的调度器选择是影子电梯。具体来说,我将往年学长的 blog 作为参考资料输入 AI,得到了现在的成品。
由于感觉写出好的调度器实在太难,我选择直接照抄往年学长的调度思路。hw6 只考虑最小化运行时间,放弃开门关门电量等等因素,hw7 直接把参数改成 运行时间/楼层,让双轿厢电梯能参与比较。遇到的困难主要是调度器过大过重,难以修改和优化。(原因是电梯本身就是一个巨大的状态机,代码复杂度难以优化)
强测出错的数据点太多,所以没有过于关注性能分。
架构上,调度器是一个架构确定的独立的类,从输入线程的消息队列获取解析好的输入请求,再按照给定的策略转发到不同电梯的消息队列。只要通过构造函数注入不同的调度逻辑,就可以实现不同调度策略,也方便比较。然而由于时间因素,我没有实现其他的调度策略(如自由竞争),这就让调度器的架构设计失去了意义。
hw5 的作业要求非常简单(相比 hw6 和 hw7,或许有点太不平衡了)。我只出现了一个 bug:由于没考虑数组下标从 0 开始,导致访问 6 号电梯时产生越界。
令人震惊的是,中测和强测居然没有任何数据点访问到了 6 号电梯,导致有如此严重 bug 的代码能够进入 A 房。在我看来这是数据组助教的一个重大疏忽。
hw6 中,收到维护请求的电梯需要把无法处理完成的请求回传给主队列,这就引入两个 bug 点:
Dispatcher 不能过早下班。在输入结束后,Dispatcher 需要确定不再有电梯会回传请求才能停止。Dispatcher 不能急着投递所有请求。如果电梯不可用或负载过高,应该停止向其派发请求。如果所有电梯都不能接受请求,则应当暂停派发请求。第一个 bug 在中测截止当晚就被我自己的评测机发现,这大大改变了我的开发思路。从作业优先到评测机优先。
第二个 bug 仍然来自人类手工构造的 corner case 而不是评测机的随机数据,这警示我们不能完全依赖评测机。
hw7 中,双轿厢电梯需要启动和停止备用轿厢线程,这就产生了新的 bug。轿厢回收时,备用轿厢应该先把剩余的请求回传,再自我结束,我的 AI 搞反了这两者的顺序,造成大量乘客丢失。
这个 bug 和其他三个 bug 一起,在中测截止当天就被我的评测机发现。然而由于当日我恰巧遇到了 rate limit,只能尝试手工排查和修复 bug,导致最终修复也没来得及完成。
三次作业中我总结出的测试流程是:
此外,随着作业的推进,我从“先完成功能代码,再开发评测机”逐步转向了“先开发评测机,再编写功能代码”,原因之一是我认为在开发评测机的过程中,我学到了比写作业更多的知识,原因之二是课程组提供的中测强度过低
,不够可靠。
我觉得 Java 在资源所有权这一方面真的是个糟糕的语言。
尽管 Java 没有明确的所有权机制,而是通过 GC 管理资源,我仍然尽可能保证代码中资源的所有权是明晰的。
具体地说,我的 Elevator 选择了实现 Runnable 接口而不是继承 Thread 类,试图在语义上明确 Elevator 承载的是具体的任务。使用 Elevator 初始化 Thread 对象,Thread 对象才是资源的真正所有者,也是程序员需要监测和考虑其声明周期的对象。
在解决线程间通信的问题时,我没有使用 synchronized,wait-notify,interrupt 等较底层的机制,而是使用 BlockingQueue,将要传递的消息打包成 Request 发送出去。
这种模式的优点有许多,一是实现了生产者和消费者的解耦,双方完全不需要关心对方的状态;二是集中了线程状态的关注点,如果一个线程不在运行也没有停止,那么它一定阻塞在自己的消息队列上,绝无其它可能,这就有效避免了死锁。
然而,使用消息队列也有缺点。一是不够灵活,线程间通信变得复杂,就需要注册更多的 Request 派生类;二是不能保证消息处理的及时性,时间上靠后但是更重要的信息(如维护请求)可能被延迟处理造成超时;三是使我错失了练习基础锁的机会,导致 OS 课上学习困难。
另外,我认为如果使用 PriorityBlockingQueue 替代 BlockingQueue,可以一定程度上解决消息处理的及时性的问题,然而此时 hw7 已过,没办法去实践了。
或许使用 C++ 理解基本的锁更好。C++ 有语义明确的 std::mutex,不是和 Java 一样对象内部隐式携带锁。
电梯的维护,双轿厢更新和回收都有大量冗长复杂的逻辑(主要是一堆 sleep 和输出,信息密度低),很容易使 Elevator 类超过 CheckStyle 限制。为此,我去学习了策略模式,将这些特定的行为拆成独立的类,将电梯与电梯行为解耦,实现了文件长度压缩。
很遗憾我到 hw7 才知道策略模式。如果我 hw5 就知道的话,一定会把所有行为都写成单独的类,而不是只有维护,更新和回收是单独的类。
OO 已死……?
—— 沃 · 兹基硕德
我要再次重复这句话:在好的架构设计控制与 TDD 工作流下,AI Agent 的开发效率实际上已经完全超过人类。
本单元我尝试把评测机接入 AI,让 AI 自己编写代码,运行评测机,阅读返回结果,查找 bug,再修改代码直到正确。结果表明这极大扩展了 AI 的能力。
在 hw6 和 hw7,我基本上只是把指导书复制粘贴到 Markdown 格式,要求一个 Agent 根据指导书更新评测机的 Checker 模块(基本上就是状态机模拟所以非常简单),调试通过后,交由另一个 Agent 生成代码,并且让它自己利用评测机检查,这样 AI 就能生成许多可以完成功能要求的代码。我只需要根据任务的不同,把任务分派给不同的模型(我使用的主要是 Github Copilot Edu 中的 GPT-5.3 Codex 和 GPT-5.4 Mini),让他们自行处理即可,很多时候模型甚至就挂在后台,人可以去做其他的事情。
然而,由于 Github Copilot 在本月突然收紧了使用限制,我的 Copilot Edu 套餐无法继续承受 AI Workflow 带来的巨大消耗,许多代码生成到一半后因 rate limit 而暂停,只能由人工补全,这导致最终生成的代码质量不高,存在许多隐性 bug 且难以阅读和修改(没有时间重构,不得不在 AI 已完成的基础上继续开发)。这对我来说是一个巨大的打击,但也告诉我不能完全依赖 AI,毕竟还是存在极端情况 :(
此外,对 AI 的使用方法也很大程度决定了它的能力。AI 不擅长阅读极长的 stdin 和 stdout 记录,因此我让 AI 调整了评测机,使其输出详细的报错信息(某行某乘客重复下客等),并在 prompt 中加入了以下几条:
print 或 throw,向控制台打印调试信息。这些 prompt 产生的思路是让 AI 模仿我手动调试(阅读评测信息,Ctrl+F 搜索相关行,插入 print 调试信息,模拟执行,阅读调试信息,定位错误并修改),结果表明效果很好。
总而言之,我的感受是:好的外部工具,人类的经验,都可以使 AI 使用更少 token 和更少时间完成更复杂的任务。