OO第二单元总结

一、同步块的设置

在第二单元的作业中,我只使用了同步块的设置,没有使用读写锁,因此仅就同步块这一方面来介绍我三次作业的设计。从第一次作业初上手多线程,对于synchronized懵懵懂懂、看wait/notify晕头转向,到第三次作业已经摸清了这些关键字、内置方法的机理,能够肉眼分析出轮询的bug,中途几多迷惑、几多痛苦,又几多顿悟、几多欢乐。

第一次作业

写第一次的时候真的是啥也不懂,只好照着上机的代码来写,上来先实现一个RequestQueue类,再把几乎每一个方法都加上synchronized和notifyAll,真是战战兢兢、如履薄冰。

具体来说,这个类中沿用了上机中以下函数:

public synchronized void addRequest(PersonRequest request);
public synchronized PersonRequest getOneRequest();
public synchronized void setEnd(boolean isEnd);
public synchronized boolean isEnd();
public synchronized boolean isEmpty();

不过好在数据量不大,程序的主要耗时在模拟电梯的移动和开关门上,因此并没有多少性能损失。第一次作业中每栋楼只有一个电梯,因此我为每个电梯单独创建了一个RequestQueue类型的对象,这样做无意之中避免了notifyAll过多的bug,当然,第二次作业会暴露出来的。

第二次作业

在第二次作业中,我学会了使用同步块,代码有了较大的变化,体现在同步块的设置和减少notfyAll语句。

首先,我删除了RequestQueue类里的getOneRequest()函数,在Elevator类中的personIn方法和directionUpdate方法中,设置同步块来访问公共消息队列。

synchronized (processQueue) {......}

这样做的好处有两点:

  • 同步块里的代码想写啥就写啥,不用在设计RequestQueue类的时候考虑Elevator类的行为,很灵活。
  • 由Elevator来定义如何访问消息队列,而不是消息队列自己来定义一个被访问的函数,更符合直觉。

其次,我在测试时发现:如多多个电梯共享一个消息队列,那么过多地调用notifyAll会导致线程频繁被唤醒,从而导致CTLE。于是我着手减少notifyAll的使用。其实notifyAll只需要在两种情况下使用:消息队列添加了新的元素,或者消息队列输入结束。因此,只需要在以下两个函数中加入notifyAll即可。

public synchronized void addRequest(PersonRequest request);
public synchronized void setEnd(boolean isEnd);

第三次作业

第三次作业中同步块的使用和第二次作业相差不大,只是多了一个RequestCounter类,也是模仿上机内容写的。

RequestCounter采用单例模式,私有化构造函数,依靠getInstance方法返回同一个RequestCounter对象。并且通过release方法提交作业、通过acquire方法验收作业,这两个方法用synchronized修饰。

由于我在第三次作业采用了动态拆分请求的方法,所以使用RequestCounter能够极大方便管理电梯线程何时结束。

二、调度器设计

由于我在三次作业中均采用了自由竞争的策略,所以调度器的功能十分简单,直接合并到输入线程之中。因此,我将直接介绍输入线程的实现,以及如何与各个线程交互。

在输入线程的构造函数中,为每个楼座和每一层都建立一个消息队列,并且将最初的6个电梯初始化,初始化的过程中会将电梯与消息队列绑定。

this.waitQueues = new ArrayList<>();
for (int i = 0; i < 5; i++) {
	RequestQueue waitQueue = new RequestQueue();
    Elevator elevator = new Elevator(i + 1, (char) ('A' + i),8,0.6,waitQueue);
    elevator.start();
    waitQueues.add(waitQueue);
}
for (int i = 0; i < 10; i++) {
	RequestQueue waitQueue = new RequestQueue();
    this.waitQueues.add(waitQueue);
}
Ring ring = new Ring(6,1,8,0.6,31,waitQueues.get(5));
ring.start();

之后,在run方法中,调用官方输入包获取请求。如果是乘客请求,就会把请求加入消息队列;如果是电梯请求,就初始化电梯并与对应的消息队列绑定。

在官方输入包结束之后,调用上文提到的RequestCounter类的acquire方法验收,然后结束所有消息队列,消息队列的一旦结束,相应的电梯在运载完当前电梯内的乘客后,也会相应结束,整个程序就彻底结束了。

public void run() {
    while (true) {
        if (request == null) {
            break;
        }
        //TODO
    }
    for (int i = 0; i < requestNum; i++) {
    	RequestCounter.getInstance().acquire();
    }
    for (RequestQueue queue : waitQueues) {
    	queue.setEnd(true);
    }
}

三、线程协同的架构

第一次作业

第一次作业的线程协同架构采用了生产者——消费者模型,其中生产者是输入线程Input,消费者是电梯线程Elevator,它们之间的桥梁是RequestQueue类的对象。每当Input得到请求后,就会调用RequestQueue的addRequest方法,向消息队列添加请求。而每次Elevator被唤醒(抵达新的楼层或者消息队列更新)后,都会去检查消息队列,查看是否能够捎带队列中的请求。

除了上述三个继承了Thread的类,还有Main类和Output类。Main类主要负责初始化Input、RequestQueue和Elevator,并调用线程的start方法。Output类对于课程组提供的输出函数进行了线程安全的封装,保证不会出现时间戳递减的情况出现。

hw5.drawio sequence

第二次作业

第二次作业仍然保持了第一次作业的生产者——消费者模型,对于新增的横向电梯,我仿照Elevator类实现了Ring类,并为每一层设置了一个消息队列,所有同一层的电梯都绑定到这一层的消息队列。

由于本次作业要求支持新增电梯,于是我把Main类中对Elevator和RequestQueue的初始化移动到了Input的构造函数中完成。并且输入的新增电梯请求也由Input类来处理,这样就把电梯线程的调度都交给了Input线程,Main类只负责初始化Input类,并把它启动起来。

除此之外,我还改写了Output类,采用了单例模式,减少了结构耦合,使得代码更加简洁、方便修改。

hw6.drawio

sequence

第三次作业

在第三次作业中,我并没有采用上机提供的流水线架构,而是改写了PersonRequest类,从而保持了生产者——消费者模型,具体实现如下:

我定义了一个新的Person类,Person类的构造函数以PersonRequest类为参数,相当于对官方提供的PersongRequest类进行了二次封装。我添加了两个属性:tarBuilding和tarFloor,表示当前这一次移动的目标楼座和目标楼层。保证(fromBuilding == tarBuilding) ^ (fromFloor == tarFloor) = 1。每次电梯停靠的时候,一个Person出电梯,会立刻调用自身的update方法,找到自己下一次移动的目标并且把自己放到对应的消息队列之中。

之所以这样设计,是因为我觉得这样更符合真实情况。因为调度器这个东西在现实世界中并不存在,真正具有自由意志的是人。一个人想从A座1楼到达C座5楼,是应该让电梯为他安排好道路,还是让人自己来选择道路?我觉得后者更加贴近现实,而我认为面向对象的精髓之一就是对现实的模仿。

hw7.drawio

sequence

四、分析Bug

第一次作业

第一次作业是前两个单元最简单的一次,但是很可惜,我还是在强测中挂了一个点。原因有两点:其一,我控制开关门的代码有问题,如果电梯已满员又无人下电梯,但是这一层有人想上电梯,电梯仍然会“虚空开门”,导致无谓的耗时。其二、我的捎带没有强制要求同向,也导致了电梯性能的下降。两点加在一起造成了RTLE。

第二次作业

第二次作业在课下我遇到了一个会导致轮询的bug。这个在前文其实已经提到了,就是如果过多的使用notifyAll方法,会导致电梯频繁被唤醒,最终导致CTLE,解决方法就是只在有新的请求到来时才notifyAll。

第三次作业

第三次作业我仍然在课下发现了一个会导致轮询的bug。由于横向电梯并不能在所有的楼座停靠,所以在判断调用wait方法的条件时,应该只检查是否还存在该横向电梯能够接到并送到的请求,而不是检查整个消息队列是否为空。否则会导致线程无法进入阻塞队列,导致轮询。

五、Hack策略

Hack策略主要还是根据自己在课下犯得一些共性错误构造几组数据去碰碰运气。三次作业总共刀中4次,效果普普通通。测试线程安全主要是用Linux下的time命令检查CPU时间。这一单元作业的正确性都比较容易保证,真正可能翻车的主要还是线程安全问题,所以测试也应该注重这一方面。

六、心得体会

线程安全:线程安全是多线程程序设计的重中之重,也是多线程编程相较于单线程编程难度陡增的主要原因。通过本单元的学习,我掌握了基本的保护线程安全的方式,也能够更加敏锐地发现可能威胁到线程安全的bug。多线程编程已经成为了现代编程的主流,因此,线程安全的也变得日益重要。

层次化设计:我认为层次化设计是一种自顶而下的设计方法,面对一个复杂的任务,首先先把它分成几个大的方面,再针对每个方面各个击破。以本单元为例:首先把任务分解成输入、电梯增添、电梯运行、输出、线程结束等几个部分,再具体实现每个部分,最后像搭积木一样把它们拼起来。

posted @ 2022-05-01 14:54  hua-0x522  阅读(50)  评论(0编辑  收藏  举报