BUAA OO Unit2 2024 博客

励思媛-22371445 学生 2024-04-18 16:34:31

OO Unit2

同步块的设置和锁的选择

在本单元多线程的学习中,线程对共享对象的竞争是需要解决的一大问题。”锁“使得任意时刻只有一个线程能读写该共享对象,实现线程互斥;”锁“也使得满足某条件时,线程通过wait()方法进入等待队列,再通过notifyAll()将其唤醒,实现线程同步。选择合适的”锁“能实现线程的同步互斥,进而解决线程安全问题。

synchronized

synchronized修饰方法
public synchronized void func() {
    // do something 
}

synchronized修饰方法,方法内的语句是临界区,只有当线程获得该对象的锁时,才能进入该对象的该方法。

若修饰的是静态方法,只有当线程获得该类的锁才能进入该方法。

synchronized修饰代码块
synchronized(obj) {
    // do something 
}

synchronized修饰代码块,代码块内的语句是临界区,只有当线程获得obj的锁,才能进入该代码块。

semaphore

在OS的学习中,为实现临界区引入了信号量机制。被赋为某初值后,若线程需要分配资源,则semaphore--,若资源耗尽则线程挂起;若线程释放资源,则semaphore++,并唤醒某一线程。

ReentrantReadWriteLock

读写锁相对于synchronized锁而言,把读写操作细分。允许readLock被多个线程拥有,但writeLock锁只能被一个线程拥有,且此时不允许线程拥有readLock。调用lock()和unlock(),自定义临界区。

writeLock.lock();
try {
    //do something
} finally {
    writeLock.unlock();
}
readLock.lock();
try {
    //do something
} finally {
    readLock.unlock();
}

调度器设计和调度策略

调度器与线程的交互

getRequest & addRequest

通过RequestList,输入线程作为生产者向请求池加入顾客请求,调度器线程作为消费者从中拿取请求;再由调度器作为生产者向电梯请求池加入顾客请求,电梯线程作为消费者从中拿取请求。

RequestList是临界资源,作为线程间数据传输的托盘。

isReset访问电梯是否在reset

reset标志位在RequestList中,代表每个电梯此时此刻的重置状态。输入线程读到重置请求后把reset标志位置1,电梯线程完成重置后把reset标志位置0。

调度器线程通过访问reset标志位,判断电梯是否正在reset,并凭此决定如何分配顾客请求。

isEnd & setEnd

输入线程为总请求池setEnd,调度器在总请求池已被setEnd且为空时,为各电梯请求池setEnd,从而完成电梯线程的合法结束。

调度策略

我的调度策略主要采用random随机分配,并局部考虑电梯性能,如电梯move速度、电梯容量、电梯目前接受的请求数、电梯目前的移动方向和楼层等等,尽量选取电梯性能更好的、电梯目前运行状态更合适的分配请求。

对于正在重置的电梯,调度器会不进行请求的分配,另找新的电梯尝试分配。为防止所有电梯都在reset时,调度器无法分配请求导致的轮询,我在每次分配请求前先检验是否电梯都在重置,若是,调度器就sleep(500),防止忙等待。

对于双轿厢电梯,调度器会把顾客请求分段后分配给双轿厢电梯,便于乘客之后的换乘。

UML类图

hw_5

屏幕截图 2024-04-16 200133

第一次架构确定了输入线程、调度器线程、电梯线程三个主线程,线程间通过请求池进行交互,是一种较标准的生产者-消费者模式。

电梯实际执行部分与策略相分离,使电梯能快速切换不同策略。

在电梯运行策略上一开始我采用的是ALS算法,该算法运送乘客效率较低,后改用LOOK算法。

hw_6

屏幕截图 2024-04-16 201406

第二次增添重置请求,在请求池中加入reset标志位,输入线程、电梯线程写reset标志位(setReset),输入线程、调度器线程、电梯线程读reset标志位(isReset),线程交互方式增多。

hw_7

屏幕截图 2024-04-16 202758

第三次引入双轿厢电梯和乘客换乘问题,调度器内不仅有普通电梯请求池,还有双轿厢各自的电梯请求池,同时修改顾客类给请求分段。

新增TransferFloor换乘楼层类,使得双轿厢电梯不会相撞。TransferFloor作为临界资源,配备synchronized锁和wait-notify机制,任意时刻最多只允许一个电梯停靠。

协作图

屏幕截图 2024-04-16 200111

三次作业的不变和易变

不变:
  • 生产者-消费者模式始终贯穿三次作业,并作为线程交互的主要方式。
  • 普通电梯和双轿厢电梯的运行策略基本相同,都采用LOOK算法。只有输出信息有略微区别,防止双轿厢相撞可以通过TransferFloor类实现,而无需修改电梯的主要运行策略。
易变:
  • 线程交互的方式逐渐增多。第二次作业增添了对reset标志位的读写,第三次作业增添了对TransferFloor的占用释放。
  • Scheduler调度策略不断改变。从第一次作业的指定电梯分配,到第二次的自由分配,再到第三次对双轿厢电梯分配时将请求分段。
  • 结束(setEnd)的条件不断变化。第一次作业输入线程读到输入末尾时就可以setEnd,第二次作业由于重置时电梯会向主请求池加入顾客请求,因此setEnd前还需判断电梯是否都已重置完毕,第三次作业由于双轿厢会向对方电梯请求池加入顾客请求,以达成乘客换乘,因此setEnd前还需要判断另一个双轿厢电梯内是否还有乘客。

避免双轿厢碰撞

避免双轿厢碰撞其实就是避免两个电梯同时在换乘层。我单设了TransferFloor类,并为其中的arriveTransferFloor和leaveTransferFloor方法加锁。

TransferFloor作为临界资源,每当电梯尝试到达换乘层时,若该换乘层已有电梯停留,则需wait,等到另一个电梯离开换乘层后再被notify;若该换乘层无电梯停留,则可以进入换乘层,并设置好doubleCarElevator标志位,防止另一个电梯进入换乘层。

public synchronized void arriveTransferFloor(String doubleCarElevator) {
    //this.doubleCarElevator标志现在换乘层的电梯编号:“A","B",null
    while (this.doubleCarElevator != null) { 
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    this.doubleCarElevator = doubleCarElevator;
}

public synchronized void leaveTransferFloor() {
    this.doubleCarElevator = null;
    notifyAll();
}

一个电梯在换乘层的时间必须是有限的,不然会导致另一个双轿厢电梯的死等wait。因此在双轿厢电梯结束线程和等待请求前,需要判断它是否在换乘层,若在则离开,并唤醒另一个等待进入换乘层的电梯。

bug和多线程debug

hw_5

强测未错,互测被hack了一刀。在hw_5中我一开始用的是ALS算法,捎带性能较差,若在50s时输入大量请求会导致RTLE(运行130s)。后改为LOOK算法,成功跑进120s(大概90s左右)。

hw_6

强测未错,互测被hack了两刀,居然是两个bug。感谢我温柔的房友们手下留情,我室友一个bug被hack了10刀

一个bug是因为我未设置缓冲队列,在电梯重置时,调度器不会向电梯请求池中放入请求。若在50s时输入大量请求且只留一个电梯未在reset,这个电梯将接受输入的全部请求,且即使别的电梯重置完成,依旧无法把请求分给别的电梯,导致RTLE。一个电梯跑的累死其他电梯:”哟老哥,忙呢”。后来我为电梯请求池设置请求池上限,解决了该问题。

另一个bug是因为线程安全。由于我处理重置请求是通过reset标志位,输入线程、调度器线程、电梯线程会对标志位又读又写,导致某些情况下,调度器会误向已在重置的电梯请求池内加入顾客请求。可能发生概率极低所以没被强测hack到但被敏锐的房友hack住了。我的解决方法是把reset标志位放入RequestList类,对标志位的读写都加synchronized锁,解决reset的线程安全问题。

hw_7

很遗憾由于强测被hack了7刀,没有进入互测。这7刀都是同一个错误CTLE。双轿厢电梯重置后,原本的电梯应该结束线程,但由于我在优化reset处理时未考虑周全,原本的电梯并未结束并且进入了死循环,导致CTLE。解决方法是完善reset的优化过程或者直接把优化删掉,很心痛早知道不优化了我失去的强测分啊啊啊

多线程debug

由于线程安全,多线程可能会出现很多幽灵bug让人百思不得其解。且错误很难再现,可能评测机显示错误但本地跑就是对的,需要本地跑很多趟才能再现错误。

除了错误很难再现的问题,由于多线程不能用行断点debug,只能用System.out.println的方式打印输出线程的运行状态和过程。

/* if (requestList.isEnd()) {
    System.out.println(String.format("%d-%s is end",id,doubleCar));
}
if (requestList.isEmpty()) {
    System.out.println(String.format("%d-%s requestList is empty",id,doubleCar));
}*/
if (advice == Advice.MOVE) {
    //System.out.println(String.format("%d-%s Advice is move",id,doubleCar));
    move();
} else if (advice == Advice.REVERSE) {
    //System.out.println(String.format("%d-%s Advice is reverse",id,doubleCar));
    direction = -direction;
} else if (advice == Advice.WAIT) {
    //System.out.println(String.format("%d-%s Advice is wait,resetFlag is %d "
    // ,id,doubleCar,requestList.isReset()));
}
...

心得体会

线程安全

多线程同时读写带来的线程安全问题像幽灵一样缠绕在三次作业中,处理不好很难debug修正。因此在多线程设计阶段就要选定好临界资源和锁,先在草稿纸上模拟多线程并发,仔细考虑线程不同执行顺序的结果,确保没有线程安全问题后再写代码。

第二次作业出现的reset标志位线程安全问题,使我深化了对临界区的认知,更体会到了锁在多线程设计中的重要性。

层次化设计

生产者-消费者的层次化设计沿用了三次,是很适合这单元作业的架构设计。

电梯单设Strategy类,实现执行与策略部分分离的层次化设计,也使本单元的作业思路更为清晰。

流水线层次化设计,把顾客请求分段,再交给双轿厢电梯执行,这一架构设计使得第三次作业的迭代很容易就能实现,而不用大幅度修改原有电梯的逻辑。

总之,第二单元相较于第一单元的难度更高了,需要考虑的情况更多了,出现的bug也更多了。可能最后一次作业没进互测有些遗憾,但完成本单元的所有作业后,我对多线程、临界资源、临界区、synchronized锁有了初步的认知,这是我近一个月来的最大收获。

...全文
68 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

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

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