目录
一.架构分析
(一)第一次作业
刚进入这一单元时,完全不知道多线程该怎么用,该使用在哪里。等到课程组给出的实验代码时,才了解到多线程的写法和锁的用处。所以第一次作业的架构大概就是对输入的指令建立一个线程,处理不同时间戳的输入,直到读到null时结束该线程。第一次我并没有开启分配线程,我觉得没有必要在这次作业进行分配,因为这次的作业的每个乘客直接指定了乘坐的电梯,但在后面的作业不指定相应的电梯,这个线程就显得十分必要了。还有就是六个电梯线程来承载乘客,每个线程都单独处理该电梯的等待队列中的乘客,输入线程来向这六个电梯线程分配乘客。
UML类图:
UML协作图(sequence diagram):
调度器设计
我这一次的作业并没有设计调度器,因为这一次的乘客都会指定特定的电梯,当指令的输入就直接进行分配,所以在这一次的作业并没有设计调度器。
(二)第二次作业
这一次的作业的乘客没有指定电梯,而且会有reset操作,对于reset电梯中的乘客需要进行重新分配。此时,调度器的存在就显得很重要了。这次作业的架构主要就是添加了Schedule类,对乘客进行分配。其次就是reset的实现,将电梯的参数进行重写,并且清空等待队列和电梯里的乘客。这次的作业的实现的难点主要在于对乘客的分配如何实现电梯性能的最优解,还有就是reset是电梯重新receive时间戳与电梯重置时间戳先后的问题,这部分我感觉是bug最多的地方。
解决方案:
调度器解决乘客分配的问题,我的思路主要就是通过托盘得到电梯的参数,随后设计一个函数对这些参数进行评分,最后得到最优的电梯,虽然性能肯定不如影子电梯,但是实现起来还是比较方便的,而且最终性能还是可以的。
对于receive时间戳的问题,出现bug的原因主要就是锁的使用,在某个电梯reset期间,该电梯的等待队列必须被锁住,不能往里面添加任何乘客,但其他电梯可以分配乘客,如果在这段时间段中锁住所有的等待队列,那么执行的时间和性能都会有很大的损失。所以我在实现电梯reset是将电梯人数和等待队列清空并重新加入到大队列中以及reset_begin和begin_end期间都加上了该电梯等待队列的锁,以保证时间戳的正确性。最后的debug主要就是轮询和cup的超时,最后通过改变最后结束和wait的判断条件来解决这个问题。
UML协作图:
(三)第三次作业
第三次作业的任务只有将电梯变为双轿厢电梯。调度和第六次的思路差不多,就是通过托盘得到电梯的参数,随后设计一个函数对这些参数进行评分,最后得到最优的电梯。设计双轿厢的思路当时有两个,一个是开始时就设计12个电梯线程,等到有双轿厢的请求时才开始移动,还有就是在电梯线程DCreset阶段开启新电梯的线程,我个人感觉第二个更好实现,所以选择了第二种方式,因为第一种要设计初始楼层来保证他不会有任何移动。最终的实现就是把原来的电梯设计为A电梯,再新建一个线程为B电梯。我觉得设计的最好的一个点就是电梯相撞的问题,两个电梯设计一个共享的锁,只有一个线程可以进入来移动电梯,而且最终的实现很简单,大概只添加了4-5行。
UML类图
UML协作图
二.锁和同步块
上锁的办法主要有三种:锁对象、锁方法和Lock类
锁对象
synchronized (requestTable) {
//to do
requestTable.remove();
requestTable.add();
}
在代码执行该段代码时,其他线程就没办法进入由requestTable锁住的任何代码段。
锁方法
public synchronized void addPersonRequest(PersonRequest personRequest) {
personRequests.add(personRequest);
notifyAll();
}
方法本身成为一个锁,任何时间内只有一个线程能进入并执行这一方法。
Lock类
采用Lock类进行上锁,写完这一单元后感觉是最好的上锁方法。
private Lock requestTable = new ReentrantLock();
requestTable.lock();
try {
//to do
}finally {
requestTable.unlock();
}
这样写的作用大概和锁对象的作用差不多,但是更加直观,实用性感觉也更加好。
死锁
以锁对象为例
synchronized (requestTable) {
synchronized (newRequestTable) {
//to do
}
}
synchronized (newRequestTable) {
synchronized (requestTable) {
//to do
}
}
两个代码to do都有对锁对象的操作,不然没有必要进行上锁,这样写就会导致死锁,因为第一个进入requestTable,而第二个恰好进入newRequestTable,这样两个代码块互相把持着对方的锁,就会一直互相不会释放对方的锁,最终导致自锁。解决方案就是调整锁对象的顺序就可以完美解决死锁的问题,当然死锁不仅限于这种死锁,但大体都是互相把持着对方的锁,导致会一直卡在这里,导致死锁。
三.调度策略
生产者-消费者模式
我的调度策略并没有使用性能最优的影子电梯,我通过生产者-消费者模式建立一个托盘来set电梯中实时的参数,在通过设计一个函数,传入电梯的参数得到评分,通过目前最佳的评分进行分配电梯,通过调整函数中的比值来得到目前最优的解决方案,但这样会存在缺点,首先性能肯定不如影子电梯,但是实现还是比较简单,其次就是reset时得到的参数可能会有偏差,导致得到的电梯可能不是目前的最优解,想要优化这一点感觉还是难以实现的,要是等到reset结束,要等到1.2s,而且等到多个电梯先后进行reset,等待的时间就会更长,最终的性能可能会更差,所以最好的解决方案就是在reset阶段之前就重置电梯参数并传入托盘中,等到reset结束后已经选出最优解的电梯了。
影子电梯
虽然我自己没有写,但看了讨论区影子电梯的实现,主要思路就是贪心算法,具体操作主要就是克隆电梯,再讲电梯的sleep(time),改为time++,wait时就直接结束返回time,得到局部的最优解,通过局部的最优解推演全局的最优解,但我个人感觉影子电梯的实现比较困难。
四.双轿厢运行机制
我的双轿厢电梯的思路是将原电梯变为A电梯,再新建一个newElevator当作B电梯,并没有用原来的Elevator类,而是新建了一个NewElevator类,两者实现的基本相同,这样写的话的判断条件会少很多,只不过两个电梯之间的共享对象会很多,而且调度算法还要重写一部分。
对于A,B电梯都有一个maxFloor和minFloor作为该电梯的两个运行区间,其实双轿厢电梯的实现不难,难点在于细节的实现,比如A,B电梯之间的换乘和防止两个电梯相撞的问题。
电梯之间的换乘
for (PersonRequest personRequest : personInElevator) {
if (floor == maxFloor) {
tmpOut.add(personRequest);
PersonRequest tmp = new PersonRequest(floor, personRequest.getToFloor(),
personRequest.getPersonId());
newRequestTable.addPersonRequest(tmp);
}
}
for (PersonRequest personRequest : tmpOut) {
TimableOutput.println("OUT-" + personRequest.getPersonId()
+ "-" + floor + "-" + id + (isDouble ? "-A" : ""));
TimableOutput.println("RECEIVE-" + personRequest.getPersonId() +
"-" + id + "-B");
}
对于到达换乘楼层的乘客,但没有到达目的楼层,会直接换乘到另一个电梯,并且输出RECEIVE。
电梯相撞
//A:
synchronized (lock) {
if (up && this.floor == maxFloor - 1 && newRequestTable.getFloor() == maxFloor{
return;
} else if (up) {
floor++;
} else {
floor--;
}
requestTable.setFloor(floor);
TimableOutput.println("ARRIVE-" + floor + "-" + id + (isDouble ? "-A" : ""));
}
//B:
synchronized (lock) {
if (requestTable.getFloor() == minFloor && !up && floor == minFloor + 1) {
return;
} else if (up) {
floor++;
} else {
floor--;
}
newRequestTable.setFloor(floor);
TimableOutput.println("ARRIVE-" + floor + "-" + id + "-B");
}
解决电梯相撞的问题最核心的就是两个电梯共用一个锁,当一个电梯楼层移动时,该电梯会持有这个lock的锁,另一个电梯就不会进入来移动电梯。这种共享锁的方式实现得十分简洁,只在源代码的基础上添加了一个共享锁和相撞时的判断。当另一个电梯在换乘楼层时且该电梯在换乘楼层的上一层或者下一层时,该电梯的楼层不会发生任何改变,直到另一个电梯离开换乘楼层时才会进入换乘楼层,由此最好的解决电梯相撞的问题。
五.bug分析
(一)第一次作业
第一次由于我没有设置调度器,代码的复杂度还是比较低的,所以第一次并没有什么bug,但是在最后一次作业中出现了第一次存在的bug,具体原因就是锁加少了,导致了对电梯中的等待队列一边进行读操作,一边进行add操作,最终的解决方案只要在读操作阶段添加一个锁就可以了。
(二)第二次作业
第二次作业新增一个调度器,复杂度变高了,为了防止出现bug,在等待类中添加了很多锁方法,而且在电梯线程中也加了锁对象。最后出现的bug是由于reset导致的,由于对等待队列加了锁
在这一过程中resetRequest.isEmpty(),判断过程发生了阻塞,导致之前的判断已经为true了,但当阻塞结束时,之前的判断有变为false,道中setEnd提前开始导致最终的电梯线程提前结束了。这个bug我个人感觉很细节,也是de了很久。最终的解决方案就是把判断的顺序稍微调整一下就可以了。debug方法采用最原始的print方法,感觉这也是多线程debug的最好方法。
(三)第三次作业
第三次出现的bug还是第一次写出现的bug,前两次的强测和互测都没有测出来,第三次强测也过来,只是在互测时被hack到了。
六.心得体会
这次第二单元的多线程比第一单元的递归下降更容易理解,感觉整体难度比第一单元简单,虽然第二单元debug的时间更长,更折磨。但是个人第一单元的递归下降太抽象了,我个人是不愿意再写这样抽象的代码了。而且第二单元也更加实用更好理解,多线程的使用在很多场景都有很多的应用,虽然debug不是很方便,但学会写多线程在未来会有很大的帮助,希望课程组在以后得代码开发也想这次的多线程一样实用。