2024BUAA_OO第二单元博客

22373124-李长佳 学生 2024-04-20 03:18:46

第二单元博客

  • Part1:三次作业中各种“同步块的设置和锁的选择,锁与同步块中处理语句之间的关系”
  • 总共总结了三种用到锁的场景和一种除synchronized关键字外的锁实现机制
  • Part2:总结分析三次作业中的调度器设计,并分析调度器如何与程序中的线程进行交互;总结分析三次作业中的调度策略,并分析自己的调度策略是如何适应时间、电量等多个性能指标的
  • 第一次作业
  • 分配器
  • 控制器
  • 第二次作业
  • 控制器
  • 分配器
  • 第三次作业
  • 控制器
  • 分配器
  • 结合线程协同的架构模式(如流水线架构),分析和总结,识别出三次作业稳定的内容和易变的内容
  • Part3:分析自己在第三次作业中是如何实现双轿厢的两个轿厢不碰撞的
  • Part4:分析自己程序出现过的bug以及自己面对多线程程序的debug方法
  • Part5:心得体会。从线程安全和层次化设计两个方面来梳理自己在本单元三次作业中获得的心得体会
  • 线程安全
  • 层次化设计

Part1:三次作业中各种“同步块的设置和锁的选择,锁与同步块中处理语句之间的关系”

总共总结了三种用到锁的场景和一种除synchronized关键字外的锁实现机制

场景一:线程1执行过程中,因为缺少对象A提供的资源,线程1需要在以对象A为monitor对象的同步块中执行wait方法进入阻塞,避免CPU空转

锁选择:不能及时提供资源的对象A,如下图中的waitingList
同步块设置

img


锁与同步块中处理语句之间的关系
在以对象A为monitor对象的同步块中执行wait方法,将线程1放入对象A的等待池,需要其他线程调用对象A中含有notify的方法来唤醒线程1

场景二:若线程1在以对象A为monitor对象的同步块中执行wait方法,需要在A对象中设置加有synchronized关键字和notify的方法a,通过其他线程调用方法a以及时将线程1唤醒

锁选择:对象A,如下图中的addRequest方法所在的对象this
同步块设置

img


锁与同步块中处理语句之间的关系
线程2执行对象A中的addRequest()方法,从而执行notifyAll()方法,将因为执行wait()方法而陷入阻塞的线程1唤醒并等待JVM执行

场景三:将对象A中read-modify-write类型的顺序操作(包括但不限于getAndRemove,遍历数组foreach等)包装为一个原子操作方法a,防止在rmw操作执行过程中有其他方法执行导致输出结果不确定

锁选择:对象A,如下图中的getAndMove方法所在的对象this
同步块设置

img


锁与同步块中处理语句之间的关系
将对象A的锁交给执行getAndMove方法的线程1,保证线程1执行getAndMove方法过程中不会有其他线程调用对象A的其他读写方法,保证getAndMove方法执行结果的确定性

锁的实现:除了synchronized关键字,锁的另外一种实现:读写锁ReadWriteLock

锁选择

img


img


同步块设置

img


锁与同步块中处理语句之间的关系
在进入方法时加锁,即执行lock()方法,在退出方法时解锁,即执行unlock()方法,由于多个线程同时调用读方法的顺序变化不会产生结果影响,因此相比使用synchronized关键字使同一时间最多只有一个线程调用对象中的方法,使用读写锁可以在多线程同时读取时提高一定性能
除此之外,如果需要在return时调用访问方法,可以通过try-finally块保证在return语句执行后再解锁,如下图

img

Part2:总结分析三次作业中的调度器设计,并分析调度器如何与程序中的线程进行交互;总结分析三次作业中的调度策略,并分析自己的调度策略是如何适应时间、电量等多个性能指标的

第一次作业

分配器

由于请求输入时已指定电梯,也没有重置请求,不需要额外分配器,由输入线程InputThread即输入即分配

控制器

调度策略:采用look策略,总的来说原则如下:
以下内容中的同向顺路可能有歧义,特此说明:
同向指乘客的请求乘坐方向和电梯运动方向相同,如乘客1:6->10,电梯方向向上,我们就说乘客1和电梯同向
顺路指乘客的出发层位于电梯的运动方向路径上(包括电梯所在层),如乘客1:6->10,电梯位于6层,方向向上,则我们说电梯和乘客1顺路

  1. 如果电梯没有方向,向距离最近请求移动
  2. 如果电梯有方向,优先将顺路同向的请求全部接送到目的地,暂时不管顺路但反向或不顺路的乘客
  3. 将顺路同向的乘客全部接送完成后,查看是否还有顺路但反向的乘客请求,如果有,选择将预计最晚到达出发层的请求置为主请求并在主请求上电梯前不接任何乘客,主请求上电梯后调转方向,并重复步骤2
  4. 如果没有,将方向调转,再不断重复234步直到全部乘客接送完毕,电梯方向置无

:电梯此时在5楼,方向向上,乘客1:6->9,乘客2:8->6,乘客3:10->7,乘客3:4->5,则电梯会先将乘客1带上电梯并送到9楼,再将乘客3带上电梯,方向改为向下,再将乘客2带上电梯,将乘客2和3送到目的地后再去接送乘客4

伪代码如下
// 获取主请求
Person getMainRequest(Action direction) {
    // 电梯有人,那么先把电梯上的人全部送到目的地,任意选电梯中的一个乘客为主请求
    // ...
    // 等待队列有人
        // 电梯无方向,选到达时间最早的为主请求
        // ...
        // 否则,电梯有方向,任意取出同向顺路的人为主请求
        // ...
        // 否则,电梯有方向,任意取出顺路反向的且上电梯时最晚的人为主请求
        // ...
        // 否则,没有顺路,掉头寻找
        // getMainRequest(!direction);
    // 否则电梯内和等待队列都没人,没有主请求
}

// 获取方向
void setDirection() {
    // 没有主请求,方向置无
    // 有主请求
        // 主请求在电梯里,或者主请求在电梯楼层也就是即将上电梯
            // 主请求期望向什么方向走,电梯就往什么方向走
        // 否则电梯向主请求方向移动
}

// 预估行为
Action getAction() {
        // 什么情况下关门
        // 什么情况下开门
        // 什么情况下移动
}

分析:这样的策略很贴合实际的电梯,既不会因为电梯对同一个方向的乘客接送不充分而导致部分乘客等待时间太长,也不会因为电梯反复掉头而加大耗电量,既易于实现又具有不错的性能

交互方式:电梯线程本身就在不断重复预估行为执行行为的过程,电梯线程在每一次预估行为前都会更新一次主请求,再根据主请求更新方向,最后根据主请求、运行方向、请求列表等来确定执行何种行为(如开门接客,移动送客,无客等待等),可以说电梯行为的调度和电梯线程本身是串行的

第二次作业

控制器

沿用第一次作业

分配器

总的来说,请求具体分配给哪个电梯,取决于由耗电量等待时间等性能综合选出的最优解。
不少同学使用影子电梯对一个乘客的接送行为进行性能预估,所谓影子电梯,就是根据每个电梯的状态分别克隆出一个只包含必要信息(如乘客,所在楼层,运行方向,移动时间等)的电梯对象,再将请求投喂到每个影子电梯的性能预估方法中,通过比较性能分,选出最优解并分配。
这种策略虽然不现实,因为现实中,乘客在请求电梯时只会告诉系统自己想往上走还是往下走,系统直到乘客登上电梯才知道乘客要前往几楼,但是作业要求如此,设计影子电梯策略就没有任何问题。
调度策略
不同于影子电梯,我的调度策略是比较贴合现实的。从性能分上来看,可能是因为数据量不大,性能分和影子电梯相差无几,由于现实中同时出现几百个请求乘坐电梯的可能性几乎为零,因此,性能差异可以忽略不计,不过实现起来更加自然简单,大致来说实现策略如下:

  1. 设置两个队列,一个是未分配队列unallocatedList,用来保存从输入线程InputThread输入的请求和电梯线程Elevator因为重置下客导致的再分配请求,一个是未决缓存unsettledBuffer,用来保存每轮分配中暂时没有找到最优解电梯的全部请求
  2. 每一轮分配都将unallocatedList中的请求全部分配
  3. 对于每个请求,按照如下规则筛选电梯:
    • 电梯未在重置
    • 乘坐楼层在运送范围内
    • 该请求上电梯时不会满员,该请求在电梯上时不会导致其他已经接收的请求因为满员上不了电梯
    • 电梯在等待状态或同向顺路
  4. 在筛选的电梯中寻找离该请求最近的电梯,如果存在,就把请求分配给电梯,如果不存在,说明目前没有适合的电梯接送该乘客,就加入unsettledBuffer等待下一轮的分配
  5. 将unallocatedList中的请求分配一轮后,等待一段时间,将unsettledBuffer中的请求再加入unallocatedList中,进行下一轮分配,重复2345步骤直到所有请求被处理完不再有新的请求加入
伪代码如下:
void run() {
    while (true) {
        // 对循环何时停止的判断
        // ...
        // 将unallocatedList中的每一个请求取出并尝试分配
        while (!unallocatedList.isEmpty()) { 
            allocate(unallocatedList.getAndRemove()); 
        }
        // 休息一段时间
        // sleep
        // 将unsettledBuffer中的请求再加入unallocatedList中
    }
}

void allocate(Person request) {
    // 筛选合格电梯:未在重置,不会满,电梯在等待状态或同向顺路
    for (Elevator elevator : elevators) {
        // 寻找满足要求的距离request楼层最近的电梯
        // ...
    }
    // 电梯不存在,加入unsettledBuffer
    // ...
    // 电梯存在,分配给电梯
    // ...
}

分析:这样的策略和实际的电梯较吻合,只有在特殊情况下会不同,如:
电梯1在5楼,方向向下,但准备掉头,电梯2在1楼,方向向上,此时请求在6楼,方向向上,按照这种策略,请求会被电梯2接收,但现实中,该请求往往会被电梯1接收,因为电梯1实际上赶到6楼更快,因此或许不一定要寻找同向顺路且最近的电梯,寻找到达请求所在楼层最快的电梯也不失为不错的办法,但我的调度策略好处在实现简单

交互方式:我为分配器单独开了一个线程,没有继续直接沿用输入线程InputThread中即接收即分配的策略是因为第二次作业新增了重置,也就是中途可能会下客,这就导致请求的分配和请求的接收不一定是同步的。因此,最好的办法是新增线程,独立地对输入线程InputThread的请求进行分配和对电梯线程Elevator重置的请求进行再分配

第三次作业

控制器

沿用第一次作业

分配器

和第二次作业唯一的差别在筛选合格电梯时加上请求所在楼层在电梯运送范围内

结合线程协同的架构模式(如流水线架构),分析和总结,识别出三次作业稳定的内容和易变的内容

三次作业中,稳定的内容为:

  1. 电梯的运行策略,即给定了接收的乘客,无论电梯如何迭代,无论电梯如何重置,电梯都会按照稳定的look策略接送乘客
  2. 电梯的分配策略,即寻找符合条件的最近电梯,在迭代中,只会对条件做出较少更改

三次作业中,易变的内容:

  1. 电梯的重置,重置后的电梯子类,方法的抽象和重写

Part3:分析自己在第三次作业中是如何实现双轿厢的两个轿厢不碰撞的

实际上双轿厢电梯A和电梯B之间除了在换乘层附近的运行之外,其他的任何行为,如接收乘客请求Receive、上下乘客In和Out等都是完全不相干的,只需要交换是否正在,或者即将进入换乘层的状态信息。因此,我采取Admit-Check的机制来管理AB电梯向换乘层的运行。
起初,我认为只需要在电梯内置布尔变量isTransferring来代表正在换乘,在策略类获取电梯行为时,按照以下逻辑控制运行:

  1. 如果自己这个电梯准备进入换乘层,就对自己的isTransferring置true,并查询另一个电梯的isTransferring
    • 如果为true,说明另一个电梯正在换乘层或者申请准备进入换乘层,那自己这个电梯就等待
    • 如果为false,说明另一个电梯没有申请进入换乘层,那自己这个电梯进入换乘层
  2. 如果自己这个电梯正在换乘层,且自己即将离开换乘层,将isTransferring置false并离开换乘层
  3. 如果自己这个电梯正在换乘层,且另一个电梯申请进入换乘层(即isTransferring为true),将自己的isTransferring置false并离开换乘层
    伪代码如下:以上层电梯为例
    private boolean isTransferring = false;
    protected Action getAction() { // 策略类决定电梯行为的方法
     // 一系列开关门的行为判定
     // ......
     // 移动行为判定
     // 该电梯准备进入换乘层
         isTransferring = true; // 申请进入换乘层
         // check下层电梯是否也正在申请换乘
             // 下层已经申请了换乘,那该电梯就等待
             // 下层没有申请换乘,那该电梯进入换乘层
     // 该电梯在换乘层,且下层电梯在申请换乘或者上楼离开换乘层
         isTransferring = false; // 释放申请
         // 向上移动,离开换乘层
     // 其他移动行为
     // ......
    }
    

如上,也就是简单的设置独立的布尔变量表示申请状态的做法,然而这样的实现却会在多线程中有个问题,举一个简单的例子:
Input:
[1.0]RESET-DCElevator-1-6-6-0.4
[4.0]1-FROM-6-TO-11
[4.0]2-FROM-6-TO-1
Output:
[4.00]RECEIVE-1-1-B
[4.00]RECEIVE-1-1-A

(然后就会卡住)
一个电梯刚接受DoubleCarElevatorReset重置完,换乘楼层在6楼,上下两个电梯分别在5,7楼,此时同时来了两个乘客,一个从6楼到11楼,一个从6楼到1楼,显然这两个请求会被上下两个电梯分别接受,然而在运行过程中,经常会看到程序卡死,这是怎么一回事呢?经过检查发现存在这种情况,当两个电梯都运行到isTransferring = true语句之后,因为check始终为true,因此两个电梯都会陷入不断等待的状态,也就是卡死了,这下我们就知道了bug产生的原因————是由设置和查询不是同步执行导致的,也就是说,isTransferring = true和check()应该放在一个代码块中同步执行,且在两句代码连续执行结束前不允许其他电梯执行,这可太耳熟了,这不就是一个加了synchronized关键字的方法吗,由此,我们也得到了一种解决方案,新增一个锁类TransferLock,用来记录和查询上下两个电梯对进入换乘层的申请

public class TransferLock {
    private boolean lockA; // 下层电梯的占换乘楼层的锁
    private boolean lockB; // 下层电梯的占换乘楼层的锁
    public synchronized boolean lockLowerAndCheckUpper() {...}
    public synchronized boolean lockUpperAndCheckLower() {...}
    public synchronized boolean checkLower() {...}
    public synchronized boolean checkUpper() {...}
    public synchronized void unlockLower() {...}
    public synchronized void unlockUpper() {...}
}
由此,getAction方法就可以修改成:
private TransferLock lock = new TransferLock();
protected Action getAction() {
    // 一系列开关门的行为判定
    // ......
    // 移动行为判定
    // 该电梯在换乘层上面一层且运行方向向下,也就是准备进入换乘层
        if (lock.lockUpperAndCheckLower()) // 申请换乘并查询另一位电梯是否在换乘
        // 下层已经申请了换乘,那该电梯就等待
        // 下层没有申请换乘,那该电梯进入换乘层
    // 该电梯在换乘层,且下层电梯在申请换乘(lock.checkLower())或者上楼离开换乘层
        lock.unlockUpper(); // 释放换乘锁
        // 向上移动,离开换乘层
    // 其他移动行为
    // ......
}

由此,我们可以产生类比,换乘楼层不可能同时出现两个电梯,就像一个锁不能被两个线程同时持有,也就是说双轿厢电梯换乘所产生的冲突,可以用锁的思路来解决,更重要的是,几乎所有的涉及类似一房间一锁问题都可以这样解决。

Part4:分析自己程序出现过的bug以及自己面对多线程程序的debug方法

因为多线程程序不易调试,且bug难以复现,因此,对出错测试点的输入要加以保存,同时,在代码不同段标记System.out.println用来输出文本不失为一个比较好的办法
例如:在part3中提到的双轿厢电梯死锁,由于死锁的出现,导致程序卡死无法继续输出,这时候我们就可以在代码各个地方添加println函数输出信息,多次运行,不断缩小范围,最终确定程序在哪里出现了卡死
仅凭肉眼分析,只有卡死这种程序出错是容易辨别的,如果条件允许,可以编写一个验证器,验证输出序列的合理性,并在输出错误行为的行上弹出错误,当然,没有条件的话依赖课程组的评测机也不失为一点小手段

Part5:心得体会。从线程安全和层次化设计两个方面来梳理自己在本单元三次作业中获得的心得体会

线程安全

  1. 对多个线程共享的对象,其中的方法应尽量添加同步保护synchronized或读写锁,以免未来在修改或迭代过程中难以发现bug
  2. 在编写代码时,注意分析一段顺序操作是否要求具有原子性,即如果在这段代码运行过程中其他线程调用了某些方法导致运行结果不同,那么这段代码就需要划为临界区,也就是说,同步的机制其实就是在保证操作的原子性
  3. 多线程的安全问题是非常难以寻找和复现的,因此,多编写容易出现冲突的数据点,有助于检验线程安全
  4. 在wait难以使用的上下文中,使用sleep让线程休眠一段时间也不失为避免线程空转的好方法

层次化设计

  1. 在迭代之初就要构建好程序的结构,确定解决问题需要的模型,这个模型应该在未来可能迭代的方向上留下一定冗余
  2. 多继承,多重写,少新增不必要的类
  3. 父类应该将子类和父类不同的细节抽象成方法,保留共性的框架,子类不需要重写框架,只需要重写描述不同细节的方法,比如:对双轿厢电梯和普通电梯,除了处理换乘层的冲突和输出信息不同以外,其他所有细节其实完全相同,因此,父类应该将输出信息和获取电梯行为抽象成方法供子类重写,其他部分应保持不变
...全文
54 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧