301
社区成员
发帖
与我相关
我的任务
分享本单元的主要目标是开发一个多线程的实时电梯控制系统。初始阶段,我们需掌握Java的多线程编程技巧和线程安全的基础处理技术,以确保实现电梯的基本功能。随后,需要应用更高级的线程同步与互斥手段来引入reset功能,并通过调整和改善电梯调度逻辑,以优化其性能。整体来看,本单元的核心挑战在于有效管理线程的同步与互斥,并且制定出一个高效的电梯调度方案。本文将概述本单元三次作业的核心内容,包括线程安全的策略、线程间的协作方式以及电梯的调度策略分析。
hw5需要实现基本的电梯运行功能。


在本单元中,我采用生产者-消费者模式进行设计。其中,生产者角色由输入线程InputThread承担,消费者角色则由6个Elevator线程组成。具体的操作流程包括:InputThread首先将接收到的乘客请求放入公共等待队列waitAll中,随后,调度器线程Schedule将waitAll队列中的请求按照一定的策略分配到每个Elevator各自的等待队列waitQueue中。
在作业hw5中,主要的考虑是解决inputThread线程写入waitAll和manager线程读取waitAll之间的冲突,以及Schedule线程写入各Elevator的waitQueue与各Elevator读取其waitQueue之间的冲突。为了处理这些冲突,我为WaitQueue类的相关读写操作添加了synchronized关键字,以确保线程安全。此外,对于那些需要频繁访问WaitQueue类的代码块,我也使用了synchronized关键字进行了同步控制。
具体到代码,对WaitQueue的增加Request以及读取Request的方法如下:
public synchronized void addRequest(Request person) {
waitQueue.add(person);
notifyAll();
}
public synchronized Request getRequest() {
if (!isEnd() && isEmpty()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (!waitQueue.isEmpty()) {
Request request = waitQueue.remove(0);
notifyAll();
return request;
}
return null;
}
关于锁与同步块中处理语句之间的关系,为了使得同步块范围尽量小,同时保证线程的安全性,需要确保锁和同步块中执行的代码语句有强相关性,例如waitQueue锁住的代码块里面要尽量都是读写waitQueue的语句。并且要注意锁的对象是没有传递关系的。
这一点我一开始没有搞清楚导致踩了坑,例如Elevator中实现的getIn方法,我一开始误以为既然waitQueue作为Elevator的一个属性,那么对Elevator加锁就能保证对waitQueue的操作是线程安全的,因此直接在方法上加了synchornized关键字,但是实际上这样做是不对的,因为这样只能保证对Elevator的操作是线程安全的,而对waitQueue的操作并不是线程安全的,因此应该在对waitQueue进行操作的代码块上加锁。
public boolean getIn() { // 判断是否有人要进电梯
synchronized (waitQueue) {
if (!waitQueue.isEmpty() && insideList.size() < 6) {
for (Request r : waitQueue.getWaitQueue()) {
if (onTheWay(r, direction) && r.getStart() == currentFloor) {
waitQueue.notifyAll();
return true;
}
}
}
waitQueue.notifyAll();
return false;
}
}
调度器由Schedule类进行实现,它通过waitAll和InputThread与Elevator进行交互,由InputThread向waitAll中写入数据,Schedule从中取出后读取数据,然后按照调度策略分配给相应电梯的waitQueue。hw5的乘梯请求中指定了每个请求的电梯号,只需要将请求分配给对应的电梯即可。
本次作业强测和互测均未出现bug。
本次作业在第一次的基础上增加了电梯的RESET指令。处理RESET指令时,我采取的策略是将被reset的电梯内乘客及被reset的电梯的等待队列中所有的乘客请求重新加入到waitAll中进行再分配,并将电梯硬控1.2秒后再重新运行。然而实现思路虽然不难,但实际实现起来却有较大难度。为了更好地解决线程冲突问题,我引入了读写锁ReentrantReadWriteLock来进行更精确高效的同步控制。
另外,由于电梯随时都有可能被reset并将自己的相关乘梯请求放回到waitAll中,故hw5中通过InputThread的结束判断Schedule和Elevator的结束是不完善的,需要判断是否满足所有发出请求的乘客都到达了目的地并且InputThread输入结束来判断Schedule和Elevator是否应该结束。


由于本次作业与上次作业在基本架构方面未发生改变,所以上次的同步控制块基本不用加以修改。主要修改的部分是对于Schedule类和InputThread类的控制,由于RESET指令需要修改电梯属性,因此在该类中加上了读写锁来保证线程安全,例如方法的具体实现如下:
public void addElevators(Elevator elevator) {
lock.writeLock().lock();
try {
elevators.add(elevator);
waitLists.add(elevator.getWaitQueue());
} finally {
lock.writeLock().unlock();
}
}
除此之外,由于在Maintain之后需要修改被踢出乘客的出发地,因此Person也可能出现线程冲突,所以我在该类内部也加上了读写锁。
public int getStart() {
lock.readLock().lock();
try {
return start;
} finally {
lock.readLock().unlock();
}
}
我采用较为简单的调度策略:对于所有不处于reset状态的电梯,如果乘客的目标方向与该电梯当前运行方向一致,并且该电梯还未到达等待乘客的出发层,且它的内部乘客和等待乘客之和不满容量上限,则将该电梯视作一个备选电梯,在所有备选电梯中选择一个离请求乘客楼层距离最近的电梯作为最终决定的电梯。如果所有电梯都不满足备选电梯的条件,那么就分配给内部和外部乘客数量之和最小的电梯,以尽量保证均衡性。如果目前没有符合条件的电梯,就继续等待。
这样的调度策略综合考虑了时间和电量的因素。时间方面,尽量均衡的去讲请求分配给各个电梯,以免出现少数电梯在处理大量请求,而其他电梯却空闲的情况。电量方面,尽量选择距离请求乘客楼层最近的电梯,并且同批次尽量接更多的乘客,以减少反复接送的电量消耗。这样的调度策略在实际运行中表现良好,能够有效的提高电梯的运行效率。
具体实现如下:
public WaitQueue getWaitQueue(Passenger passenger) {
int start = passenger.getStart();
int distance = 20;
Elevator e = null;
WaitQueue waitList = null;
lock.readLock().lock();
try {
while (true) {
while (waitList == null) {
for (Elevator elevator : elevators) {
if (!elevator.isReset()) {
int direction = elevator.getDirection();
int currentFloor = elevator.getCurrentFloor();
int capacity = elevator.getCapacity();
WaitQueue waitQ = elevator.getWaitQueue();
synchronized (waitQ) {
if ((passenger.getDestination() - start) * direction >= 0 &&
(waitQ.getSize() + elevator.getInsideNum() < capacity) &&
Math.abs(currentFloor - start) < distance) {
if (direction == 0 || (direction == 1 && currentFloor <= start)
|| (direction == -1 && currentFloor >= start)) {
distance = Math.abs(currentFloor - start);
waitList = waitQ;
e = elevator;
}
}
}
}
}
if (waitList == null) {
int min = 200;
for (Elevator elevator : elevators) {
WaitQueue waitTemp = elevator.getWaitQueue();
if (elevator.isReset() || waitTemp.getSize() > 30) {
continue;
}
int insideNum = elevator.getInsideNum();
synchronized (waitTemp) {
int outsideNum = waitTemp.getSize();
if (insideNum + outsideNum < min) {
waitList = waitTemp;
min = insideNum + outsideNum;
e = elevator;
}
}
}
}
}
synchronized (e) {
if (!e.isReset()) {
TimableOutput.println("RECEIVE-" + passenger.getId() + "-" + e.getID());
break;
}
}
}
return waitList;
} finally {
lock.readLock().unlock();
}
}
本次作业强测未出现bug。但互测中被hack出TLE的bug,因为在对电梯进行reset操作时,会取出其相关的所有乘客重新加入到总队列中,如果电梯运行一段时间后,突然5个电梯都进行reset,就会导致剩下的一个电梯被塞满请求,而其他电梯完成reset后空置,从而TLE。解决方法倒也很简单,即限定电梯的等待队列人数不得超过30人,否则请求将继续等待直至有合适的电梯接纳。
本次作业在第二次的基础上增加了双轿厢电梯电梯的RESET指令。为了尽量减少改动量(那周实在忙(悲)),本次作业我主要是在普通电梯的基础上进行改造以实现双轿厢电梯的功能。思考了一下发现完全可以把双轿厢电梯沿用普通电梯的方法将进行处理,只需要规定上下轿厢只允许一个处在运行状态,并当电梯在转换层运行时进行上下轿厢的乘客转让,并且在转换层加入让层机制以防止碰撞即可(下文会详细阐述)。
我也构思了理论上性能更高的方法,但因为时间原因没有加以实现。即出现双轿厢电梯reset请求时,将被reset的普通电梯分裂为两个电梯加入到总电梯队列中,并将电梯的运行范围作为参数,以让上下两个轿厢作为独立的电梯去运行。这样在乘客发出请求之后,每个电梯都能同时运行接送乘客,如果一次不能到达,则到达转换层之后重新发出请求等待接送即可(一个乘客可能被转送多次),这样理论上可以提高电梯的运行效率。至于防撞机制,可以对于转换层设置一个值为1的信号量,从而控制只允许一个同单位电梯进入转换层且到达后需要尽快离开该层,另一个电梯则需等待。


本次作业与上次作业在基本架构方面也未发生改变,只是在Elevator中增加实现了双轿厢电梯的运行机制,所以上次的同步控制块基本不用加以修改。
本次作业由于对运送乘客的要求与第二次基本一致,故调度策略基本没有改变(普通电梯和双轿厢电梯视为同等地位)。
本次作业对于双轿厢电梯,需要控制转换层不能同时出现AB轿厢电梯,因此需要实现防撞机制。我的实现方法主要基于逻辑判断,虽然繁琐且不太优美,但至少保证了正确性:上/下电梯到达转换层完成必要的人员进出之后,需要立刻让出该层,以便另一个电梯进入。另外进入转换层前,需要检查转换层中是否有电梯,如有则让其让出(不过这种情况其实不会出现,由于人为限制了上下轿厢只有一个处于运行机制,所以理论上不会出现一个电梯想进入转换层时需要等待另一个电梯让层的情况)具体的代码实现如下:
private void doubleDown() throws InterruptedException {
if (upperCurrentFloor == currentFloor) { // 如果上电梯在运行
if (upperCurrentFloor > transFloor + 1) { // 没到达转换楼层正常运行
upperCurrentFloor--;
currentFloor--;
Thread.sleep(speed);
TimableOutput.println("ARRIVE-" + upperCurrentFloor + "-" + id + "-B");
} else if (upperCurrentFloor == transFloor + 1) {
currentFloor--;
if (transFloor == lowerCurrentFloor) {
lowerCurrentFloor--;
Thread.sleep(speed);
TimableOutput.println("ARRIVE-" + lowerCurrentFloor + "-" + id + "-A");
}
upperCurrentFloor--;
Thread.sleep(speed); // 到达转换楼层
TimableOutput.println("ARRIVE-" + transFloor + "-" + id + "-B");
TimableOutput.println("OPEN-" + transFloor + "-" + id + "-B");//上厢放人
sleep(200);
ArrayList<Passenger> newList = new ArrayList<>();
boolean trans = transDouble("-B", "-A", newList);
if (!trans) {
PassengerInTrans("B");
direction = 1;
}
sleep(200);
TimableOutput.println("CLOSE-" + transFloor + "-" + id + "-B");
upperCurrentFloor++;
Thread.sleep(speed); // 上轿厢让层
TimableOutput.println("ARRIVE-" + (transFloor + 1) + "-" + id + "-B");
if (trans) {
while (transFloor != lowerCurrentFloor) { // 下轿厢到转换层
lowerCurrentFloor++;
Thread.sleep(speed);
TimableOutput.println("ARRIVE-" + lowerCurrentFloor + "-" + id + "-A");
}
TimableOutput.println("OPEN-" + transFloor + "-" + id + "-A");
sleep(200);
for (Passenger person : insideList) {
TimableOutput.println("IN-" + person.getId() + "-" +
transFloor + "-" + id + "-A");
}
PassengerInTrans("A");
sleep(200);
TimableOutput.println("CLOSE-" + transFloor + "-" + id + "-A");
lowerCurrentFloor--;
Thread.sleep(speed);
TimableOutput.println("ARRIVE-" + lowerCurrentFloor + "-" + id + "-A");
currentFloor--;
} else {
currentFloor++;
}
}
} else if (lowerCurrentFloor == currentFloor) {
lowerCurrentFloor--;
currentFloor--;
sleep(speed);
TimableOutput.println("ARRIVE-" + lowerCurrentFloor + "-" + id + "-A");
}
}
本次作业出现bug,原因是在Elevator类的reset“踢人”过程中,过早的减少了电梯的insideNum值(还没有来得及将电梯内人员请求重新加入到总队列中),但这个值同样在Schedule类中被使用以判断运行是否结束,因此可能发生在乘客移交过程中调度器误判导致提前终止。将减少insideNum值的操作移至请求重新加入总队列之后,修复了该bug。
另外关于多线程的调试,由于多线程的特性,有时候会出现一些难以复现的bug,并且断点调试的方法也基本无法运用。这时就需要通过打印日志的方式来进行调试。在这次作业中,也是运用TimableOutput.println()大法以便更好地理解程序的运行过程,还是帮助我发现了不少问题。结合讨论区中的帖子,也是运用打印法解决了CTLE的轮询问题。
本单元的任务迭代过程中,我的程序整体架构并没有发生太大的变化,这体现出生产者-消费者模型具有较强的扩展性,也说明了层次化设计的重要性。在第二三次作业的迭代中,如需增加电梯的新功能或者运行限制(如双轿厢电梯的引入),只需要修改Elevator类的相关方法;如果需要修改调度策略,则修改Schedule中的方法,整体架构没有像第一单元那样做了大改。另外对于增加的新功能和需求,课程组也帮助我们对InputThread中的请求进行了统一。对于未来的扩展能力也是如此,只需要修改对应的模块即可。
我认为在作业迭代中,稳定的内容是电梯的运行方式,例如上行、下行、开门、关门、上下乘客等实际上并没有发生较大改变,哪怕是双轿厢电梯的引入,我也是在普通电梯的基础上做了延伸和特判。易变的内容则是电梯的运行限制条件及状态,如RESET以及去年的MAINTAIN,这就要求做好类之间的协同,这也是多线程的主要难点。另外电梯的调度策略也是仁者见仁,智者见智,可以灵活的根据需求进行调整(虽然我没有做太多改动)。
本单元的主要难点还当是处理线程安全问题,理论课中介绍了wait()、notify()和读写锁两套方案。虽然作用基本相同,但在某些情况下还是有优劣之分的。如果一个代码段中需要多次访问一个共享的对象时,任何一个时刻丢掉锁都可能导致程序的异常执行,这种情况使用synchronized进行代码块的修饰就更加保险。所以说,这两者都是非常重要的,我们必须全部掌握,才能更好地解决线程安全的问题。
总的来说也是跌跌撞撞完成了久仰大名的电梯单元,虽然在实现过程中遇到了不少困难,但是通过不断的理论学习和代码实践,最终还是对多线程问题有了更深入的了解。在这个过程中,我学到了很多关于多线程编程的知识,也对Java的使用有了更深的理解。希望在接下来的学习中能够更好地应用这些知识,提高自己的编程能力。