OO第二次博客

oo5_7

多线程同步策略分析

1.多线程电梯时的策略

线程分析

多线程电梯时,我还执着于时间的精准性,也就是上下楼一定要多少多少秒,所以采取的是假时间策略。

为了实现假时间策略,我将三部电梯的运行封闭到了一个线程当中,单独一个线程内部的执行是不会受到线程调度产生的误差的影响的。

在这基础上,考虑到输入IO会有阻塞,安排了一个输入线程。至于调度器线程,对于使用假时间策略的我的设计而言,它是可有可无的,毕竟实际的调度都是在电梯线程内部进行的,如果不在电梯线程内部进行,那么调度与执行之间就会产生线程调度导致的时间误差,会导致正确性问题。故而我的调度器线程虽然按照指导书要求而加上了,但它仅进行部分不会产生时间误差的调度功能。

同步策略

在输入线程与调度器线程之间仅有指令需要传递,我才用了一个阻塞队列来实现指令队列。利用java库中自带的同步容器类保证线程安全。

在调度器线程与电梯线程之间,存在两类同步问题:

  1. 为了避免因时间误差而产生正确性错误,我需要在同一时间获取三部电梯的状态,并且能在该时间将指令传递给电梯线程。
  2. 我需要保证电梯的状态变更能在调度器线程需要访问的时候就能被反映出来。

第一类同步问题,因为我采用的是电梯线程内部假时间的策略,所以很容易解决,只需要利用一把调度器线程与电梯线程共享的“运行锁”即可。电梯线程每次主循环开头获取锁,主循环末尾释放锁。当调度器线程希望停止电梯线程时,只需获取该锁即可。同时为了避免饥饿问题,这里我采用了公平锁,在只有两个线程争夺该锁的情况下,性能损失还是可以接受的。

第二类同步问题,因为当调度器线程访问电梯状态时,电梯线程必定停止,不可能更新自身状态,故而仅需确保电梯状态都能反映到内存中,而不是被缓存。故而我大量使用了volatile变量、原子变量实现轻量级的同步。

2.IFTTT时的策略

线程分析

线程划分非常明细、简单:

  1. 触发器线程组
  2. summary线程
  3. detail线程

同步策略

首先考虑触发器线程组,它们之间共享的是被监控的文件,而这份线程安全性被委托给了FileCenter这一线程安全的File类的封装类。

再考虑summary线程、record线程,它们之间没有共享,但各自都和许多触发器线程共享了它们记录的信息。这是典型的“读者-写者”的情况。写者是一堆触发器线程,读者是需要将记录的信息写进文件的summary、record线程。我采用了消息队列的方法保证了写者与读者的同步,将线程安全性委托给了java的同步容器类。

3.第一次出租车时的策略

线程分析

这一次我抛弃了多线程电梯时注重正确性的策略,没有采用假时间策略。故而这里100个出租车不再只有一个线程,而是真正的100个线程。

同时,我注意到对乘车请求的响应、“抢单窗口”的设计非常适合使用服务器模型进行实现。故而我安排了一个线程池,这个线程池中一个线程对应于正在处理的一个乘车请求,称该线程为调度单元线程。

除此之外,就还有一个标配的输入线程。

同步策略

首先,出租车之间共享地图,以及调度单元。

  1. 因为地图在这次作业中是不可变的,故而线程安全性被保证。
  2. 对于调度单元,我采用了消息机制来处理出租车、调度单元之间的同步问题。即两者都具有消息队列,互相传递信息时仅通过消息队列进行。这样虽然降低了性能、正确性,但简化了实现逻辑。

其次需要保证出租车的状态对其他线程都可见,这一点通过简单的内部锁即可实现。

最后因为需要通过位置、状态来访问出租车,故而我安排了一个缓冲用的TaxisMonitor,出租车监控类来存储缓存信息。因为该缓冲对象会被所有出租车访问来更新缓存,故而需要进行同步。这里我采取了细粒度加锁策略,毕竟本身就是为了性能而做的缓冲,不能因为加锁反而损失性能。对于每一个位置上一个锁,每一种状态上一把锁。

不过因为每一次出租车状态更新会需要访问前后两个状态,如果同时获取两个状态的锁,会导致死锁问题。我的解决方法是让程序同一时间要么只获取前一状态的锁,要么只获取后一状态的锁,虽然会导致出租车在一段时间内在缓冲区中不可见,但可以简单解决了死锁问题。而不可见导致的正确性损失,在这次作业中并无伤大雅。如果真的会因为这点时间的不可见而产生正确性错误,那出租车线程本身运行的时候就会因为过卡而导致走一条边超过200s了。

度量分析

电梯

这次电梯作业光看度量的面板数值还可以,也就输入处理那里我图省事嵌套多了点。但是实际上因为是多次更迭的项目,其中有众多冗余的代码。这点从55个类、2898行代码中就可以看出。

IFTTT

从面板数值上可以看出,这次IFTTT作业最大的问题就是,它的分支判断相当地庞大。这一点我实在想不出怎么避免,我已经将分支判断尽可能封装在一个方法中,并且保证该方法的接口统一性。或许可以采用将分支判断数据化,然后编写自动进行分支判断的代码来解决。

出租车

状态变化——这是这次出租车的红点。我在思考能否通过将状态本身也给抽象出来,作为一个类对待?然后状态变化的逻辑交由状态本身来处理?或许这样就能将复杂的圈逻辑降维,分散到各个状态类中去。

类分析

电梯

从LiftsThread以右下的那一部分代码全部都是和单线程时一致的,也就是电梯内部依然是按照单线程时的运作模式进行运作,不再赘述其内部实现。

为了能够复用单线程时的代码,我在LiftsThread内部,将系统时间的流逝转化成了对Lift响应模拟时间变化的调用次数。也就是每过几几秒就调用一次Lift一个时间粒度的变化函数。

所以实际上,LiftsThread仅仅只是一个用来封装模拟时间的线程,其内部不包含任何调度逻辑。

实际的调度逻辑全部被包含在Schedular及SubSchedular中。其中SubSchedualr为单部电梯时的调用逻辑。Schedular为协调三部电梯的运动量均衡策略逻辑。

SchedularThread仅仅是为了迎合指导书要求而赘写的中介线程。

InputThread负责读入指令,并将其放入CommandTray中。之后SchedularThread从CommandTray中取出指令,再暂停LiftsThread,转交给它指令。

World负责系统内时间的管理。

IFTTT

ifttt中我大量使用了继承,主要原因是指导书的不明确以及来自助教的需求的频繁变更,导致了代码需要不断维护。为了减少代码维护时的工作量,我尽可能地复用代码,减少同质代码的出现。

继承树一共有四支。

  1. Trigger树。Trigger即为触发器,每一个Trigger都是一个实现了Runnable的可运行类,在实际的程序运行中,每个Trigger都是一个监控线程。其工作即为每隔一段时间获取监控对象的快照,随后根据自身响应方法的具体实现,生成Alter,交给注册在自己身上的Task处理。
  2. Task树。Task即为任务。Task负责接收文件快照的变化(Alter),随后根据自身响应方法的具体实现,进行处理。
  3. Recorder树。Recorder负责信息的记录与记录文件的读写。其自身也是一个线程,每隔一段时间就刷新文件中的记录。
  4. Test树。为了方便测试者进行测试,我将较为具体的测试时的运行逻辑封装在了抽象基类Test类中。每一个具体实现了Test类的类都可以作为一个测试样例执行。

FileCenter即为一个线程安全的File类的封装类,负责文件读写的底层封装。

出租车

出租车的类设计主要分为了三个族:

  1. Taxi族。这部分包含了Taxi, Driver, TaxisMonitor。这一族内部高度耦合,三位一体地实现了出租车。

    1. TaxisMonitor负责出租车状态的对外查询,其内部实现为缓冲实现。每当出租车状态发生更新时就会调用该对象,进行缓冲更新。
    2. Taxi负责出租车的运动逻辑的维护。也就是出租车实际在地图上如何位移的问题。
    3. Driver负责出租车的服务逻辑的维护。也就是出租车如何与调度单元进行消息交互的问题。

    Timepasser为Taxi的基类,封装了Taxi的线程逻辑。负责将系统时间的流逝转化成Taxi内部时间的每时间粒度的流逝。

  2. Schedule族。这部分包含了ScheduleServer, ScheduleUnit。ScheduleServer维护了一个线程池,该线程池中每一个线程都为ScheduleUnit的实例化。这一族负责响应乘车请求,并对每个乘车请求分配一个线程进行抢单、分单等逻辑。

  3. Map族。这部分包含了Map, RouteSolver。这一族封装了地图的具体实现。

除了这三族以外,还有几个类是为了之后的扩展而额外实现的:

  • TwoSideMessage。给消息增加了一层抽象,使得之后还能扩展出除了乘车请求交互时的消息以外的消息。
  • Mesable。该接口是为了使得之后其他类也可能能够收发消息而实现的。
  • InputProcessor。该类将输入处理与乘车请求响应分离开来,是为了之后增加除了乘车请求的输入。

设计分析

关于这个,我难以进行分析……因为我不知道我有没有做到。究竟做到什么程度才能算是做到了某一项原则?我没有足够的经验去回答这个问题……我只能说我尽量去做了。

bug分析

主要的bug来自于对指导书理解有误以及未及时看issue和微信群。其次的bug大多是因为我没写正则去检查输入是否合法,只要输入错了,那我就挑正确的然后继续跑,跑不下去再跑错,但是公测要求不管能不能继续跑都要报错。

我没怎么查别人的bug,没那个时间,仅仅只是按照公测要求完成了课程安排的工作。

心得与体会

其实多线程的同步控制并不困难,与电梯复杂的调度逻辑相比,那是很简单的东西。代码编写过程中最大的难点其实是两点:

什么是正确的代码?

我很想说我知道这个问题的答案,但我做不到。仅仅只是阅读指导书,进行需求分析,并不能真正导向“正确的代码”,而只是能接近。在知道这件事情以后,我所能做的或许仅仅只是留出“将代码改正确的余地”。

性能 || 简单

在程序写完前我无从得知程序的性能如何——这很显然,但是我却必须要写完它。如果我在编写过程中就考虑性能问题,那很有可能就会导致我代码根本写不完,或者越写越错。因为优化性能的代码逻辑往往是复杂的。在第一次编写的过程中我往往会采取最简单的那种方法,而不是最快的方法。我所能做的或许仅仅只是留出“将代码变快的余地”。

总结

上述两个问题最终都导向一点:余地。也就是代码的可修改空间、可拓展空间。当我编写的程序不只是写完就行,过了OJ就行的时候,“余地”成了我编写代码时需要重点考虑的因素。

对课程的看法

在电梯的时候,我十分执拗于程序的正确性,通过不断地发issue明确指导书的意思,我虽然不能完全做到,但是却能向“正确的程序”接近。

不过在ifttt时,需求频繁的变更、指导书的不明确让我放弃了对正确性的执拗——那是一件做不到的事情了,同样一份代码,两个人看,一个人觉得对,另一个觉得错,不再有统一规定。

公测逐渐转为互测,互测时bug树可以用一个bug挂满,许多人的公测因为对方没测而满分,吐槽版各种认领代码,等等现象,让我明白了一件事情:这课的成绩没有很大的意义,就和我的博雅课程的成绩的意义差不多。虽然这课的成绩会算进GPA,但是这课的设计就不是以给学生成绩为核心的。

言尽于此,并不是抱怨,这就是我所见到的,我所想的。

猜你喜欢

转载自www.cnblogs.com/supplient/p/8973141.html