BUAA_OO_2022第二单元电梯多线程总结

第二单元总结

1. 总结分析三次作业中同步块的设置和锁的选择,并分析锁与同步块中处理语句之间的关系

同步锁synchronized

  1. 修饰实例方法:作用于当前实例加锁
public class ReqBuffer {

    public synchronized void addRequest(MyRequest myRequest) {
        myRequests.add(myRequest);
        notifyAll();
    }
}

如果ReqBuffer类实例化了两个对象A和B,线程T1和T2会访问对象A,线程T3和T4会访问对象B。当T1进行A.addRequest()操作时,T2无法进行访问对象A,同理当T3进行B.addRequest()操作时,T4无法访问对象B。但是T1进行A.addRequest()操作不会影响到T3进行B.addRequest()操作。

  1. 修饰代码块:指定加锁对象,对给定对象加锁
public class Elevator extends Thread {

    public void lineChangeDirection() {
        boolean sign = true;
        synchronized (processingQueue) {
            for (MyRequest r : processingQueue.getRequests()) {
                if (r.getFromFloor() >= curFloor && up ||
                        r.getFromFloor() <= curFloor && !up) {
                    sign = false;
                    break;
                }
            }
            processingQueue.notifyAll();
        }
        if (sign) {
            up = !up;
        }
    }
}

在线程类Elevator中修饰代码块,对processingQueue加锁。尤为需要注意的是,不应该对这个方法使用同步锁,即public void synchronized lineChangeDirection(),这样的效果是给这个Elevator类的实例化对象加锁,而非对其中的processingQueue加锁,因此不会达到互斥的效果,线程是不安全的。

读写锁 ReadWriteLock

读写锁内部维护了两个锁,一个用于读操作,一个用于写操作。
读读不互斥,读写、写读、写写互斥。
加锁方法:

public class RoundElevatorList {
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    public RoundElevatorList() {}

    public void addRoundElevator(Elevator elevator) {
        readWriteLock.writeLock().lock();	// 写锁
        try {
            roundElevators.get(elevator.getFloor()).add(elevator);
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }

    public Lock getReadLock() {		// 获取读锁
        return readWriteLock.readLock();
    }
}

在问过助教之后,了解到读写锁是不需要notifyAll的,(而且notifyAll和notify好像只作用于synchronized同步锁)
附上一篇易懂的博客:轻松掌握java读写锁(ReentrantReadWriteLock)的实现原理

同步块的设置与锁的选择

在第五次和第六次作业中,我仅使用了synchronized同步锁,因为只有ReqBuffer类的对象实例会被多个线程所共享,所以我使用synchronized对ReqBuffer类中会对其属性进行读写的方法上锁,同时,对于InputHandler、Elevator和Schedule线程中会读写共享对象的方法,同步其内部对共享对象读写的代码块。

在第七次作业中,除了使用同步锁以外,还简单了解并使用了读写锁。因为我采用了动态分解请求的策略,每一个请求在 最初被获取 时以及 到达中转楼层楼座 时,都会访问当前的横向电梯容器,对于还未到达目的地的请求进行请求分解。这一策略,意味着要对横向电梯容器roundElevators进行大量的读操作,少量的写操作,所以使用读写锁。(从整体表现来看,动态分解请求似乎对性能提升并不显著,而且读写锁机制可能会导致写锁饥饿。)

2. 总结分析三次作业中的调度器设计,并分析调度器如何与程序中的线程进行交互

第五次作业,调度器的作用是将总候乘表中的请求按照楼座分给五部电梯的候乘表中。线程间的交互:InputHandler从输入中获取请求,将请求加入到waitQueue中,调度器Schedule依次取出waitQueue中的请求,然后加入到对应楼座的processingQueue中,电梯Elevator使用LOOK策略接受processingQueue中的请求。
第六次作业,调度器的作用与第五次作业基本相同,只是多了判断将请求分配给纵向电梯还是横向电梯的processingQueue中。线程的交互一样,只是电梯线程由5个增加到了15个。
第七次作业,调度器Schedule要依次取出 status = 1 的请求(表示该请求未到达目的地且未分配给任一电梯线程),当没有符合要求的请求时需要 wait(),否则会产生轮询导致CTLE。线程的交互仍与第五次作业类似。

总结:虽然三次作业都有使用调度器,但是调度器的功能实现比较单一,大部分的调度还是放在Elevator类里实现的,因而由于调度器与电梯的功能的耦合,在线程的唤醒和结束部分的处理变得复杂。(这样鸡肋的调度器还不如祭天)

3. 结合线程协同的架构模式,分析和总结三次作业架构设计的逐步变化

第五次作业

UML类图:image
UML协作图:image
第五次作业的架构,基本上是按照实验课的示例代码来写的(查重率肯定很高)

第六次作业

UML类图:image
UML协作图:image
第六次作业在第五次作业的基础上进行了简单的迭代,以自由竞争为主,彩色部分即修改的类,以及添加的协作关系,可以看到,较第五次增加了10张候乘表(横向电梯)以及增加实现InputHandler线程create Elevator线程。

第七次作业

UML类图:image
UML协作图:image
第七次作业较前两次作业最大的不同,在于对候乘表中乘客请求的处理。
在总候乘表waitQueue中的请求不会被remove,而是通过请求的状态status判断队列中是否还有请求待分配:1表示该请求在总候乘表中,尚未被分配给任何一楼层或任何一楼座的候乘表中,可由Schedule获得(getOneRequest)并分配; 2表示请求已经经过Schedule分配到了某一楼座或者楼层的候乘表,无法由Schedule再次获得(getOneRequest);0表示该请求已经到达最终目的地。
当waitQueue中的所有请求的状态都为0时,表明乘客均到达目的地,所有线程停止。

4. 分析自己程序的bug

第五次作业

互测中被hack出的bug是输出时间戳不是单调递增的。原因是输出线程不是安全的。
修复方法:依照讨论区的分享,增加了一个输出安全类,其中实现了一个静态的同步输出方法。

第六次作业

因为冗余的notifyAll()而导致轮询,在强测中TLE了14个点qwq。

// ReqBuffer 类
    public synchronized boolean isEnd() {
        notifyAll();
        return isEnd;
    }

    public synchronized boolean isEmpty() {
        notifyAll();
        return myRequests.isEmpty();
    }
// Elevator类
	//
    synchronized (processingQueue) {
            while (processingQueue.isEmpty()) {
                if (processingQueue.isEnd()) {
                    return;
                } else {
                    try {
                        processingQueue.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
	//
    }

原因:因为是自由竞争,如果两部电梯A、B共享一个候乘表,在输入线程(InputHandler)未结束processingQueue.isEnd() = false,但是共享的这个候乘表等待队列为空processingQueue.isEmpty() = true时,就会有电梯A进入等待状态,让出锁,电梯B拿到锁进入临界区,在判断isEmpty的时候唤醒电梯A,而电梯B在判断isEnd之后进入等待状态,电梯A拿到锁重复上述过程,结果就是A和B互相唤醒但又都什么都不干,进而产生了轮询。

解决办法就是注释掉两个notifyAll().(两个notifyAll值90分,痛,太痛了!)

第七次作业

在完成第七次作业的时候,花了很长时间去debug。bug集中出现在线程无法结束或者提前结束,而前两次作业没有出现这种情况。

根本原因在于,前两次作业的调度器不需要进入等待状态,在输入线程(InputHandler)结束之后,就能将每张候乘表setEnd,正如上面第5、6次作业的协作图那样,线程能够顺次有序地结束。但是第七次作业,当输入线程结束的时候,总候乘表中往往不为空,如果此时调度器(Schedule)进入等待状态,就无法将总候乘表中的请求分配给次候乘表,线程无法结束。

解决方法:当每张次候乘表(某一楼座或楼层的processingQueue)为空时,唤醒调度器。

强测的时候wa了一个点。
原因:调度器线程和电梯线程都会对请求类(MyRequest)进行读或写操作,但是我忘记给方法枷锁了,导致线程不安全。(如果一开始删掉调度器就没这事儿了)

解决办法:在MyRequest类的方法上加同步锁。

5. 分析自己发现别人程序bug所采用的策略

采取的测试策略:借助自动评测机,进行大量的测试。
随机数据的生成:

  1. 在较短的时间区间里投喂大量的相似请求(同一楼层或同一楼座)
  2. 根据捎带策略,交叉投喂方向不同的请求。

本单元的测试策略与第一单元测试策略的差异之处:
由于多线程的结果不可再现性,不同于第一单元时发现bug后提交对应数据即可hack成功,在本地使用大量数据测试找出的bug,很可能无法再现,这时候应该找出bug问题所在,根据bug的类型人为地构造数据,使得即使在不同的线程切换条件下,该bug出现的机率都很高。

6. 心得体会

本单元的三次作业,是一次一次的迭代过来的,没有大面积的重构,但是在方法的复杂度上可能还是欠佳,尤其是电梯类中。
由于对多线程的线程安全理解的不够充分,在第五次作业和第七次作业分别出现了线程安全问题。而对于同步锁机制的理解不充分,使得第六次作业产生轮询,导致强测一片飘红,没能进入互测。

这一单元吸取了很多教训,应该引以为戒,在较为充分理解知识的前提下认真完成任务,同时不要过度依赖中测(数据太弱辣),绝对不能因为中测ac了就觉得万事大吉了,“有事没事,多做测试”。qwq

posted @ 2022-05-02 16:07  cchang111  阅读(38)  评论(2编辑  收藏  举报