301
社区成员
发帖
与我相关
我的任务
分享第一单元代码的顺利完成让我有了完成第二单元的巨大信心。但在第二单元的迭代过程中,引入的线程与锁的机制让我在编写代码和debug的过程中遇到了不小的困难,好在一路磕磕绊绊走过了第二单元,经受住了魔改电梯系统的折磨后,我对多线程编程的能力从无到有,实实在在得到了提升。

总体上来说借鉴了实验的思路,自hw5以来架构并没有巨大改变,但不足在于我将策略类和电梯类全都封装在Elevator类里,导致迭代到hw7时类的行数接近500行。
第一次作业的代码规模分析如下:

第二次作业的代码规模分析如下:

第三次作业的代码规模分析如下:

可以看到,代码规模的增长主要集中于Schedule和Elevator中,这是因为在hw5->hw6的过程中,电梯类新增了reset相关方法,调度器类增加了调度楼层的方法,在hw6->hw7的过程中,电梯类新增了doubleCarReset,调度器也随之做出了必要的改变。可以看到,我的代码规模横向对比而言应该算较小的,这是因为我并没有使用影子电梯等高端算法,也就无需实现深克隆等媒介方法。

因为作业中没有额外要求,三次作业里用到的锁均为synchronized,没有出现使用读写锁的情况。受实验代码影响,hw5中将WaitQueue中的方法设为synchronized,这样写起来虽然方便,但会导致连续调用共享对象时频繁唤起其他线程,导致CPU时间增长,性能下降。例如:
public synchronized boolean isEmpty() {
notifyAll();
return waitQueue.isEmpty();
}
public synchronized void setEnd(boolean end) {
this.end = end;
notifyAll();
}
public synchronized void add(PersonRequest personRequest) {
waitQueue.add(personRequest);
notifyAll();
}
public synchronized boolean isEnd() {
notifyAll();
return this.end;
}
可以看到,这样子频繁的调用notifyAll显得没有那么优雅,若连续调用类中方法时代码卡住,到底是在哪一个方法中出了问题也难以判断。
故后续的hw6和hw7中,我只对所需调用的共享对象上锁,产生的同步代码块也便于理解。以我捎带策略的代码为例:
public void update() throws InterruptedException {
synchronized (inQueue) {
if (checkNormalReset() || checkDoubleCarReset()) {
inQueue.notifyAll();
return;
}
while (des == -1) { // 若没有主请求,一直寻找主请求
if (checkNormalReset() || checkDoubleCarReset()) {
inQueue.notifyAll();
return;
}
int dis = -1;
for (int i = loFloor; i <= hiFloor; i++) {
if (inQueue.getFloorPassengerNum(i) != 0) {
if (Math.abs(pos - i) > dis) {
des = i;
dis = Math.abs(pos - i);
}
}
}
if (des != -1) {
break;
}
if (inQueue.isEnd()) { // 若电梯已没有请求,且输入队列中不再有请求,结束。
end = true;
inQueue.notifyAll();
return;
}
if (DEBUG) { System.out.println("电梯 " + id + "休眠"); }
inQueue.notifyAll();
inQueue.wait();
if (DEBUG) { System.out.println("电梯 " + id + "唤醒"); }
}
normalOut();
if (type != 0 && pos == changeFloor && innerCnt != 0) {
forceOut(false);
backToWaitQueue();
inQueue.notifyAll();
return;
} else { pick(inQueue); } //捎带
if (innerCnt == 0 && type != 0 && pos == changeFloor) { //自动离开换乘层
if (id <= 6) { des = changeFloor - 1; }
else { des = changeFloor + 1; }
}
if (pos == des) {
des = -1;
}
inQueue.notifyAll();
}
}
hw5中不需实现调度器,但考虑到未来必定会出现此类需求,我新建了Schedule类来预备后续的扩展内容。Schedule在我的程序里,是连接WaitQueue和elevatorQueue的桥梁,理想情况下,电梯仅仅需要知道调度器分配给它的elevatorQueue并进行处理,而无需知道是否有别的电梯。或者别的电梯是否运行。调度器与消费者-生产者模式较为相像,起到一个类似“托盘”的作用。事实上,在我的设计中,Schedule还起到了判断电梯线程是否应该结束的功用。
本次作业中,我采用的是按权重随机分配策略,具体实现思路十分简单:
按照每个电梯的性能得到一个权值,具体来说,速度越快,容量越大,权值就越高
检查当前可用的电梯,将当前可用电梯的权值相加得到总权值。
按照总权值为上限进行随机,可以认为,权值越大的电梯被随机到的概率越大。
在最初编写调度器时,我也参考了很多学长学姐的博客,其中影子电梯的调度器构想我曾认真考虑过,但由于涉及到深拷贝,而我在第一单元在拷贝的编写上出现过bug,所以决定先把随机策略写掉,是否编写影子电梯根据后期精力决定(最后也确实没有实现)。在实现完随机策略后,我简单总结其优点如下:
防御性编程!互测无法被针对性hack,只能寄希望于随机的犯病
时间上与均摊算法相差不多,大多情况下优于均摊。
对于电量未进行特意限制。
编写简单,不超过30行即可解决。
但与影子电梯相比,在性能上确实存在硬伤,我hw6的强测仅有95分,hw7仅有89分。下次要克服畏难情绪,尽全力实现自己心中的最好。
为了解决双轿厢不能同时到达共享楼层的问题,我新建立了FloorLock类,将其作为一个共享对象分发给双轿厢的AB两电梯,当电梯想要达到共享楼层,就需要在共享对象被释放后到达并设置楼层状态为Occupied,离开楼层后释放共享对象。
private void up() throws InterruptedException {
if (opened) {
close();
}
sleep(moveTime);
pos++;
if (type != 0 && pos == changeFloor) {
floorLock.setOccupied();
}
if (type == 0) { TimableOutput.println("ARRIVE-" + pos + "-" + id); }
if (type == 1) { TimableOutput.println("ARRIVE-" + pos + "-" + id + "-A"); }
if (type == -1) { TimableOutput.println("ARRIVE-" + pos + "-" + (id - 6) + "-B"); }
if (type != 0 && pos == changeFloor + 1) {
floorLock.setRelease();
}
}
private void down() throws InterruptedException {
if (opened) {
close();
}
sleep(moveTime);
pos--;
if (type != 0 && pos == changeFloor) {
floorLock.setOccupied();
}
if (type == 0) { TimableOutput.println("ARRIVE-" + pos + "-" + id); }
if (type == 1) { TimableOutput.println("ARRIVE-" + pos + "-" + id + "-A"); }
if (type == -1) { TimableOutput.println("ARRIVE-" + pos + "-" + (id - 6) + "-B"); }
if (type != 0 && pos == changeFloor - 1) {
floorLock.setRelease();
}
}
由于研究过去年的作业迭代情况,我在第一次作业就建立好了绝大部分所需的类,回顾迭代的过程,我们可以发现,线程之间的关系往往是十分稳定的,如果一开始就安排好明确的线程分工协作,能达到事半功倍的效果;同时迭代过程中也不免对方法进行删改与增添,例如hw6中就加入了重定义电梯参数和自行调度电梯等需求,我们只需要将需求进行拆分并在相应的类中一步步实现即可。
Debug:本单元中,bug多出现在多线程的特性上,比如线程进入wait()未被唤醒而无法正常终止,导致程序卡死,这一部分需要通过合理控制wait数量,同时分析程序的Wait-Notify机理,在需要的地方加入Notify,在不需要的地方尽量减少Notify,控制Notify的数量也正是编写性能良好的程序的有意思之处。多线程的Debug存在较大的难度,其一是Bug无法稳定复现,有时往往要输入好几次数据才能有一次使程序无法终止;其二是很难利用IDEA自带的GDB,我上网查询了资料,虽然可以进行多线程的断点调试,但效率比我想象中低了太多,所以还是采用在程序输出调试内容来进行Debug。至于寻找测试数据,十分感谢DPO的贡献)
Hack:虽然三次互测都进入了A房,也就是说强测没有出现重大bug,但由于调度策略存在些许不足,导致围师必阙的数据能够将我Hack掉,具体来说,就是令其余五部电梯进入Reset,同时投放大量数据,试图令余下的一部电梯RECEIVE大量乘客(事实证明确实兵法对我确实有效)。我用了三行解决这个bug,说实话这种数据确实很好想,但我在课下没有进行充分的思考和测试,导致了此类bug的出现。我在互测中投放了一些数据规模和时间都较为极限的数据,也成功Hack到5个人,但其中应该有一些包含运气成分,因为第二次作业Hack了4个人,最后却只有3.5分。
被互测Hack的的解决代码如下:
private boolean checkAvailableElevator() {
int availableNum = 0;
int num = elevators.size();
for (int i = 1; i < num; i++) {
Elevator elevator = elevators.get(i);
if (!elevator.getNormal() && !elevator.getDoubleCar() && elevator.getType() == 0) {
availableNum += 2;
}
if (elevator.getType() != 0 && !elevator.getDoubleCar()) {
availableNum++;
}
}
return availableNum > 5;
}
线程安全指的是多个线程并发访问共享资源时,不会出现数据不一致或其他意外情况的情况。在多线程编程中,线程安全非常重要,因为多个线程可能会同时访问和修改同一数据,如果不进行适当的同步处理,就可能导致数据不一致、竞态条件和死锁等问题。
要考虑线程安全问题,就需要实现操作的原子性、可见性和有序性。例如自增操作,它本身其实并不是原子性操作,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二步,另一个线程就已经读取了值,导致结果错误。我们如果能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。这就实现了原子性。一个线程对共享变量的修改,另外一个线程不能立刻看到,这就是可见性。程序执行的顺序没有按照代码的先后顺序执行,这就是有序性,有序性的问题可能由编译中的重排序产生。
具体方法有:
使用关键字synchronized。当方法或代码块被synchronized关键字修饰时,只有一个线程能够进入该方法或代码块,其他线程则会被阻塞,直到当前线程执行完毕。被synchronized修饰的代码块称为同步块,当线程进入同步块时,会尝试获取对象的锁,如果对象没有被加锁或者已经获取了该对象的锁,则锁计数器+1;如果该对象已经被其他线程加锁,则该线程会进入阻塞状态,等待其他线程释放锁。当其他线程释放锁后,等待的线程会被唤醒,并重新尝试获取锁并执行同步块中的代码。同一时刻,只有一个线程可以获取对象的锁并执行同步块中的代码。
使用锁lock。使用Lock相较于synchronized更加高效。Lock锁最常用的是可重入锁ReentrantLock。可重入锁是指可以对同一个锁进行重复的加锁和解锁操作,每次加锁操作都必须对应一个解锁操作,否则锁将一直被占用。synchronized关键字也是可重入锁。其内部有一个类似于计数器的变量(锁计数器),每当加锁时,计数器+1,解锁时,计数器-1,直到计数器为0时释放锁。
使用关键字volatile。使用volatile关键字可以保证变量的可见性,即使多个线程同时访问同一个变量时,保证变量的值是一致的。此外,volatile还具有禁止指令重排的作用。当一个变量被volatile修饰时,所有线程访问该变量都是从主内存中读取最新的值。