oo 第二单元博客作业总结

祁明礼-21376220 学生 2024-04-20 19:58:52

同步块的设置和锁的选择

第一次

在第一次作业中 因为使用了最为简化的消费者生产者模式
在作业的要求中将电梯的分配固定在输入中
仅仅需要托盘类包含 addRequest getRequest方法即可
为了保证正确性 我在RequestTable类将所有的方法都加上了synchronized关键字
由此在性能上的牺牲并不明显

第二次

与第一次相同 我在RequestTable类将所有的方法都加上了synchronized关键字

第三次

在第二次互测中 我发现同一个房间的同学使用了synchornized代码块 而不是使用synchronized实例方法

public synchronized void func() {

}

相当于

public void func() {
    synchronized (this) {

    }
}

而我在第三次作业中使用了这种写法

public void addNormalResetRequest(NormalResetRequest request) {
    synchronized (resetRequestHashMap) {
        resetRequestHashMap.put(request.getElevatorId(), request);
    }
}

这个this 是这个托盘类本身实例
改成了resetRequestHashMap 相当于缩小了同步域 显然是更合理的写法

一些补充

实际上我也了解了一下读写锁和copyOnWrite

信号量

Single Thread Execution模式用于确保某个区域"只能由一个线程"执行
那么如何做到"只能由N个线程执行"
使用到Semaphore类

Semaphoreacquire方法用于确保存在可用资源
当存在可用资源时 线程会立即从acquire方法返回 同时信号量内部资源个数- 1
若无可用资源 线程则阻塞在acquire方法内 直至出现可用资源

Semaphorerelease方法用于释放资源 释放资源后 信号量内部的资源个数会增加 如果acquire中存在等待的线程 其中一个线程会被唤醒 并从acquire方法返回

使用copy-on-write 的 CopyOnWriteArrayList类

java.util.ArrayList 是非线程安全的
当多个线程并发执行读写时 不安全

CopyOnWriteArrayList类采用copy-on-write避免读写冲突
也就是在对集合执行"写"时 将数组整体复制 就不用担心被修改了
因此在写操作比较频繁的时候比较花费时间

synchronized

synchronized void method() {
    ...
} 

等同于

void method() {
    synchronized(this) {
        ...
    }
}

都可以看作是 在{处获得锁 在}处释放锁
其真正对应的写法是

void method() {
    lock();
    try {
        ...
    } finally {
        unlock();
    }
}

try-finally是必须的!!!

想要缩小同步域 可以使用synchronized代码块 但是需特别考虑 使用什么锁
在synchronized代码块中 需要明确指定获取哪个实例的锁

读写锁

当某个线程正在读取时 其他线程也可以读取 但不能写
当某个线程正在写时 其他线程不可以写也不可以读

写锁同一时间只允许同一个线程获得
 一旦有线程获取写锁 将不会有线程获取读锁直到写锁被释放

读锁同一时间可以有多个线程获得 
一旦有线程获取读锁 将不会有线程获取写锁直到读锁被释放

这样与单纯的互斥操作相比 在多个线程读取的情况下 不会引发互斥 提高性能

public int readData() {
    lock.readLock().lock(); // 获取读锁
    try {
        return data; // 读取共享资源
    } finally {
        lock.readLock().unlock(); // 释放读锁
    }
}

public void writeData(int newValue) {
    lock.writeLock().lock(); // 获取写锁
    try {
        data = newValue; // 写入共享资源
    } finally {
        lock.writeLock().unlock(); // 释放写锁
    }
}

读写锁和synchronized的锁的对比

Java每个实例都持有一个锁 这是java语言提供的物理锁
读写锁则是实现出来的逻辑锁
用于实现读锁和写锁的物理锁只有一个 就是ReadWriteLock 实例持有的锁

但是

实际上我这几次的作业关于上面的补充知识均没有使用
大部分精力在保证教程规定的动作实现正确

调度器设计

架构-生产者消费者模式

消费者-生产者模式
有以下登场角色

  • Data Data角色由Producer角色生成 供Consumer角色使用 人
  • Producer Prodecer角色生成Data角色 并将其传递给Channel角色 输入
  • Consumer Consumer角色从Channel角色获取Data角色并使用 电梯
  • Channel 候乘表

Channel角色保管从Producer角色获取的Data角色 并且响应Consumer角色的请求 传递Data角色

第一次

第一次 因为在输入中指定了所选电梯 不需要调度

时序图

电梯线程:

img

第二次

时序图

Schedule 和 Elevator的时序图

img

img

第二次 RECEIVE机制使得在乘客在进入电梯之前就需要确定 进入且只能进入一部电梯
所以无法使用往届性能不错的自由竞争
我采用了计算该电梯的等待队列中的数量 并没有计算在该电梯内部的人数
将新的Request 分配给该数量最少的人数
同时这里还需要注意
可能会出现6部电梯同时被reset的情况
在这种情况下 我会让bestEle返回特殊值 然后让分配方法等待 直到bestEle返回合理值

if (person != null) {
    int num = bestEle();
    while (num == -1) {
        try {
                wait();
        } catch (Exception e) {
            //
        }
        num = bestEle();
    }
    requestTables.get(num).wake();
    mainRequest.addRequestToBuf(num + 1, person);
}

第三次

时序图

只看两个主要的线程

img

img

为了简洁
我认为可以省略Schedule这个类

public synchronized void addPersonRequest(Person person) {
    int elevatorId = decideElevatorId(person);
    personRequestMap.get(person.getFromFloor()).add(person);
    requestNumTable.put(elevatorId,
        requestNumTable.get(elevatorId) + 1);
    notifyAll();
}

通过decideElevatorId 决定
最终的调用者是Input 线程
这样就不需要Schedule 的参与

同时我尝试使用Random来决定电梯编号
而不是获取电梯的信息计算决定
比如影子电梯和我上一次作业使用的统计人数
因为这样比较简洁 性能上的损失也不是不能接受

双轿厢的重置方法也没有特别的
就是让给当前电梯线程一个标志(可以结束了)
我在之前电梯的run 的while(true)
进行了改动

img

在方法中新启动两个双轿厢电梯线程
这样工作就完成了

电梯运行策略

三次作业均使用了LOOK策略
使用策略类给出建议
由电梯线程接受建议并做出行动

三次作业的比较

三次作业中 基本保持不变的是

  • 消费者生产者的主要架构 输入的线程和电梯的线程 资源类
  • LOOK(或者其他)电梯的运行策略

每次变化比较大的实际上是电梯的实例方法中的行为
比如从第一次到第二次增加了重置
就要求电梯增加重置的方法
而且这个方法涉及到的一些操作也要增加
同样的从第二次到第三次增加了双轿厢的重置
就要求电梯增加双轿厢的重置方法

这样看来 变化的代码部分是变化的(新增的)需求导致的 只要不是新的需求太离谱
不需要将原来的架构和方法进行大的改动

其他分析

防止碰撞

img

img

参考了评论区同学的设计
双轿厢电梯两个线程共享一个资源类
设置占用和解除占用的机制
通过互斥的约束防止碰撞

debug

print

在debug过程中 还是print来的比较直接

如果遇到了卡在CLOSE-N-N这种
需要Ctrl-C结束的
我就在一些方法前后加上print 看看 到底代码卡在了什么地方

img

在作业5中我就通过这个方式发现过卡在了in() 的内部
然后分析是否出现死循环或者wait() 没有结束之类

还有一种针对CTLE的方式
根据助教发在答疑群的

在while(true) {} 的内部
在一些条件判断中 加上TimableOutput.println("....");
如果出现一眼能看出来的大量的重复输出
说明出现了轮询
简单来说 应该在第一次发现无法继续的时候wait
而不是直接continue 重复进入循环一次判断条件是否满足
这样对于CPU的资源消耗非常厉害

我在第6次作业debug的时候采用
帮助我解决了CTLE的问题
强测也没问题

img

这个bug可以看出来是双轿厢电梯没有在该放人的地方把人放下来 所以我下一步考虑在放人的out方法静态检查
或者静态检查一下策略类 是否在该放人的楼层没有给出正确的建议
简而言之 通过分析 然后静态检查
是我主要采用的方法

心得体会

线程安全

我目前认为要保证线程安全
首先要采用一些理论指导程序的设计
如果事先不知道一些线程安全的模式和实践
就不要想自己无师自通

其次是要多测试
尤其是并发的问题 很多只有多次重复才可以复现
比如我出现过的双轿厢电梯楼层越界
只有按照一定的次序分配了电梯资源
正常的电梯和双轿厢电梯按照一定的次序出现 才能出现问题
但是这个次序是程序运行过程中才能决定的

正是这种问题 使得测试对线程安全非常重要

层次化设计

积贫积弱

通过三次作业 了解了多线程的一些实践
但是通过将需求落实的过程 我发现了自身代码能力的一些缺陷

比如并发问题 一旦出现 难以定位问题
这是理论课老师指出的
虽然理论上确实无法通过一个现象就立刻找到与之相关的变量
但是通过一些天赋和练习 是可以做到的
实际上其他同学很多就做到了
虽然是程度上的问题 但我目前这方面确实有所欠缺

定位问题的能力
举例来说
上学期计组p7上机 强测出了一个bug
通过一行报错发现

教程规定 不管是否跳转都要有延迟槽标记 但我在判断标记的时候是
D_NPCOp != `plus4  
然后在判断 D_NPCOp 时将branch但没有跳转 归为 `plus4 于是就出了问题

这个问题我花了一个小时才发现 但是
既然在一个小时的时间点想到了 为什么不能通过10分钟想到呢 前面几十分钟的思考是有效的吗

定位问题的效率低下 在当时就体现了
但是直到OO 才让我如此痛苦

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

301

社区成员

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

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