【2022春-面向对象】第二单元总结

【2022春-面向对象】第二单元总结

写在前面

第二单元以电梯为情境,利用了多线程的方式解决问题。多线程,听起来很高深很复杂,实际上确实很复杂......

但其实多线程需要解决的问题用一句话来说,就是:如何支持许多”程序“”同时“跑?

这里的”程序“事实上就是线程,说成程序有失严谨,不过在写代码的层面,将线程简单理解为一连串代码片段也可以。

这里的”同时“事实上也可能不是真正的同时,而是操作系统的调度实现的虚假的”同时“:操作系统先让A线程跑一会,再让B线程跑一会,然后再让A线程跑一会......只不过这种切换对人类来说太快,我们所看到的就是”同时“在跑。

ok,尽管多线程很麻烦很复杂,但也并非无从下手.本文将以作业的电梯为情境,把多线程的种种问题捋清.记住我们主要解决的是如何支持许多”程序“”同时“跑的问题.

一.同步块与锁

介绍同步块的机制之前,先解决一个问题:同步是什么?

同步是什么?

先从一个简单的问题开始:

现有两个线程A和B,一个公共变量count,A会执行count++一百次,B执行count++两百次,实现此代码.

如果不使用同步控制的机制,得到的结果往往不是300.这是由于count++这个操作可能被打断(即在A线程count++过程中,操作系统切换到了B线程),换言之,它不是一个"原子操作".

这里的count便是共享资源.对于共享资源来说,我们希望实现:在一个线程对其进行操作的时候,另一个线程不能对其操作,必须等其他线程操作完再对其操作,这就是同步.否则就可能会有错误的结果.

同步控制块

而同步控制块就是将一段代码给括起来,以synchronized修饰.

synchronized (count)
{
    count.add(1);
}

在一个线程进入这一个语句块时,我们说线程给count对象上了锁.这是一个很形象的说法,因为上了锁之后,其他线程就不能进入这个语句块了.必须等前一个线程退出这个语句块的时候才能进入.

锁机制

java具体如何实现上述的同步控制的呢?

实际上对于每个对象来说都有一个监视器monitor,没有上述synchronized情况下基本可以无视monitor,但是当程序使用synchronized修饰一个语句块时情况有所不同:

  1. 线程将要进入到此语句块之前,会先检查被锁对象(即括号内的对象引用所指对象)的monitor,monitor记录当前对象是否上了锁.
  2. 如果上了锁就必须等待,直到锁被释放
  3. 如果没有上锁或者锁刚刚被释放,线程就会尝试获取锁(不一定能获取到,因为可能多个线程会争抢同一把锁).如果成功获取,则进入语句块同时给对象加锁.monitor对象记录加锁信息.

二.架构设计思路

ps:关于作业要求:本部分的架构思路包括调度器的设计,即博客作业要求(2)

第一次作业

本次作业的基本目标是模拟多线程实时电梯系统,熟悉线程的创建、运行等基本操作,熟悉多线程的设计方法.

第一次作业是一个常见,普通的电梯系统.整体采用生产者消费者模型.

既然采用生产者模型,我们的架构生成思路便是,套用此模型并逐渐丰富细节.

ps.第一次作业的架构生成十分重要,直接影响到后面作业的可扩展性以及写代码的舒适程度.如果第一次就写成了"shi山",在后面的迭代中则需要"在shi山中穿行"(指debug)甚至需要"把shi山炸了重来"(指重构).

Step1.明确模型

首先我们明确生产者,消费者,共享对象都是谁:

  • 输入线程是生产者
  • 楼(一些请求队列的集合)是共享对象
  • 电梯线程是消费者

A-E座,每一个座就对应一个楼对象和电梯线程对象.

Step2.丰富模型

让我们深入到这三者之中的具体实现.

输入线程

比较简单:调用官方包,将请求放到对应的building中去.这一切只需在run()方法中实现.

电梯线程

是架构的核心,维护一个电梯的运行.首先需要一个容器存电梯当前队列,然后run()方法里则是电梯运行的具体步骤:

  1. 判断是否需要结束线程,若需要则结束.(相关方法:Building的isEnd与setEnd方法)
  2. 获取下一步的运动方向(相关方法:新类Strategy的getDirection方法,负责给出电梯的运行策略)
  3. 判断是否需要开关门,如果需要则开关门,并且维护电梯内请求的增删(相关方法:openAndClose())
  4. 根据获取到的运动方向移动.(相关方法:moveAndArrive())

没错,尽管这是一个较为复杂的过程,但我们依然可以利用分而治之的思想化繁为简,把任务分配给其他类其他方法.

楼对象

负责管理楼内的请求.为了配合电梯的取请求,存请求的容器设置为:

HashMap<FloorAndDirection, LinkedBlockingQueue<PersonRequest>> waitQueues;

即:建立了一个<楼层,方向>到一个请求队列的映射.当电梯到达某一层需要取请求时,直接访问对应<楼层,方向>的队列即可.

注:这里的BlockingQueue来源于java的concurrent类库,但个人感觉不是很好用,反而是自己通过同步控制写的容器更不受限制一些.在后面的作业中便不采用BlockingQueue了.

丰富之后的UML图:

至此,基本的架构已经出现,剩下的就是一些细节了.

Step3.增添细节

调度策略

主要是针对Strategy类设计调度策略.这里注定会涉及到许多细节,所以实际写代码的时候通常会先忽略这些细节,先写出一个"差不多能跑"的程序(比如这里可以写一个无脑上下往复运动的策略),防止有架构上的大问题.确保能正常跑之后再去实现调度算法.

本次调度算法采用look策略,具体来说:电梯总是倾向于不改变当前运转的方向。

例如:对于竖直方向且正在上行的电梯,如果电梯内部有上行的请求,或者电梯外部有在更高的楼层等待的请求,则上行。反之才会等待或者下行。

协作

在输入结束时,输入线程调用读入方法是接收到null,此时向每个Building设置setEnd().电梯线程run()方法执行开始时先调用Building的isEnd查询是否结束输入,若是则结束.

总览

增添细节之后,便拥有完整的代码了,类的设置如下:

//核心类
MainClass.java 主线程
Building.java 楼,其实就是同一栋楼上队列的集合
InputThread.java 输入线程,向控制器发出请求
ElevatorThread.java 电梯线程,维护一个电梯的运行

//其他类
MessageObserver.java 消息观察者,利用官方输出包输出电梯运行信息
Direction.java 电梯方向的枚举类
FloorAndDirection.java 楼层与方向,作为存放请求的HashMap的key
Strategy.java 策略枚举类,为电梯提供调度策略

UML图(事实上只是比step2多了几个零星的类与方法)

UML协作图

第二次作业

第二次作业涉及到横向电梯以及多部电梯的调度.

从多个楼座到控制器单例

显然不能再以楼为一个共享对象,因为横向电梯的调度会变得异常复杂.

因此我们需要将楼合成为一个大的共享对象.受到课上实验的启发,这个大共享对象变为了控制器单例.

但整体架构其实与第一次改动不算很大,只是将5个楼变成了1个控制器.

当然控制器内部的存放容器也需要改变:仍然是一个map,以<楼座,楼层>(可以抽象为平面上一点)为键,以一个队列为值,构建HashMap.当电梯到达相应位置时取请求.由于此时的请求并不是无脑的从队列里弹出请求,而是需要额外判断是否能捎带,因此便不采用BlockingQueue.

锁机制优化

形成控制器单例之后,共享资源变大,如何尽可能支持并发?如果在取请求时为整个控制器上锁,尽管足够安全但是效率较低.我们期望电梯取请求时,以及策略类中的判断时,尽可能对较少的队列上锁.我们可以采用如下代码的形式:

LinkedList<MyRequest> dest;
synchronized (dest = waitQueues.get(myRequest.getFrom()))
{
    dest.add(myRequest);
}

当电梯暂无请求需要等待请求时,我们设立新的对象MyLock,类似于一个休息室:当电梯没有要处理的请求时,会进入MyLock的同步控制块进行等待(可以理解为一个”休息室“:电梯没有工作时,Controller就安排这些电梯来这里休息)。当Controller收到新请求,或是输入终止时,会唤醒在MyLock中等待的线程,使其继续工作/结束线程。

多部电梯调度

策略保持不变,甚至这里也没有实现基准策略的"平均分配"(因为这样做并不一定效率更高,且在现有架构上实现较为困难).而是直接采取"竞争"的策略:电梯取请求时尽可能取满,谁取到了请求就算谁的.因此关于多部电梯调度基本不需要做任何改动.

总览

//核心类
MainClass.java 主线程
(NEW)	Controller.java 控制器单例,维护未处理的请求以及电梯线程
InputThread.java 输入线程,向控制器发出请求
ElevatorThread.java 电梯线程,维护一个电梯的运行

//其他类
MessageObserver.java 消息观察者,利用官方输出包输出电梯运行信息
(NEW)	MyLock.java 锁对象,管理线程的等待以及唤醒
(NEW)	MyRequest.java 官方包PersonRequest的对应类,此类的设计是为了适配于当前架构
(NEW)	Point.java 点对象,表示电梯所在坐标
Direction.java 电梯方向的枚举类
Strategy.java 策略枚举类,为电梯提供调度策略

UML图(标红的为新增内容)

第三次作业

涉及到多段请求的调度与带限制的横向电梯问题.

多段请求调度

可以采用流水线策略:将请求划分为若干阶段,每次电梯完成一段请求则丢给控制器,又控制器进行请求的更新与再分配.

具体来说,在迭代的过程中需要修改MyRequest的结构,支持多段请求的存储以及记录当前处理到了哪一段请求.

需要在Controller控制器内增加updateRequest方法,以实现请求的更新以及完成记录.

需要在Controller控制器内记录当前所有横向电梯的SwitchInfo,以生成多段请求.

结束线程方式改动

由于多段请求的出现,线程的结束方式从原来的检查end变量改为了课上实验的RequestCounter形式.即计数请求的完成情况,在输入线程结束输入之后对每一个乘客请求进行验收.

总览

//核心类
MainClass.java 主线程
(MODIFIED) Controller.java 控制器单例,维护未处理的请求以及电梯线程
InputThread.java 输入线程,向控制器发出请求
ElevatorThread.java 电梯线程,维护一个电梯的运行

//其他类
MessageObserver.java 消息观察者,利用官方输出包输出电梯运行信息
MyLock.java 锁对象,管理线程的等待以及唤醒
(MODIFIED) MyRequest.java 官方包PersonRequest的对应类,此类的设计是为了适配于当前架构
Point.java 点对象,表示电梯所在坐标
Direction.java 电梯方向的枚举类
Strategy.java 策略枚举类,为电梯提供调度策略

(NEW) RequestCounter.java 请求计数器,负责线程的结束统计
(NEW) RequestQueue.java 请求队列,为LinkedList<MyRequest>的重命名
(NEW) SwitchInfo.java 维护横向电梯的开门信息

UML图(标红的为新增内容)

三.度量分析


可以看到复杂度最高的类是Strategy类,最高的方法是电梯线程中调用的Strategy类getDir()方法.

在写代码的过程中笔者也感觉到了不对劲之处.策略类需要查看电梯的状态以及电梯外各个等待队列的状态,无疑增加了策略类与Controller,与电梯线程的耦合度.在现有的架构下,似乎把电梯的策略安排放到电梯线程内部或许是个更好的选择,当然也可能是笔者自己的策略类没有设计的很好...

四.bug分析

本次的bug主要集中在第三次作业.而最主要的问题毫无疑问涉及到线程协同问题.(因为其他的问题可以复现,debug较轻松)

等待未被唤醒

public synchronized void myWait(ElevatorThread eth)
{
    if (endTag) {
        return;
    }

    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

本意是:在函数外检查是否需要等待,若需要则进入此函数等待.

但是忽略了此情况:在函数外检查,发现需要等待,并且恰好在检查endTag之后加入了新的请求.导致线程错过了被notify的时机,线程一直wait.

解决方法:进入函数同步控制快之后进行二次检查,若确实需要等待再进行等待.

public synchronized void myWait(ElevatorThread eth)
{
    if (endTag) {
        return;
    }

    if (!canWait()) {
        return;
    }
    
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

死锁

当老师讲授死锁时往往会给出如下例子:

// Thread 1
synchronized(A) {
    // switch
    synchronized(B) {
        ...
    }
}

//Thread 2
synchronized(B) {
    // switch
    synchronized(A) {
        ...
    }
}

当两个线程在switch处切换时,即可能出现死锁.

这样的代码有很明显的特征,比较容易发现,但是在自己的代码中就不一定那么明显了...

// Thread 1
synchronized(A) {
    // switch
    func1();
}

//Thread 2
synchronized(B) {
    // switch
    func2()
}

如果func1()和func2()里面又存在对B以及对A的同步块,就会导致死锁.也就是说,要从函数的表象破解开来,捋清同步控制究竟是如何一层一层加锁的.

本次作业的死锁bug是最难发现的,问题所在也在上述代码中.

public synchronized void myWait(ElevatorThread eth)
{
    if (endTag) {
        return;
    }

    if (!canWait() /* synchronized */) {
        return;
    }
    
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

另一段代码在Conteoller.java,这个方法负责更新请求

public void updateMyRequest(MyRequest myReq)
{
    myReq.update();
    if (myReq.isFinish())
    {
        RequestCounter.getInstance().release();
    }
    else
    {
        RequestQueue dest;
        synchronized (dest = waitQueues.get(myReq.getFrom()))
        {
            dest.add(myReq);
            myLock.myNotifyAll();// synchronized
        }
    }

}

注意到两段代码事实上都有两层synchronized语句块,只是隐藏在了函数中.稍有不慎就可能造成此错误.

当然发现问题后,解决问题很简单:事实上第二段代码的myLock.myNotifyAll();语句没有必要放在控制块内部.把它放出来就好了.

这也是一个启示:同步控制快内只把必要的部分框起来,不要框多余的部分.

五.心得体会

此文章并未涉及到关于如何寻找他人bug的问题,因为笔者自己并没有很认真的去阅读其他同学的代码,一是自己精力实在有限,二是从客观上来说阅读他人代码是一件非常困难的事情.因为自己不了解别人是怎么想的,别人想的跟自己想的即使是同一个东西也会存在差距.尽管说本单元需要通过观察来发现bug所在,但实际上由于自己能力有限,bug的发现仍然要依靠大量的黑盒测试.

当然在测试的过程中也发现,由于多线程的原因,黑盒测试不再那么适用,因为bug无法复现.一个时而发生时而不发生的死锁问题在第三次作业困扰了笔者相当大的时间.甚至很长一段时间内自己根本不知道究竟是死锁还是wait的线程没有被唤醒等等问题.最后是依靠同时观察错误样例以及代码以及代码顺序调换的种种尝试才逐渐找到正确的道路.

总之,道阻且长,要走的路还很远......

posted @ 2022-05-04 15:16  infinity0  阅读(31)  评论(0编辑  收藏  举报