面向对象设计与构造第二单元
在我之前所学的所有编程知识都是关于顺序执行的,就是程序在任何时候只能执行一个步骤。然而能够并行的执行程序中的多个部分,在很多时候可以大大提高程序的效率。这个单元最主要的内容就是理解并编写并发执行的程序,这对于我所学的编程知识来说是一种质的飞跃。
前置芝士
上下文切换
- 线程执行的底层机制是切分
CPU
时间,CPU
轮流为不同的任务分配时间,每个任务都觉得自己在一直占有CPU
,但事实上CPU
时间是划分成了片段分给所有的任务,除非程序确实运行在多个CPU
之上,这种情况下是可能出现真正的并行执行的,虽然对于今天的计算机来说多核心已经是最基本的配置之一,但并不是所有情况,这一点在后面的程序编写中非常重要。这意味着对于单个CPU来说需要不停地切换执行的任务以达到多任务并发执行的目的,至于具体如何切换将完全受控与CPU
和操作系统(Java
中还有JVM
参与)。而一次CPU
任务的转换就被称作一次上下文切换,这是非常重要的概念,后面我们将会看到上下文切换的次数将会很大的影响并发程序执行的速度,而且一些特殊的切换方式将会导致线程无法安全终止的问题暴露,记住,切换方式完全不受我们支配,多线程的执行具有极大的不确定性。
定义任务
- 实现
Runnable
接口并编写Run()
方法。
public class TaskB implements Runnable {
public void run() { // 执行入口点
...
}
}
- 继承
Thread
类并重写Run()
方法。
public class TaskA extends Thread {
public void run() { // 执行入口点
...
}
}
-
创建线程对象
Thread t = new TaskA(…)
继承Thread
类Thread t = new Thread(new TaskB(…))
实现Runnable
接口。
-
启动线程
t.start()
共享资源竞争
- 在单线程编程模式,每次只做一件事情,永远不用担心"两个实体同时访问同一个资源"的情况。但并发就不同了,可能出现两个人同时向同一个车位停车,两个设备同时试图用某台打印机打印,甚至两个线程试图同时修改某个变量的值。这是我们需要避免的情况。想象一下,你正要将筷子伸向盘子中的最后一块食物,但食物突然消失了,因为你的线程被挂起了,另一个进食者开始执行吃掉了这块食物,我们需要一些手段阻止两个任务同时访问一个共享资源。
锁(同步代码块)
-
解决共享资源竞争的办法是对共享资源加锁,例如多个线程都希望单独使用卫生间,第一个进入卫生间的线程将门锁上,后续想用卫生间的线程都会先敲敲门,如果里面有人就在门外等候,等到使用卫生间的线程出来,再进去关门上锁使用。
-
synchronized
关键字:Java提供此关键字为防止资源冲突提供了支持。-
synchronized
声明的方法。public class SynBlock { public synchronized void m1() { ··· } }
若有线程试图调用某个
SynBlock
对象的m1()
方法,会首先检查此对象的锁是否可用,若可用则这个线程获取这个对象的锁,开始执行这个方法,执行完毕后释放这个对象的锁,此时别的线程才可以调用这个对象的m1()
方法。 -
synchronized
声明的代码块public class SynBlock { private Ctrl sbms; public void m1() { ··· synchronized (sbms) { ··· } ··· } }
当某个线程调用某个
SynBlock
对象的m1()
方法时在执行到synchronized
关键字包裹的代码块时,会检查sbms对象的锁。括号中也可以使用this
关键字,此时则检查的是此SynBlock
对象的锁
-
-
可重入锁
一个任务可以多次获得对象的锁,如果一个方法在在同一个对象上调用第二个方法,后者又调用同一对象上的另一个方法,就会发生这样的情况。JVM负责跟踪对象被加锁的次数,在任务第一次获得锁时,计数器为\(1\),以后,每当这个相同的任务在这个对象上获得锁时,计数器都会递增,只有首先获得了锁的任务才能继续获得锁。每当任务离开一个
synchronized
方法,计数器递减,当计数器变为\(0\)时,释放锁。 -
使用显式的
Lock
对象:Lock
对象必须被显式的创建、锁定和释放,因此与synchronized
这样内建的锁形式相比代码缺乏优雅性,但具有更好的灵活性。public class Sample { private Lock lock = new ReentrantLock();(可重入锁) private void m1() { lock.lock(); try { ··· } finally { lock.unlock(); } } }
线程之间的协作
-
如何使得一个任务不会干涉另一个任务的资源这一问题已经解决,接下来的问题是线程之间的彼此协作,这就像盖房子一样,必须先筑地基再铺设钢结构和水泥部件,而这两项任务又必须在执行混凝土浇筑之前完成。在这些任务中有些可以并行执行,而有些则有严格的先后顺序。为此我们为任务添加了一条途径,他可以将自身挂起,直到某些条件发生变化再重新执行。当然一种方法是不停地检查这些条件并进行空循环(轮询),然而这种方式是一种不良的
CPU
使用方式,因为CPU
一秒钟执行的指令数量十分庞大,而某些条件的改变可能用时会比较长,不断的轮询就会导致执行了大量无意义的指令,这十分浪费CPU
资源,不值得提倡。 -
wait()
和notify()/notifyAll()
-
wait()
可以使线程等待某个条件发生变化,而改变这个条件超出了当前方法的能力范畴。wait()
会使当前线程挂起,并释放所获得的锁(没错,因此wait()
必须放入同步代码块中执行),只有在notify()/notifyAll()
发生时才会重新唤醒继续执行。 -
notify()
唤醒的是某一个等待的线程(由系统决定),notifyAll()
是唤醒所有正在等待的线程。 -
值得注意的是,这三个方法都是基类
Object
的一部分,而不是属于Thread
的一部分。 -
调用某个对象的
wait()/notify()/notifyAll()
方法前必须拥有对象的锁,否则将会得到IllegalMonitorStateException
的异常。例如:synchronized (req) { try { req.wait(); } catch (InterruptedException e) { e.printStackTrace(); } }
req.wait()
才是正确的写法,如果改成wait()
则会获得异常。 -
notify()
是唤醒一个正在等待的线程,而notifyAll()
是唤醒所有正在等待的线程。这句话其实具有误导性,事实上当notify()/notifyAll()
因为某个特定的锁而被调用时,只有在等待这个锁的任务才会被唤醒。 -
这里有一个小细节,就是并不是调用
notify()/notifyAll()
的瞬间就会唤醒,而是调用完并且交出相应锁之后才会唤醒。
-
-
await()
和signal()/signalAll()
-
与
wait()/notify()/notifyAll()
系列类似,是Condition
类的对象中的方法,可以直接类比,只不过需要和Lock
配合使用。Lock lock = new ReentrantLock(); Condition condition = lock.newCondition();
-
单元任务
设计一个多线程的电梯调度系统,实时模拟若干个电梯的运行,评测系统可以做到在特定时间投喂一定量请求。
两种思路
-
调度器架构
- 即有一个调度器专门负责电梯运行的控制,这里面也有两种实现思路,一种是向电梯发送指令流,一种是仅将乘客分配给电梯。但前一种方法过于繁琐,调度器设计的复杂性比较高,后一种是比较可行的方案。
- 然而,若是想实现一些稍微复杂的调度策略,无论是哪种调度器的设计,都会面临一个问题,分析策略需要读取电梯目前的运行状态,然而电梯线程都是并行执行的,调度器读取到并进行分析的电梯状态并不一定是最新的状态,这会造成现实实现出来的策略和预期策略有所不同,若要避免,则必须在调度器分析期间阻塞所有的电梯线程,然而这有悖于并发编程的设计理念,况且过多的阻塞会严重影响程序效率。
- 当然也可以认为,即使现实实现出来的策略和预期策略有所不同,考虑到调度器编译后的指令条数并不多,小于电梯程序编译后的指令条数,在调度器分析期间电梯的状态变动不会对电梯系统的运行时间产生太大的影响,因此并无阻塞电梯线程的必要。
- 但我比较讨厌这种自己的程序不能完全体现自己的思想的感觉,因此并没有选择这种思路,而是选择了第二种思路。
- 不过在调度器架构下有一种模拟的贪心思路,每次可以选择局部最优的策略,即每个电梯可以计算出运送完自己的所有乘客所需的时间,每次把乘客分给用时最少的。(这个策略在课程组的数据下似乎表现很好)。
-
在电梯中实现策略
- 基于经典的"生产者-消费者"模式,考虑一个传送带,输入线程不断将乘客请求放入传送带,而电梯根据自己的策略在传送带中选择合适的乘客接入内置的请求队列。
- 每个电梯都有自己内置的乘客请求队列,每个电梯只需要专心处理自己的请求队列。
- 对于具体的策略,什么FCFC、LOOK、SCAN等算法都源自于操作系统的磁盘调度算法,自由竞争是每个电梯都认为只有自己一个电梯并直接处理输入的乘客请求,至于在数据竞争下哪个电梯能处理到哪个乘客完全交给
JVM
。 - 评估一个电梯调度系统有很多指标,比如运送完所有乘客的总时间、电梯系统耗电量、乘客请求的响应时间等等,不同的策略各有千秋,可以根据自己的喜好随意选择,不过"世界上最好的OO"课程组出的"世界上最好的数据"似乎具有偏向性,可以根据每次的评测结果灵活变动,甚至实现多种策略每次随机选择一种。
第一次作业
需要实现如下请求:
- 乘客请求,包括出发楼层和目的楼层。
直接按照上述的第二种实现思路进行编码。共享资源也只有输入的乘客请求队列,注意加锁就好。
注意事项
-
wait()
错过notify()
-
如下是我作业中截取的两段代码。
public class Elevator implements Runnable { private final InputThread req; public void run() { ··· while (pass.isEmpty()) { synchronized (req) { try { req.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } ··· } } public class InputThread implements Runnable { public void run() { ··· while (true) { PersonRequest req = elvIn.nextPersonRequest(); synchronized (this) { if (req == null) { notifyAll(); break; } ··· notifyAll(); } } } }
假设有
Elevator
和InputThread
两个线程并行执行,若Elevator
执行完while(pass.isEmpty())
语句后被JVM
进行一次上下文切换,执行InputThread
假设这时已经读入到了null
,于是InputThread
结束,JVM
进行上下文切换,继续执行Elevator
,这时线程进入wait()
而这这个wait()
将始终得不到唤醒,于是整个程序就无法安全终结,这是很有可能的,我们前面已经提到上下文切换完全由系统决定,我们必须考虑到最坏的情况,事实上,这也是大部分TLE
的原因,在后续作业中也是一个非常值得重视的问题。 -
解决办法
- 考虑出现这种线程不安全问题的本质原因,在于
InputThread
连续执行,过早的发送了唤醒消息,虽说上下文切换完全由系统决定,但我们也可以通过sleep()
来进行强制的上下文切换,迫使系统从当前执行的线程切出去执行别的线程,这里,我们在notify()
之前加上一句sleep(1)
就能保证Elevator
在进入wait()
之后才发送唤醒消息。当然具体sleep()
的秒数要看对于代码编译后的指令条数和CPU执行速度的估计,我的程序\(1ms\)已经足够。 - 线程由于进入无法唤醒的
wait()
而无法安全终止,那我们就给wait()
加上一个时间限制,变成wait(10)
,超过一定时间,无论是否收到唤醒,都将强制结束。
- 考虑出现这种线程不安全问题的本质原因,在于
-
-
减少上下文切换次数
- 除了轮询之外,过多的上下文切换也会导致对CPU资源的浪费,在课程任务中对CPU使用时间限制在\(10s\)以内。
- 我一开始仅设计了一个请求队列,输入线程读取到的乘客请求都会一股脑被放入这一个队列,如此一来,只要有电梯同时需要读取请求队列就会陷入阻塞,这就会发生一次上下文切换,但CPU使用时间超过了\(10s\)。
- 仔细分析,我们发现,在电梯顺路捎带的途中,如果一个电梯在\(1\)楼,那就只需要读取起始楼层在\(1\)层的乘客请求,另一个电梯在\(2\)楼就只需要读取起始楼层在\(2\)层的乘客请求。如果只有一个请求队列,那么这是势必会发生冲突,但这是可以避免的,我们给每个楼层设计一个独立的乘客请求队列,那么这个冲突就被避免了。经过这个优化,CPU使用时间缩短了\(10\)倍。
第二次作业
需要实现如下请求:
- 乘客请求,包括出发楼层和目的楼层。
- 新增电梯的请求,电梯的速度、载客量可以自定义。
- 关停维护已有电梯的请求(需要在两层楼层移动之间清空要维护电梯内的乘客)。
这次比较简单,收到新增电梯的请求就新开一个电梯线程,对于维护请求就把要维护的电梯的乘客全部重新加回乘客请求队列,只需要注意如果维护请求是最后几条请求,不要在完成加回之前过早结束程序。
第三次作业
需要实现如下请求:
- 乘客请求,包括出发楼层和目的楼层。
- 新增电梯的请求,电梯的速度、载客量可以自定义。
- 关停维护已有电梯的请求(需要在两层楼层移动之间清空要维护电梯内的乘客)。
- 电梯可以到达的楼层有限制。
- 同一层楼同时开门的电梯数量有限制。
-
对于同一层楼同时开门的电梯数量限制,只需要给每个楼层加一个计数器,开门之前将计数器累加,关门后递减,注意这个计数器也是共享对象需要解决数据竞争问题。
-
电梯引入到达楼层的限制之后,处理乘客请求就不能单纯只关注起点和终点,需要规划一条从起点到终点的路径,容易想到建图处理。
-
例如电梯的可达楼层是\(1、2、4、5\)我们建出下图。
flowchart LR node_1(("1")) node_2(("2")) node_3(("4")) node_4(("5")) node_1 --- node_2 node_1 --- node_3 node_1 --- node_4 node_2 --- node_3 node_2 --- node_4 node_3 --- node_4也就是说我们在任意两个可以直达的楼层之间连边,一个乘客请求相当于要寻找一条起点到终点之间的通路。
-
每次电梯只处理通路之中的一段,处理完成之后将乘客重新加回乘客请求队列供其他电梯继续处理,同样需要注意不要在加回之前过早结束程序。
-
bug分析
- 第一次作业将电梯承载人数限制设置错误,题目要求是\(6\)人,我设置了\(7\)人。
- 第一次作业之后痛定思痛和室友一起完成了一个自动评测系统,因此第二次作业没有出现bug。
- 第三次作业误以为空开关门不算只接人的电梯。
心得体会
- 关于并发编程的理解与感悟都已经融入在上文之中,引用《Thinking in Java》中的一句话做结,如果一个人认为线程机制很简单,那么请不要让他对你的项目做出任何重要决定,如果他已经这么做了,那你就陷入了麻烦之中。
- 除了对并发的理解之外,这单元最大的感受就是感到在"世界上最好的OO"课程!!!和"昆仑课程"!!!下学习实在是太幸运辣!!!
- 我们昆仑课程的中测数据竟然强到连最基础的错误的测不出来,甚至连读错题意都不能发现。
- 第三次作业的强测数据竟然可以让没有设计换乘机制的代码通过大部分测试点,(最多有通过\(18\)个点的)!!
- 第二次作业的强测数据强到竟然能够让一个在我本地评测机上随便就能卡掉的代码进入A房!!
- 由此可见我们的课程组不愧是"世界上最好的OO"课程组,在测试数据编造上花费了大量心血,构造了极强的数据。同时可以看出,我们课程组出题之后都是仔细找人验过题的!
- 对于自由竞争的写法,他满足了多线程编程的思想,完全按照课上讲授的知识编写,我们的昆仑课程组为了保证课程的公平性,以莫须有的罪名,精心设计了针对自由竞争的数据!!同时对调度器结构在上文提到的缺陷只字不提,推崇备至!!
- 这里推荐几篇跟出题有关的博客ouuan 的出题规范、CF出题人的自我修养、UOJ精神之源流虽然这些都源自于\(OI\)出题,但与任何程序设计相关的课程的出题也是有很多共通之处的。出的题目质量很大程度上决定了课程的质量。
- 另外真的可以换换题目了,每年OO的题目都基本一样,真的不腻吗?