• 多线程编程初探——OO第二单元作业回顾


    一、作业设计策略

    1)执行FAFS策略的单部电梯

    ​ 由于对多线程不是很了解,于是采用了理论课上介绍的生产者消费者模型作为设计模板(也是很多同学一开始的做法):将请求队列作为共享对象(托盘),名为Input_handler的类处理输入的请求并将请求加入到请求队列(相当于生产者),调度器类则负责从请求队列中取请求并直接执行(相当于消费者)。

    ​ 同步控制方面,由于共享对象queue中的请求队列是可变对象,因此可能有线程不安全的问题,简单的解决方法是将能够修改该可变对象的两个方法add/pop设置同步控制synchronized即可,这样保证两个线程不会同时访问修改共享对象。

    ​ 在本次作业中我选择了好写的傻瓜调度,线程间的协同也仅仅是调度器和输入处理各自和共享对象交互,总体设计难度不大。

    2)执行捎带策略的单部电梯

    ​ 此次作业需要考虑电梯的性能,也就意味着需要调整原有的设计架构,加入捎带功能。我考虑过两种捎带策略,区别在于“电梯知道多少信息”:在第一种架构中,电梯只需要知道接下来向哪个方向运动,当前层是否开门,不需要知道电梯上的乘客信息;而在第二种架构中,电梯知道乘客的信息,可以自主决定向哪运动,向哪开门。

    ​ 明显可以看出,第二种架构相当于赋予了电梯一定的自主决定权,而第一种情况下的电梯则只具备执行请求功能。第一种设计的电梯内聚性更低(代价是电梯控制器的复杂度明显上升了),但耦合度较高,每次运动、开门、上下乘客时都要交互。第二种设计的电梯内聚度比较高,但是耦合度低,电梯只需要从请求队列中取请求进行执行,每运动一层自行决定是否上下乘客。

    ​ 其实总的任务是一样的,不同的设计只是在如何分解大的任务方面存在分歧,上面的第一种设计相当于在第二种设计上把“请求”更细致地解析为了每一层的移动策略,在实际运用中更安全同时在某些情形下可以实现更为精细的控制,但第二种设计实现起来无疑更加简单可靠。

    ​ 在沃艾思(操作系统)课程压力较大的情况下我最后还是求稳选择了第二种架构:由于只有单部电梯不涉及请求分配,砍掉了调度器只保留请求队列,在请求队列中编写两个请求分配方法(主请求和捎带请求);电梯在乘客队列为空时,向请求队列申请主请求(没有主请求则wait),拿到主请求开始工作,每运行一层后从队列中获得一个捎带请求,如果捎带请求不为空(有人可以捎带)或者有乘客要下电梯则开门,开门后先下乘客(到达目的地的乘客)后上捎带乘客(while 捎带请求不为null则get捎带请求);捎带请求并入乘客队列(副请求),在执行完主请求时选择一个最近的副请求升级为主请求;所有请求执行完毕且输入关闭时电梯下班(退出线程)。

    ​ 关于捎带策略:官方推荐仅在能上且目的地和电梯运行方向一致时才捎带,但实际上有一种卑鄙的外乡人换乘策略更简单,且效果也不差(由于这部电梯可以无限载重),即在能上时就把人带上,每次执行完一个请求后都切换到一个目的地距离当前楼层最近的副请求,这样不仅可以在电梯运行时间上性能优于ALS捎带策略,同时还节省了开门的时间。

    ​ 线程协作关系基本上还是生产者-消费者模式,托盘增加了一个获得捎带请求的方法(花式消费)。

    ​ 线程同步控制方面,将增加请求、获取主请求、获取捎带请求这三个方法设为synchronized即可。

    3)多电梯协同工作

    ​ 这次作业主要的变化是电梯数量和电梯停靠楼层的变化(载重问题只需要在上次作业的基础上根据电梯当前人数限制捎带即可),前者使得工作的线程数增多了,后者则使得单个乘客请求的执行可能需要电梯间合作执行。具体到编程实现,需要有策略地拆分某些请求,并保证拆分掉的请求按一定的次序得到执行(先要执行完前一个子请求才能执行后一个)。此外,对于某些不需要拆分的请求,由于三部电梯运行速度不一致,调度策略也不是唯一的。

    ​ 首先是拆分请求,所谓拆分实际上就是把单部电梯无力完成的任务拆分为两部电梯协作完成的任务,第一部电梯接到乘客将其送到换乘楼层(两部电梯的公共楼层),第二部电梯从换乘楼层接到乘客并把乘客送到目的地。不难知道,最简单的拆分策略是把换乘楼层设置为三部电梯的公共楼层1和15,这样所有请求都能被拆分后由单部电梯执行;更优的策略则是考虑选择能使用户乘坐电梯时间最小化的换乘楼层;当然,可以在短时间内做一些优化,例如考虑合作者当前所在的楼层和合作者搭载的乘客(合作者有自己的运行计划,且前往换乘楼层需要时间),但是这无疑使问题变得相当复杂,且只对极少情况下的性能有提升(考虑到三部电梯神奇的楼层分布使得请求拆分的情况较少),所以我仅仅考虑了用户乘坐电梯的总时间。

    ​ 其次是保证请求的执行顺序,这里我类比了一下work-thread模式:我的请求队列相当于channel,电梯相当于worker,不同的是这里的worker执行的工作可能是多工序的,worker完成自己可以完成的那道工序之后会通知channel “这一步我搞定了”。而具体到我的实现,就是把拆分后的请求(两道工序)打包起来送给一部电梯,电梯完成第一个子请求(第一道工序)后将第二个子请求加入请求队列(通知channel第一道工序已经完成)。这样便能保证请求按顺序执行。至于两个子请求如何打包传入,我新建了一个request类用来存这两个子请求(第二个子请求可以为空,表示该请求没有被拆分)。

    ​ 那么剩下的问题就是:电梯如何得到请求?既可以是电梯向调度器“抢”请求,也可以是调度器根据电梯的状态将请求分配给电梯。同样地,因为我不希望上调调度器的复杂度,所以我还是继承了上次作业的“电梯抢请求”的方式,调度器只需要负责分配和检查有无捎带乘客即可。

    ​ 由于整体架构和上次作业相同,线程协作和同步控制的方式总的来说和之前一样,区别在于我修改了请求队列(调度器)的add方法,在每次加入请求时判断是否需要拆分请求(如果无法由单部电梯执行则需要拆分)。

    二、基于度量分析程序结构

    1)作业程序结构分析

    a.hw5

    可是看他的架构平平无奇

    方法复杂度:

    类复杂度:

    代码总规模:

    类图:

    线程协作关系:

    b.hw6

    方法复杂度:

    可见分配捎带请求的方法相对复杂。

    类复杂度:

    从此次作业开始电梯类变得复杂。

    代码总规模:

    类图:

    可以看出整体的架构和第五次作业一致,类的内部属性方法进行了扩展。

    线程协作关系:

    c.hw7

    类复杂度:

    显然,由于需要自行判断运行方向和开关门策略,电梯类的复杂度较高。

    方法复杂度:

    可见请求队列(调度器)中拆分请求和分配主请求的方法复杂度较高。

    总代码规模:

    类图:

    整体结构依然和前两次作业一致,内部的属性方法进一步复杂化,引入了Request和Type两个类,前者用于容纳一个请求拆分得到的两个子请求,后者用于规范化电梯的属性。

    线程协作关系:

    2)优缺点评论

    ​ 三次作业采用的架构基本一致,优点在于沿袭了设计模式,线程安全得以保证;缺点在于把相当一部分运行策略封装进了电梯,调度器的作用仅剩下“提供请求让电梯来拿”,无法根据电梯当前的楼层和电梯的运行速度做针对性的分配策略,因而性能并不顶尖。

    3)SOLID原则

    a.SRP单一责任原则

    ​ 较好实现——电梯只负责运行和在停靠楼层与调度器交互(确定是否开门),调度器负责拆分和分配请求,内置请求队列;输入处理负责向调度器的请求队列中加入新的请求。

    ​ 如果不增加新的类,则所有的工作将由这几个类分担,想要减少一个类的责任,势必增加另一个类的,如不新增类(例如电梯控制器、楼层等等)很难更好地符合单一责任原则。

    b.OCP开放封闭原则

    ​ 实现较好,三次作业架构始终不变,第六次作业扩展了捎带请求方法,第七次作业扩展了拆分请求的方法。

    c.LSP里氏替换原则

    ​ 不适用,并未使用继承,第三次作业用可变参数的方法实现了多电梯。

    d.ISP接口分离原则

    ​ 不适用,程序除了线程使用了Thread接口,没有使用其他接口。

    e.DIP依赖倒置原则

    ​ 虽然我没有使用抽象接口,但是的确对程序设计进行了抽象而非针对实现进行编程,可以说实现地较好。

    三、程序bug

    1)公测中出现的bug

    ​ 本次公测(至今最灰暗的一次)中我的程序出现了两个逻辑上的错误,并且是小规模手造数据难以暴露的bug,加上我没有自己在课下进行自动化测试,中测样例又比较仁慈,因此强测分数十分喜人(如果分数评论标准和田径比赛一样的话)。

    ​ 第一个错误是电梯arrive楼层之后和调度器的交互,应当先确定当前楼层能够停靠之后再进行捎带和下乘客的交互,但是我将这两者(交互和确定停靠)的顺序写反了,导致会出现如下情况:电梯拿到一个捎带请求,发现停靠不了,然后请求就没有加入电梯,没有加入电梯……这导致在某些情况下乘客和消失了一样(应该是掉入电梯井了?),程序无法正确执行。

    ​ 第二个错误是电梯是在乘客上电梯之后才增加载重,而不是在相应请求之后就增加载重,于是一开始的主请求在被响应之后,电梯载重为0,然后在去接主请求的路上已经装满了捎带请求,这样等到电梯终于到了主请求上电梯的楼层时,主请求如果直接进入,那么违反了载重限制;如果不上电梯,那么电梯的主请求没有得到执行。两种情况都会出错。

    ​ 这两个bug一个发生elevator类中负责电梯交互的方法interact里,另一个是在共享对象请求队列的mainreq方法中。都与线程安全无关,是电梯运行逻辑上的错误。

    2)bug与设计结构的相关性

    ​ 本次作业会出现这两个bug,和我的设计结构是有一定关系的。

    ​ 第一个bug的出现是因为在继承上次设计的基础上对于“判断当前楼层能否停靠”判断的位置不恰当,放在了拿到请求后(本应该在拿到请求前判断),导致可能出现请求未被执行的情况。

    ​ 第二个bug——由于我采用的是“主请求+捎带”的响应请求逻辑,所以和现实生活中电梯的逻辑是有一定差异的:响应了主请求,电梯事实上就做出了保证一定要接到主请求,此时即应留出主请求的空位确保其一定能搭乘电梯。但是我在考虑人数限制的时候,依然是按照现实生活中的电梯运行逻辑来控制的:上人载重+1,下人载重-1,看似没问题,实际可能出现主请求进入满载电梯的情况。

    四、互测策略

    ​ 本单元前两次作业放了几个空刀,没有发现同屋同学的bug,第三次作业没有进入互测,所以总的来说互测经历没有太多东西值得总结。

    五、学习心得

    1)如何保证线程安全

    ​ 在保证线程安全方面,我认为应该重点关注共享对象的访问控制,同时对可能死锁的情况予以提前考虑。当然在三次作业中,synchronized方法锁基本上就能解决所有问题,且性能损失可以接受,不太需要用到原子操作、对象锁等等;同时作业中只有一个共享资源、不太可能出现死锁,选取合适的设计模式加以改造,可以比较好地处理作业中的线程安全问题。

    2)设计原则

    ​ 设计原则方面,我认为应当参考SOLID原则进行程序设计,这样可以保证程序的可靠性且易于扩展。同时我们应当善用不同的设计模式,提高程序效率、保证程序安全性。

  • 相关阅读:
    回答自己的提问
    <构建之法>13——17章的读后感
    <构建之法>10,11,12章的读后感
    <构建之法>8,9,10章的读后感
    作业5.2
    【作业报告】作业5 四则运算 测试与封装 5.1 改进版
    作业 5.1
    阅读
    做汉堡
    阅读《构建之法》
  • 原文地址:https://www.cnblogs.com/why34/p/10763545.html
Copyright © 2020-2023  润新知