用很简单的方式教你学临界区、信号量、互斥锁

前言

这也算是面试必备的知识了。其实这东西还是非常简单的,这篇文章争取把你教会。
为什么要搞这么个信号量和互斥锁呢?其实很简单,同一个资源(比如同一块内存或者是同一个IO外设)同时被不同的进程访问(A进程想写入一些东西,恰巧B进程也想写入一些东西),最后的结果很有可能就出乎意料,所以在访问一些公共资源时,大家要排好队,一个个来访问,避免冲突。

Linux下的进程与线程

有人这么说:进程是资源分配的最小单位,线程是CPU调度的最小单位。其实线程和进程在CPU调度器上来说,都是基本的调度单位,都有自己的PCB(process control block)。线程是依托于进程而创建出来的,本质上就是可以独立运行的一个函数。进程享有独立完整的地址空间,而线程则没那么好运了,线程需要依赖进程的资源,本身是不会分配得到独立的地址空间的。
另一个角度理解是:进程之间切换时需要不断地保存上下文,而进程中的线程间切换时,不需要再重复保存进程的上下文,在CPU调度时间上来说,进程的粒度更小。

CPU如何调度进程和线程

这是个很沉重的话题了,我肯定是不会随手去网上摘一段然后放在这里,文字枯燥难懂,看了等于没看。
Linux下有一个东西叫PCB,process control block,就是一个进程或线程的身份证,这个结构体里面大概有啥呢?先不告诉你,反正这里保存了进程的基本信息。

struct ProcessControlBlock
{
	balabala........
}

接着,咱们得准备两个全局的链表,链表成员就是PCB指针。一个是所有的进程PCB链表,一个是已经处于ready状态随时可以运行的PCB链表。

extern struct list thread_ready_list;
extern struct list thread_all_list;

好了,调度的本质,就是将当前的PCB归还到ready链表,再将ready链表的front弹出,运行之。是不是听起来很简单?(细节?别着急啊,慢慢来)
调度是啥时候发生的呢?调度的发生,分为主动与被动两种。主动的调度,就是当前正在运行的PCB(我这么说很挫的,但好理解)里,自己写了几行代码,执行调度函数。。。。。。(当然这样的例子有很多啊,比如sleep(0),比如后面咱们要说的信号量与锁的实现都是如此),还有一种调度是有点被动的,我来介绍一下被动调度的机制-定时中断。定时中断大家都知道,就是每隔一段时间执行一次定时中断函数。PCB每次登场的时候,都会有一个叫优先级的东西,0是内核优先级,最高,3是用户优先级,最低。(现在PCB长这样)优先级越高的PCB,获准执行的时间就越长,这个时间呢,在每次PCB登场时也准备好。还有一个参数,出厂时是0,每来一次定时中断,由中断给他+1,相当于计时。

struct ProcessControlBlock
{
	uint8_t priority;		//多了一个优先级
	uint8_t ticks;	   // 每次在处理器上允许执行的时钟中断数
	uint8_t cur_ticks;	//定时中断每次将其+1
	balabala........
}

看到这里你肯定明白了,PCB不可能一直运行下去,定时中断会不断地更新当前运行PCB的cur_ticks,如果当定时中断发现:卧槽,cur_ticks==ticks了,那得了,您下去吧,换个人上来。于是定时中断就执行调度函数了。
究竟是用户主动执行的调度函数,还是定时中断执行的调度函数,在处理上肯定是有区分的,但是咱们先不管那么仔细,咱们就去研究一下,调度函数究竟是如何实现的。
本质很简单,就是切换PCB,但是切换涉及到很多东西的,稍微有点麻烦。
1.页表需要更换。啥叫页表呢?这又是个很沉重的话题了。我感觉自己捅了马蜂窝。这里我先不讲那么仔细好了,咱们到时候再聊,总之要记住,这个分页表,非常重要,及其重要,不能少,且内核进程有自己的分页表(所有内核进程的分页表都是唯一的),而每一个用户进程都有自己的分页表(这是各自不同的),OK这个信息存放在PCB的单独一个描述中,如果是用户进程PCB,此描述为分页表的虚拟地址,如果是内核进程则为空指针:

struct ProcessControlBlock
{
	uint8_t priority;		//多了一个优先级
	uint8_t ticks;	   // 每次在处理器上允许执行的时钟中断数
	uint8_t cur_ticks;	//定时中断每次将其+1
	uint32_t* pg_addr;	//进程页表的虚拟地址
	balabala........
}

更换页表方式很简单,更新CR3页目录寄存器即可。
刚刚咱们不是说到,线程和进程的区别是,线程没有自己的地址空间嘛,就体现在这里了。线程此处的pg_addr为空。
2.更新0级特权栈寄存器。用户进程通过中断执行内核进程时,需要保存上下文,CPU为咱们提供了一套TSS机制(可以自己先去了解一下),咱们需要更新TSS的esp0.(没关系,这里不太了解也不打紧)
3.把将要下台的PCB的环境备份一下,同时从新的PCB中拿出他自己当时备份的环境,恢复回来。这就涉及到两个问题:什么环境需要保存呢?保存到哪里呢?
其实就是CPU的5个寄存器,包括esi edi ebx ebp,还有一个esp。那这些环境保存到哪里呢?这就看你咯,不管你保存到哪里,等恢复环境的时候咱们肯定得让人家知道对吧,也就是保存的地址要记录,于是PCB变成这样:

struct ProcessControlBlock
{
	uint8_t priority;		//多了一个优先级
	uint8_t ticks;	   // 每次在处理器上允许执行的时钟中断数
	uint8_t cur_ticks;	//定时中断每次将其+1
	uint32_t* pg_addr;	//进程页表的虚拟地址
	uint32_t* self_kstack;	//保存环境时的一些寄存器
	balabala........
}

我给大家举个例子好了。PCB0被定时中断打断,这个时候需要保护一次中断环境对吧,等定时中断执行完成了,肯定要还原,再回到PCB0.
可是好巧不巧的,定时中断干了一件这样的事情:它发现PCB0运行时间没了,该下台了。于是中断调用了调度函数。调度函数干了啥呢?(自己看看上面好吧)其实就是将当前运行的环境保存到一个栈里(这里肯定是中断的栈),然后将栈指针放到PCB0的self_kstack里。同时呢,调度函数将新的PCB1的环境恢复出来了,然后就去执行PCB1了。等于说,之前的环境被打断了(之前的环境是指,PCB0被中断打断,中断也被打断了)。然后经过很长时间,终于PCB0又得已重见天日,那还是会先恢复环境。啥环境呢?肯定是继续回到之前的定时中断呀。放心,调度函数是定时中断的最后一条执行语句,也就是说,恢复到定时中断后,就马上退出定时中断,回到PCB0继续执行(这一步恢复,是定时中断当时保存的环境恢复回来的)。
这样一来,一条完整的被动式的调度就完成了。至于主动调度,其实更简单,都没有中断掺和了。
这下总算是讲清楚了。

临界区

说白了就是一段访问公共资源的代码段,要保证同一时刻只能有有限个进程通过这段代码访问公共资源。

信号量与互斥锁

其实这俩在实现上根本就是一回事情,只是信号量semaphore呢,允许同时访问的资源量value= N,而mutex则只允许value=1,也就是只有一个进程可以访问。也就是说,咱们只要实现了semaphore,也就基本上实现了mutex。开整。
实现信号量的第一步是,实现thread_block()和thread_unblock()阻塞和非阻塞函数。阻塞函数是一种主动的行为,自己阻塞自己(肯定别人没这个权限的对吧,不然爆炸了呀),而非阻塞(或者叫唤醒函数)则是别人调用,来拯救被阻塞的PCB的。
OK上代码

void thread_block()
{
	schedule();
}

很简单对吧,就是自己调用调度函数,把自己整下台(但是这里没写下台到哪里哦,自然不是再次回到ready链表)
下面是唤醒函数:

void thread_unblock(PCBType aPcb)
{
	list_push(&thread_ready_list, &aPcb);
}

也就是说调度的本质是将阻塞的PCB重新放回到就绪队列中,给他机会继续去被调度。
有了这两个基本的操作,下面咱们实现semaphore的结构:

struct semaphore
{
	int value;
	list waiters;
}

value表示所剩资源数量,waiters链表就是阻塞到此semaphore的PCB队列了。thread_unblock也就是从这里把人拉出来放回ready队列中。
下面是重头戏了:
信号量有两个操作,即P操作和V操作。P操作是尝试获取信号量资源的操作,V操作是释放信号量资源的操作。上代码:

void sema_p(struct semaphore* psema)
{
	curPcb = getCurPcb();
	while(psema->value == 0)
	{
		list_append(psema->waiters, curPcb);
		thread_block();
	}
	//终于获取到资源了
	psema->value --;
}
void sema_v(struct semaphore* psema)
{
	if(is_empty(psema->waiters) == false)	//有人阻塞了
	{
		blockedPcb = list_pop(psema->waiters);
		thread_unblock(blockedPcb);
	}
	//归还资源
	psema->value ++;
}

需要你细品一下sema_p操作为啥搞了个while循环。因为被唤醒不代表就一定能获得资源,只是说,你又有了争夺资源的权利了,至于下一次有没有争取到,还是两说。
OK mutex我就不说了,其实没啥区别好吧,就默认设置value=1,就没了。

有趣的生产者-消费者问题

这个问题确实有趣,我来给大家掰扯掰扯。
食堂里,师傅做饭,饭呢放到一张大小有限的桌子上,然后一群学生排着队买饭。这就是一个典型的生产者-消费者场景:即生产者负责生产资源,消费者负责消费资源,且资源区大小有限,不能空了或者满了,不然没法消费和生产。
原则上:
1.多个生产者需要一个个排队将生产的东西放到台面上
2.多个消费者需要一个个排队去台面上消费东西
3.可以同时容纳台面上有一个生产者和一个消费者
4.台面上要是没东西了,获取了消费锁的消费者要把自己再挂起来(锁上加堵);每次消费完了,都得看看生产者有没有挂起来的,有的话就在走之前唤醒一下;
5.台面上要是东西满了,获取了生产锁的生产者就把自己再挂起来(锁上加堵);每次生产完了,都得看看消费者有没有挂起来的,有的话就在走之前唤醒一下;
6.这个台面咱们搞点简单的数据结构,环形队列听说过不?很简单的一个东西:在实际存储上,其实就是个线性数组,只不过咱们用了两个指针表示一前一后,这样就成功循环起来了。在这里插入图片描述

spinlock自旋锁

spin_lock和mutex两个都是互斥锁,不同的地方是spinlock是忙等待,不支持睡眠
mutex是可以睡眠,把当前等待mutex的task置于睡眠等待队列中,等mutex被释放之后再调度。
如果是临时的资源保护,那么我推荐使用spinlock。虽然会一直让cpu执行不断的检查操作,但是可以不用像mutex那样,直接保护上下文之后就睡了,这样开销很大的。
说白了spinlock其实就是对性能的调优,要是用错了还可能会发生错误。
为啥会发生错误呢?其实很简单,我举个例子:比如A线程里搞了个spinlock,然后在cpu上一直循环检测,终于等到了,于是执行了一会儿还没释放spinlock就睡了。下一个线程B登场,它也想获取这个spinlock,但是肯定获取不到,于是就一直占用cpu,这就等于是死锁了。
那么mutex会发生这样的事情么?当然不会呀,因为B线程争抢不到mutex的时候就会自己把自己sleep掉呀。

死锁

刚刚咱们介绍spinlock的时候讲了一个死锁的产生,那么还有其他的么?

死锁的概念

死锁是指多个进程循环等待彼此占有的资源而无限期的僵持等待下去的局面。原因是:
系统提供的资源太少了,远不能满足并发进程对资源的需求
进程推进顺序不合适,互相占有彼此需要的资源,同时请求对方占有的资源,往往是程序设计不合理。

死锁的产生条件

产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。
1.互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
2.不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
3.请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
4.循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。

如何预防死锁

1.银行家算法(关于银行家算法,我单独写一篇)
2.尽量少用spinlock
3.可以尝试将mutex托管给C++11的unique_lock(其实就像是智能指针那种感觉)

结束语

绕了一圈总算是讲完了同步问题,里面有很多细节需要去研究,靠你自己了,加油。

猜你喜欢

转载自blog.csdn.net/weixin_44039270/article/details/106556331