[BUAA 2024OO U2]你的线程不太安全,但你的调试语句弥补了这一部分。如果删除你的调试,就会显得你的代码比较危险,可能就会出现一些死锁轮询的情况

迭代与分析

hw5

总体设计

这次的hw5相比于往年大幅缩减,没有了调度部分,只需要在电梯运行策略上尝试发挥。

参照学长博客和自己理性分析,我设计了一个Distributor和六个Elevator共七个线程,并为了简单起见把基本操作都写在了线程内部。Distributor直接从Input中读取请求,并把请求按照要求归入各个电梯的小盘子WaitingList中。每个电梯在上人时,从WaitingList中读取并删除一个请求,直到Distributor读取到输入结束,向各个电梯发送结束信号。当电梯处理完各自WaitingList中的请求并且受到结束信号时,就结束自己的线程。

同步与锁

对于同步块与锁,hw5的唯一共享内容为WaitingList,因此我直接在每个需要读写此内容的地方使用Synchronize块,并在每个包含写操作的块的末尾使用notifyAll。为防止轮询,当电梯读取对应WaitingList并发现已经没有需要处理的内容时,会使用WaitingList.wait(),等待DistributorWaitingList中投放内容并使用notifyAll唤醒电梯。

策略

hw5不包含调度策略,只需要考虑电梯运行策略。我按照理解实现了学长博客中经常提到的LOOK策略,但是在强测中差强人意。后来我和其他同学讨论了我的策略,发现我对LOOK策略的理解有误,因此实现了一个像是LOOK与ASL特点兼具的策略,效果不如真正的LOOK。因此我也在hw6中重写了电梯运行策略。

结果

如上文所言,虽然没有出现错误,但性能不佳,只获得了94分。互测期间并没有发生什么问题。

hw6

总体设计

hw6加入了三个新内容,其一为调度器的设计,其二为电梯的RESET,其三是RECEIVE。

我发现学长的博客中有很多输入线程直接修改电梯的操作。我理性分析,认为这样的操作很不安全,很混乱,还很不好写。因此我决定自己构思一种更好的策略。首先,加入输入线程,专门管理输入,并把请求直接送入大盘子DistributeQueueDistributor不再从输入中获取内容,而是从大盘子``DistributeQueue`中获取内容,并送入各个小盘子,形成一个流水线的结构。

I n p u t → D i s t r i b u t e Q u e u e → D i s t r i b u t o r → W a t i n g L i s t → E l e v a t o r Input\to DistributeQueue\to Distributor\to WatingList\to Elevator InputDistributeQueueDistributorWatingListElevator

为了处理RESET和结束信号,我把他们也视为一种请求。因此,请求被分为RESET请求、乘客请求、退出请求三类,处理优先级递降。当电梯RESET放出乘客时,电梯直接把乘客扔进大盘子DistributeQueue。由于存在RESET,分配器不能在读取到输入线程的结束信号时就直接给电梯发送结束信号并结束自己,因为电梯可能还会向DistributeQueue扔请求。因此,我设定结束条件是所有电梯都处于没有请求空闲,且输入线程已经给出结束信号时,Distributor向电梯发送结束信号并结束自己。

同步与锁

考虑到锁的使用相对分散,语句之间的微小差异可能导致各种各样的不稳定性,我尽可能使用同步块而不用锁,虽说这点其实应该是因人而异,差别不太大,但总之如此。

首先大盘子DistributeQueue也需要像小盘子WaitingList一样,每次使用都加入同步块中,以防多线程出现异步问题引发各种问题。我在hw6同步对象基本就是这两种盘子。

然而,这只处理了数据共用的问题,线程安全的问题仍非常复杂,我在实际实现时遇到了许多没有预想到的神奇的问题,并在帮助室友调试的过程中又发现了很多之前没有想到的隐患,也加深了对自己程序的理解,以下逐一分析。

RESET期间加锁与RECEIVE问题

RESET的时候需要进行小盘子WaitingList的修改,因此一个比较粗暴的实现可能会是RESET全程synchronized这个WaitingList。然而,这会导致电梯一直拿着小盘子的锁,如果此时Distributor正要给这个电梯分配请求,Distributor就会因为锁的问题卡住。如果此时大盘子里有另一部电梯的RESET请求,Distributor就不能及时分配这个请求,导致此电梯不能及时RESET。

解决这个问题有两个思路。其一是调整分配器,拿不到锁就先看后面的请求,但是很难实现并且可能引发更多问题。因此放弃,直接考虑第二个思路,在RESET期间不要锁住小盘子,允许接收请求。但是这样就要求必须延迟输出RECEIVE,为了补救我意淫了一个缓冲区策略,表示电梯自己的输出缓冲。分配器把请求扔进小盘子的同时,把RECEIVE的那句话丢入电梯的输出缓冲区。不RESET时,电梯就尽可能频繁清空缓冲区;RESET时,不清空,在RESET完成后再清空。后来发现似乎很多同学都采用了这个做法,没有采用的大多都出现了一些难以察觉的问题。

RESET丢出的人被立刻RECEIVE问题

Distributor是很勤劳的,每当获取到大盘子有修改就会立刻被notify起来干活。有可能电梯RESET丢出乘客的时候,刚丢出还没开始RESET,大盘子就又把刚丢出乘客分配给他了,即使有输出缓冲区,也于事无补,因为RESET_BEGIN之后RECEIVE会被清空,此时调度器Distributor认为这个乘客分配了,电梯认为小盘子里应该没人了,于是这个乘客就会出现没有RECEIVE就被运输的问题。

为了处理这个小bug,我在RESET期间又加了一个小暂存区,丢出的乘客先进入暂存区,等到电梯开始RESET_BEGIN之后再把乘客从暂存区丢入大盘子。这时候因为已经RESET_BEGIN,清空工作已经结束,分配器即使立刻干活,也有输出缓冲区保障,不会有问题。

如何结束

上面提到的结束问题也涉及很多细节。例如,这里要求电梯也能唤醒调度器Distributor。因此,我考虑在大盘子里设置计数器waitingCount,每次电梯开始等待就给计数器+1,结束就-1。修改时要唤醒调度器。这样调度器就能直到有多少电梯正在休息。

WAIT死锁

有可能电梯的策略分析出接下来没有任务了,应该等待。但是在分析出结果到真正开始.wait()之前,小盘子WaitingList发生了修改,但是由于电梯还没.wait(),这就导致明明电梯有活干了,却还在干等。

这个问题的本质在于电梯的等待这一动作有特殊性,要求判断行为和真正开始.wait()之间不能有小盘子的修改。因此我们要保证这期间一直synchronized住。

策略

由于hw5的失误,我重写了LOOK策略,这次他表现出了应有的水平。

调度策略我使用了影子电梯,继承了普通电梯并重写了Sleep()方法,实际上非常好写。

最后我还加入了量子电梯,希望能再榨取一点时间优势。

结果

各种优化效果显著,获得了99.8分。互测中,我发现有人没有处理好RECEIVE的端点情况,也就是同时对6部电梯RESET,且给一个人,他们会出现先RESET_BEGIN再RECEIVE的问题,利用这一点hack掉了一些房友。

hw7

总体设计

hw7主要新增加入了双轿厢电梯。

一个电梯变成了两个,第一想法应该是用双线程还是单线程。很明显这里双线程更简单,更有效。单线程需要模拟两个电梯的步进,这样写着有点像状态机了,很不好写,而且偏离这个单元的重点了。这里直接把原来的Elevator改成子电梯ChildElevator,然后在原来的Elevator中直接用封装的子电梯,如果是普通电梯就设置一个子电梯,如果是双轿厢就设置两个子电梯。

这样修改之后电梯策略也有一些需要修改的部分。首先是两个电梯不能在同一层,这个可以在电梯内设置一个锁,两个轿厢抢锁解决,由于双轿厢不会再RESET,不会再出现什么问题。然后LOOK策略要略做改变,让轿厢不能停在换乘层。最后,电梯在换乘层下客的时候,由于乘客此时有可能有更好的选择,所以把乘客扔到大盘子里是更好的选择。此外就是LOOK策略由于换乘层的出现产生的一些其他细节,稍微修一下就好。

同步与锁

经过hw6的打磨,锁的问题已经基本解决。这次多加的锁只有双轿厢电梯抢换乘层锁的部分。每当运行到换乘层,都需要拿到锁,直到离开这层。这样可以保证只有一个电梯在换乘层。

策略

LOOK策略还是基本不变,只需如上略作修改。对于调度策略,由于一个电梯有两个线程了,影子电梯就不好用了。但是我认为影子电梯折中一下使用,效果应该也比其他策略好不少。因此,我决定普通电梯继续使用影子电梯,双轿厢电梯就把跨越换乘层的请求拆分给两个子电梯,然后两个子电梯独立跑影子电梯,加起来再把结果乘0.8,作为双子电梯的时间。乘0.8是为了弥补直接相加时间的亏损。

结果

大意了,互测卡不掉但是强测死锁挂了一个点。排查了一下发现是影子电梯里,深拷贝的时候顺手写了个synchronized,正好跟正常的电梯运行互相干扰卡死锁了。然而这个深拷贝是可以不用sychronized的,直接去掉不会有问题,因为即使深拷贝发生了一些错误也没有什么关系,偶尔的调度失常不会影响大局。这个出在影子电梯上的错误让我直接红温非常恼怒,因为影子电梯带来的这点性能收益直接被一个RTLE冲没了,还不如写随机分配。

还是用第六次的hack策略,成功进行了一次hack。

最终架构可视化

类图

具体实现已在上文阐述。这次略有失败的一点,从图中也可以看出,电梯的部分内聚太严重了。最后子电梯一个类有400多行。如果重新让我实现,我会考虑把电梯本身,电梯动作,电梯策略,电梯线程分开,这样结构更清晰。

协作图

主要线程有以上五个,上图展示了出现乘客请求、RESET请求、退出进程时的逻辑。

稳定与异变、可扩展

稳定

尽管迭代中有一些变化,还是有很多不变的量。

简单的不变量例如:总楼层,按常理讲应该不变;电梯数量等。

抽象的不变量例如:电梯运行策略,LOOK大体不变;线程协同的流水线架构,更多的要求只不过是不同种类的请求;电梯行为模式;调度器行为模式等。

异变

电梯有很多在迭代中可以变化的量。

简单的变化量例如:电梯的运行承载量;电梯的速度;电梯开关门时间(未要求);单电梯内轿厢数等。

抽象的变化量例如:电梯调度策略,根据电梯而变化;请求类型,除了乘客请求可增加RESET请求,等等。

可扩展性

这份代码有很强的可扩展性。首先,各类参数用Parameter封装,简单的参数调整都可支持,即使楼层可变有点抽象也可调整。其次,电梯包含子电梯的结构十分灵活,如果出现三子电梯四子电梯也可以处理。再次,流水线+请求的结构使得更多复杂请求的处理成为可能:例如,可以加入电梯调试请求,让电梯立刻放下乘客回到第一层;可以加入优先级,某些乘客比其他乘客更加紧急;可以加入强制结束请求,使其立刻放出所有乘客并强制关闭,不再使用;配合强制结束请求,可以加入重启操作,重启某个强制关闭的电梯等等。

Debug

这部分的Debug是一个相当困难的工作。一方面是bug的不可复现性,另一方面加入调试输出语句会影响程序的运行,原先相对容易出现的一些死锁可能就不再会出现,而且还不能使用断点调试。

调试死锁,我的方法主要是在获取锁和wait的前后加入输出,观察哪些线程因为哪个语句卡住,再试图理解死锁发生的原因,最后进行处理。如果可以,根据这个原因构造一个小数据卡自己,用于测试。同时,借助民间的评测机辅助测试调试。

心得

可能是经历过了计组的拷打,我的破防阈值变得非常高,现在很少有课程能破我防了。尽管如此,回看这个月的多线程电梯三次作业,只能说这也确实是个重量级。

由于之前没有学过多线程编程,对于锁和同步的理解是这个单元最大的挑战。横跨一整个hw6到hw7,周围的同学都在为各种死锁自闭。面对不可复现的bug和输出调试以及瞪眼两者作为唯二的工具,不得不说我的分析能力和耐心都得到了很大的提升。

线程安全容不得一点马虎,任何两个语句之间只要不加锁,就有可能会有变动,就可能会触发各种各样的问题。唯一的对策是,任何地方都想清楚弄明白。确保每一点的安全。就像上文说的那样,多线程的各种特性给debug带来了相当多的困难,那么一方面的努力就是写的时候就尽可能避免bug的出现。另一边,如果出现了bug,一定要有耐心,锁定bug出现的位置,理性分析bug出现的原因,思考运行模式中的漏洞,把问题处理清楚。

层次化设计需要全局的视野,重新剖析需求的过程,将其分步梳理出来,再按照这些步骤逐层搭出数据流,并设计全局架构。有可能后期需要对架构做各种修改,这就需要清晰的思路和预见性的思考方式。

第二单元的成绩不够满意,但也还算比较顺利,没出什么大问题。后面再接再厉。

一点点DLC

在这里插入图片描述
今天看cmu的计算机体系结构课,看到了一个很有意思的东西叫runahead excution,这是一个处理器被cache miss stall 的时候,为了不浪费时间,把cpu调整为"runahead"模式,继续往前运行,如果碰到新的load指令就一起访存的策略。这样并行访存,重叠访存时间就可以节约很多运行时间。我发现这个东西不就是这个单元的影子电梯吗,这就是把cpu调整成影子模式再向前运行,不仅可以提前计算那些与当前cache miss 的load指令无关的运算,还可以提前去访存节约时间。而且这种策略好处也和影子电梯很像:效果好、容易实现(影子电梯只需要继承一下原来的电梯)、不需要100%精确度(这种cpu策略,即使有branch分支错了也不影响正常运行,而且错误代价也不一定有,因为两个分支很有可能共用一些内存;影子电梯,即使偶尔出一些差错也无妨)。之前我其实多少有点觉得OO这课只有之后要当苦逼程序员的那些人学了才会用,现在看来我这么想也不是很对,某些思想其实是无处不在的。

  • 24
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值