301
社区成员
发帖
与我相关
我的任务
分享目录
同步块与锁,是Java提供的解决线程安全问题的机制。它保证同一时刻只有一个线程进入临界区,访问临界资源。
在同步块和锁的设置上,我认为最核心的是考虑好下面的问题:第一,这个对象是不是共享资源?第二,这个对象被哪些线程共享?第三,这个对象被线程以什么样的方式共享?只要是共享资源,就要用同步块限制那些线程对它们的访问(除非均只读),在线程访问那个对象的方法上加锁。synchronized机制可以很好地解决多数情况下的同步控制问题,但有些时候为了提高并发效率,也可以使用读写锁,使同时对共享对象的读不受限制。
锁与同步块中处理语句之间的关系:同步块中的处理语句只有获得锁才能执行,锁使得同步块中的处理语句执行过程中,不会转入别的线程。


第一次作业的要求实现:接受乘客乘坐请求(FROM、TO、Elevator),并通过实现电梯的一系列动作(上下移动、开关门、停止等),完成所有乘客的请求。
采用的主要架构模式:生产者-消费者模式。
生产者-消费者模式十分适用于本次乃至本单元作业的设计需求。输入线程不断读取乘客请求,并将其放到请求队列里,交给调度器;调度器不断拿出乘客请求,并将其放到电梯的请求队列里,交给电梯处理。这是两次生产者-消费者模型。采用这一设计模式,使得解决问题的思路和代码的架构都变得清晰。具体而言,设计一个Input类(extends Thread),用于处理输入;设计一个Dispatch类(extends Thread),用于实现分配;Input对象和Dispatch对象间共享一个请求队列RequestTable对象;设计一个Elevator类(extends Thread),用于完成请求;Dispatch对象和每个Elevator对象间都共享一个请求队列RequestTable。在Main类中创建各对象、启动线程。
第一次作业由于指定了乘坐的电梯,所以调度器要做的事情就十分简单,调度策略就是读取乘客要求的电梯ID,将其分给要求的电梯即可,不能擅自改变。调度器与其它线程间的交互,都基于共享对象。如调度器基于共享的RequestTable请求队列对象从输入线程中获取请求、收到结束标志、被输入线程唤醒等,类似地,基于共享的RequestTable请求队列对象向各电梯线程发送乘客请求、发送结束标志、唤醒电梯。
在第二次作业中,增加了电梯重置(reset)的要求。电梯在重置时,需要尽快停靠,取消已接收的请求,放出电梯内的乘客,开始重置,并在经过1.2秒后完成重置。重置后的电梯可能改变限乘人数和移动速度。此外,电梯收到乘客请求时,需要输出相应的RECEIVE信息,并且一个乘客请求同时最多只能由一个电梯接收,避免了“一人乘坐,六梯皆动”的现象。
基本的架构仍然为生产者-消费者的设计模式,但是需要做出一些改动:本次作业中,由于电梯会因reset而中途放出乘客、取消请求,导致一些乘客的请求可能没有完成就离开电梯。此时显然需要将这些未完成的请求重新分配,即再次放回总请求队列中。因此,电梯和输入线程一样,也成了总请求队列的生产者,需要调用同步控制的方法写总队列。同时要注意,既然电梯也成了总队列的生产者,那就不能因为输入的结束就判定“生产”的全部结束,还要增加电梯“生产”是否结束的判断条件(电梯还未处理完的reset请求数是否等于0),并且电梯“生产”结束时也要通知调度器(notifyAll),以避免调度器无限wait下去。
此外,对于“尽快停靠”要求,本次作业中我采用了对reset请求也过一遍两个队列和调度器的方式,认为这样应该也不会消耗太多时间。后来在测试中发现,由于调度逻辑(乘客请求不适合分配到任何电梯时,就sleep一下再尝试分配,此时,后输入的reset请求也会被卡住,暂时不会分配),会有少数时候出现停靠不够快的情况。为了避免在临近截止前引入bug,我采取了一些别的办法,暂时解决了这个问题,而在第三次作业中完全解决。
在调度策略方面,我选择均匀分配。当时我认为均匀分配和随机分配应该效果相同,并且均匀分配因为分配方式更有规律可循(123456循环),也更有利于减少随机因素,复现bug。但是强测成绩出乎我的意料,随机分配强测成绩普遍在95分以上,并且单测试点几乎没有低于90的;而均匀分配只有90.0几分,单测试点甚至惊现了八个85,这个数据已经足以说明问题。分析之后我认为,这主要是由于耗电量的差异导致的,因为测试表明均匀分配总运行时间总是明显小于随机分配,在时间上有优势。但均匀分配方法下,几乎任意时刻总有6个电梯一起在动,而随机分配则未必。比如6个请求,均匀分配会将6个请求分给6部电梯去完成,而随机分配却很少能在6次随机中恰好随完1-6六个数。看似每一次的微小差异,最终带来了总性能的巨大差异。因此,在第三次作业中,我决定将均匀分配改为随机分配,以满足耗电量的要求。
在调度器与其它线程的交互方面,多了一种电梯线程与调度器线程的交互,即调度器线程要得知电梯线程是否还有未处理完的reset请求。这一交互仍然基于共享对象进行。电梯线程与调度器线程共享总请求队列,我们便在总请求队列中维护一个未处理完的reset请求的数量,该量由输入线程、电梯线程写,由调度器线程读,由此实现交互。
本次作业要求实现电梯的双轿厢重置,即在收到相关指令时,立即开始与上一次实现的电梯重置同样的操作,重置结束后,原本的单轿厢变为分别在上区、下区运行的两个轿厢。两个轿厢分别在划分好的上区和下区运行,共享同一个换乘楼层,重点在于保证两轿厢不在换乘楼层相“撞”。此外,双轿厢电梯有巨大的耗电量优势。
在课上得知要实现双轿厢电梯时,我一直以为是一开始就有12个轿厢,所以构思的是一开始就开12个线程。等到晚上指导书发布才发现,是用第二类重置指令重置为双轿厢,比我想的难度大了不少。但一开始就开好12个线程的想法已经形成了思维定势,我最终未跳出原有的思维定势,而是继续采用这一想法,经过一些调整形成了我的最终架构。
双轿厢电梯,我是这样实现的:在单轿厢电梯DCreset时,直接将原单轿厢的elevatorType由空串改为"-A",继续使用,并在DCreset方法内调用另一轿厢的start方法,启动B轿厢线程。轿厢1~6用作原单轿厢、轿厢A,轿厢7~12用作轿厢B,并且1和7、2和8......6和12一一对应。为了在原单轿厢中调用另一轿厢的start方法,原单轿厢必须拥有另一轿厢的引用,这通过在构造方法中作为参数传入实现。
如上所述,本次作业我主要使用了随机策略。此外,为利用双轿厢电梯的耗电量优势,还限制:有双轿厢电梯时,优先在双轿厢电梯中随。但是如果一个请求是因为换乘而被扔回的请求,那么强制将其分回原电梯的另一轿厢,这主要是为了避免多次换乘,分回原电梯的另一轿厢后必定不会再次换乘。
调度器与其它线程的交互,多了一种电梯线程因换乘而扔回请求给调度器重新分配的情形。调度器要得知是否还有可能被扔回的请求(即:是否还有未完成乘客请求),这同样通过共享的对象实现。类似上次判断是否还有未完成reset请求的处理方法,在总队列中维护一个未完成的乘客请求数量,由输入线程、电梯线程写,调度器线程读,实现有无未完成乘客请求的判断。
为了避免双轿厢相撞,我又设置了一个双轿厢间的共享变量TransferState类,让双轿厢电梯去争夺TransferState对象的“锁”,争抢到后才可以去换乘楼层。其实这样做的原因是,换乘楼层本身很像一个“共享资源”,同一时刻仅允许一个轿厢“访问”,若轿厢A想要“访问”时轿厢B正在“访问”换乘楼层,则轿厢A对换乘楼层的“访问”必须暂时阻塞,等到轿厢B“释放”换乘楼层时才可以“访问”。这与互斥的概念不谋而合,因此可以采用解决互斥问题的手段,用个TransferState类的对象表示换乘楼层,将两个轿厢对换乘楼层的互斥争夺转化为对TransferState对象的“锁”的争夺,这样,不能同时位于换乘楼层的问题就迎刃而解了。具体代码实现如下:

加入横向电梯:两类电梯实现Elevator接口,共用一部分行为(如move、openAndClose等),而各用一部分行为(如moveDirection)即可。
更复杂的维护请求:处理流程可以沿用,而在具体维护内容和结果上可以重新编写。
电梯的启用和禁用:初始时,六个电梯都可以启用,电梯接收禁用指令后要离开电梯系统,直到重新接收启用指令为止。禁用的处理方式与reset类似,但重新启用要由信号给出,并且存在禁用后未启用的情况。
这三次作业的架构中,既有稳定的、可一直沿用的部分,又有易变的、随时会因需求的变动而变动的部分。
输入—调度器—电梯的三线程架构是稳定的,符合电梯运行实际,电梯新增再复杂的功能,也离不开输入—分配—处理这三个处理问题的基本流程。这个架构,和其所基于的生产者-消费者模型,是代码的“骨架”。
电梯运行时采用的LOOK策略是稳定的,捎带乘客的算法不会因为新增了别的功能改变。捎带乘客是电梯的基本功能。
电梯新支持的其它功能及其变化是可变的。就比如在二、三次作业中新增的电梯重置功能,是迭代出的功能。而其中,电梯重置时的行为(尽快停靠、放乘客、退请求等)又是固定的,重置后的结果和一些相应的实现细节是可变的。
本单元三次作业均未在强测和互测中出现Bug。我认为,减少Bug的关键在于做好本地测试,尤其是大量构造手工数据。手工构造数据往往能更加针对性地发现一些Bug,不要仅仅依赖于自动化构造数据。手工构造数据可以专门测试某一个功能,往往会有意想不到的收获。
以下是一些我在本地测试的过程中发现的关键Bug:
1.鼎鼎大名的“围师必阙”,即在5个电梯都在reset时输入一大批请求,此时调度器会将所有请求都分给空闲的那部电梯,导致最后只有一部电梯在实际运转而超时。此Bug由于发现,所以防了一手,采用的办法是:如果不在reset中的电梯收到的请求数量都已达最大限制12,那么就sleep一段时间再尝试分配,等待新reset完电梯或者有电梯处理完了部分请求而腾出空位。当时没有想到reset中的电梯也可以分配,只是暂不输出的方法。
2.反复开关门问题。判断本层是否可以OpenForIn时,要加上未满员的条件,否则会导致满载的电梯一直CanOpenForIn,而开门后又无法进人,导致停在原地反复开关门,程序无法运行结束。
3.结束条件的改变。在第二次作业、第三次作业中,要新增结束条件电梯的生产也已完毕,总队列的生产才算都完毕,因为总队列的生产者由输入变成了输入和电梯。遗漏结束条件的改变,会很容易导致提前结束,乘客请求未完成完。
4.对第七次作业,利用耗电量优势而产生的Bug“围师必阙2”。若分配逻辑中有“只要存在双轿厢电梯,就优先往其中分配”,而不做其它限制的话,那只在开始DCreset一部电梯,随后输入一大批请求,同样会因为请求都分配给那部双轿厢电梯,而导致超时。但大家基本都做了相应的改进,所以这个也没hack到什么人。
对于debug方法,对于多线程程序,不得不重新采用大名鼎鼎的printf大法(),如在wait前后各加一个begin wait和end wait输出判断是否死锁等。
1.线程安全:要想做好线程安全,核心就是要做好对共享资源访问的限制,一个线程访问时,不允许其它线程访问。为了进一步提高效率,可以使用读写锁来实现允许多个线程同时读的情况。据此,我们分析好每个资源是否被共享,被谁共享,以及是否写,据此对相应的代码进行同步控制即可。
2.层次化设计:本单元中的代码结构主要表现为并列结构(输入、分派、电梯三个线程同时执行),多线程并发完成任务。在编码时,要理清完成一个请求的流程,设计相应的线程和功能模块。