标签(空格分隔): 操作系统之哲学原理
为什么要同步
最直观的例子是
线程A:x=1;
线程B:x=2;
这两个线程执行结束后会有以下几种情况:
- x=1;
- x=2;
甚至可以是3.
由于x是共享变量,且线程之间的相对执行顺序是不确定的,因此线程A可以在线程B的前后执行。至于3的情况,根据指令集结构在执行赋值语句时的动作而定。
两个线程在执行的时候会进行穿插:
线程A
i=0;
while(i<10){
i++
}
printf”A finished”;
还有
线程B
i=0;
while(i>10){
i–;
}
printf”B finished”;
不知道哪一个线程会先结束
这里也是折射一个看上去比较矛盾的问题:我们使用多线程并发,但是针对其中某一个进程来说我们无法确定这个进程在什么时间可以结束,因为进程并发交叉执行。于是就想到同步。
线程同步的目的
线程同步的目的就是不管线程之间的执行如何穿插,其运行结果都是正确的。或者说,要保证多线程执行下结果的正确性。还有一个目的就是关于执行效率的问题。
同步就是让所有线程按照一定的规则执行,使得其正确性和效率都有迹可寻,线程同步的手段就是对线程之间的穿插进行控制
锁的进化:金鱼生存
讲的是两个人一起养金鱼给金鱼喂食,金鱼不能多吃,多了就撑死;金鱼不能少食,少了就饿死。因此两个人喂食很有讲究。他们基本不会天天都在一起,因此什么时候适合给金鱼喂食就很重要。喂食之前先要判断对方是否已经给金鱼喂食,要是没有喂,就得喂食;要是已经喂了,就不再继续喂食。
两个或者多个线程争相执行同一段代码或访问同一资源的现象称为竞争。这个可能造成竞争的共享代码段或资源称为临界区。
两个线程不可能在同一时刻执行,但是有可能在同一时刻两个线程在同一段代码上
变形虫阶段
要防止鱼撑死就要防止竞争,要避免竞争就要防止两个或多个线程同时进入临界区。一次需要协调手段。
协调的目的就是在任何时候都只能有一个人在临界区,这就是传说中的互斥(mutual exclusion),互斥就是说一次只有一个人使用共享资源,还不能违反互斥模型
正确的互斥:
- 不能有两个进程同时在临界区
- 进程能在任何数量和速度的CPU上运行
- 在互斥区外不能阻止另一个进程的运行
- 进程不能无限地等待进入临界区
任何一个条件不满足,设计的互斥就不正确
于是改进喂鱼的手段,决定每个人要是喂了鱼就留一个纸条。
但是这样的方式并没有达到互斥的目的,因为没有防止两个人同时进入临界区。但是降低了鱼撑死的概率。这个案例的临界区就是站在鱼缸前面准备投鱼食 撑死可能是:A在鱼缸前发现没有B的纸条,好,鱼很饿。丢几袋鱼食。还没来得及留下纸条。好,线程切换,B来到了鱼缸前,发现没有纸条,好家伙鱼还没喂食呢,一股脑扔几袋鱼食。一分钟后,不幸的事情发生了,鱼撑死了。线程切换可以想象成电影里面的场景切换。
鱼阶段
上面的喂鱼情景中,发现不解决问题的原因是我们先检查纸条,再喂鱼,再留纸条。喂鱼和留纸条之间出现了一个空档。我们的解决办法就是先留纸条再检查有没有对方的纸条,喂完后把纸条拿开。不过需要区分纸条是谁的。
总有一个人的留纸条指令在另一个人的检查纸条指令之前执行,从而将防止两个人同时进入临界区
但是穿插执行的话又有新问题:
A来到鱼缸前,先留下纸条。可是在这个时候发生了线程切换,B来到鱼缸前先留下纸条,检查有没有对方的纸条,发现对方已经留下了纸条,于是离开。线程切换,A开始检查对方纸条,发现对方已经留下了纸条。于是离开。于是,。,。,。鱼就悲剧了。没有喂食。
比起撑死来说,饿死也是一种改善。
如果是撑死,程序的运行结果很可能出错:几个线程同时获得同一个资源,出现的不一致性及结果不确定性几乎是难以避免的。但是如果是饿死,大家都拿不到资源,线程处于饥饿状态,至多是停止推进,而这不一定产生错误结果,或许只是推迟结果的出现。
猴阶段
上面的鱼被饿死是因为没有人进入临界区。于是想出了另一个同步机制:循环等待;
办法就是:让某一个人等着,直到确认有人喂了鱼才离去,不能见到对方的纸条就开溜。
下面的场景:
1.A来到鱼缸前,先留下纸条。检查没有对方的纸条,喂鱼。喂完鱼后删除纸条。B来到鱼缸前,先留下纸条。检查没有A的纸条,开始喂鱼。喂完后删除纸条。成功。
2.A来到鱼缸前,先留下纸条。检查有没有对方纸条,好,进程切换。切换至B来到鱼缸前,先留下纸条,再检查有没有A留下的纸条,发现有。于是,。,。,。,。。,。不能离开,等待,。,。,。等到线程切换回A线程,A喂完鱼后删除纸条。检查鱼有没有被喂。有就离去。
几个场景逻辑不是很缜密,但是意思肯定是到位了,理解了意思不要纠结于我们模拟出来的细节
锁
上面的几种同步机制虽然正确
- 程序不对称就不美观
- 程序不对称就编写困难
- 循环等待是对CPU的极大浪费,浪费就是犯罪。
设计一种同步措施,使得在任何时候只能有一个人进入放置鱼缸的房间,这样检查纸条和留纸条都可以变成房间上锁的一步操作,在操作系统中,这种可以保证互斥的同步机制称为锁
锁的基本操作
- 闭锁操作
- 步骤1:等待锁达到打开装态
- 步骤2:获得锁并锁上
- 开锁:打开锁
显然闭锁的两个操作应该是原子操作,不能分开。
正常锁的基本特性:
- 锁的初始状态是打开状态
- 进临界区前必须获得锁
- 出临界区时必须打开锁
如果别人持有锁则必须等待
循环等待很痛苦呀,部不仅造成了浪费,还降低了系统效率。但是不能消除繁忙等待,可以减少等待的时间。
睡觉与叫醒:生产者与消费者问题
什么是睡觉与叫醒?
如果对方持有锁,你就不需要等待锁变为打开状态,而是去睡觉,锁打开后对方再来把你叫醒。
sleep
与wakeup
就是操作系统里的睡觉和叫醒原语。一个程序调用sleep后进入休眠状态,将释放其所占的CPU.一个执行wakeup的程序将发送一个信号给指定的接收进程。
信号量(semphore)
信号量的功能:
- 同步原语
- 通信原语
- 锁
信号量作为同步原语和锁简单来说就是一个计数器,其取值就是当前累积的信号数量。
信号量支持的操作:
- down减法操作
- 判断信号量的取值是否大于等于1
- 如果是,将信号量的值减1,继续往下执行
- 否则在该信号量上等待(线程被挂起)
- up加法操作
- 将信号量的值增加1(此操作将叫醒一个在该信号量上面等待的线程)
- 线程继续往下执行
看上去很多的操作,但是这些操作都是原子操作,是不能分开的。
将信号量的取值限制为0和1两种情况则获得的就是一把锁。也称为二元信号量。
锁、睡觉与叫醒、信号量
锁解决了同步问题,但带来了循环等待,为了消除循环等待,我们发明了睡觉与叫醒,但是睡觉与叫醒又带来了死锁,于是发明了信号量。使用信号量的时候,操作顺序很是重要,稍有不慎就会发生死锁,于是我们发明了管程。