一文带你彻底理解同步和锁的本质(干货)
谈到锁,离不开多线程,或者进程间的通信。 为了更好地从底层原理去了解锁的机制,形成体系化的知识,这篇文章我会从进程间通信底层原理说起,然后介绍一下Java中各种线程通信的实现机制,最后做一个系统的总结。
还记得上次跟你撕逼内存模型的那个人吗,他又来了,并且向你甩出了一堆问题:
1、为什么需要通信
1.1、竞态条件
我们知道,在操作系统中,互相协作的进程之间可能共享一些彼此都能读写的公共存储区,假设两个进程都需要改写这个公共的存储区那么就会产生竞争关系了。
下面举个例子
假设两个进程a和b共享一个脱机目录,脱机目录中有许多槽位,free记录了下一个空的槽位,进程可以往下一个空槽位中写入内容。
进程a准备往下一个空槽位写入内容"test",进程b准备往下一个空槽位写入内容“good”。
我们来分析下极端情况:
可以发现,由于发生了时钟中断,两个进程都往槽位3写入了内容,进程b的内容被进程a的内容覆盖掉了。
像这种由于两个或者多个进程读写某些共享数据,最后结果取决于进程运行的精确时序,称为竞态条件
。
为了避免这种竞态条件
的出现,就需要找出存在这种竞态条件
的程序片段,通过互斥
的手段来阻止多个进程同时读写共享的数据。
1.2、临界区
对共享内存进行访问的程序片段称为临界区
。
为了实现互斥而选择适当的原语是任何操作系统的主要涉及内容之一。 后面我们会详细讨论各种实现互斥的手段,这些手段也是实现进程通信或者线程通信的技术基础。
还是以上面的例子来说明,为了避免竞态条件
的产生,我们需要把获取空槽位和往槽位写内容的程序片段作为一个临界区,任何不同的进程,不可以在同一个时刻进入这个临界区:
如上图,进程b试图在a离开临界区之前进入临界区,会进入不了,导致阻塞,通常表现的行为为: 进程挂起或者自旋等待。
为了实现这种临界区的互斥,需要进程之间能够像对话一样,确认是否可以进入临界区执行代码,这种对话即进程通信
。 有很多经典的处理方法,下面我们就逐个的来介绍。
2、常见的实现进程通信的手段
2.1、忙等待的互斥(自旋等待)
所谓忙等待,指的是进程自己一直在循环判断是否可以获取到锁了,这种循环也称为自旋
。 下面我们通过屏蔽中断
和锁变量
的介绍,依次引出忙等待的相关互斥手段方法。
2.1.1、屏蔽中断
如下图,在进程进入临界区之前,调用local_irq_disable
宏来屏蔽中断,在进程离开临界区之后,调用local_irq_disable
宏来使能中断。
CPU只有发生时钟中断或其他中断才会进行进程切换,也就是说,屏蔽中断后,CPU不会切换到其他进程。但是,这仅仅对执行disable的那个CPU有效,其他CPU仍将继续运行,也就是说多核处理器这种手段无效。
另外,这个屏蔽中断是用户进程触发的,如果用户进程长时间没有离开临界区,那就意味着中断一直启用不了,最终导致整个系统的终止。
由此可见,在这个多核CPU普及的时代,屏蔽中断并不是实现互斥的良好手段。
2.1.2、锁变量
上面一种硬件的解决方案,既然硬件解决不了,那么我们尝试通过软件层面的解决方案去实现。 我们添加一个共享锁变量,变量为0,则表示可进入临界区,进入之后,设置为1,离开临界区重置为0,如下图所示:
但是由于对Lock的check和set是分为两步,并非原子性的,那么可能会出现如下情况:
也就是说在进程a把Lock设置为1之前,b就进行check和set操作了,也获取到了Lock=0,导致两个进程同时进入了临界区。
这种非原子性的检查并设置锁操作还是会存在竞态条件,并不能作为互斥的解决方案。
接下来我们升级一下程序,为了避免这种竞态条件
,我们让进程间严格轮换的方式去争抢使用Lock的机会。
2.1.3、严格轮换法
所谓严格轮换法,就是指定一个标识位turn
,当turn=0的时候让进程a进入临界区,当turn=1的时候,让进程b进入临界区。 以下是实现代码:
1// 进程a 2while(TRUE){ 3 while(turn != 0); /* 循环测试turn,看其值何时变为0 */ 4 critical_region(); /* 进入临界区 */ 5 turn = 1; /* 让给下一个进程处理 */ 6 noncritical_region(); /* 离开临界区 */ 7} 8// 进程b 9while(TRUE){10 while(turn != 1); /* 循环测试turn,看其值何时变为1 */11 critical_region(); /* 进入临界区 */12 turn = 0; /* 让给下一个进程处理 */13 noncritical_region(); /* 离开临界区 */14}复制代码
这种方法可能导致在循环中不停的测试turn,这称为
忙等待
,比较浪费CPU,只有有理由认为等待时间是非常短的情形下,才使用忙等待
,用于忙等待的锁,称为自旋锁(spin lock)。
假设现在进程a在临界区里面,并且执行了turn=1,准备把临界区轮换给进程b,但是这个时候进程b正在处理其他事情,那么这个临界区就一直被进程b阻塞了。 进程a想重新进入也需要等待。
也就是说,我唱完一首歌,把麦给了你,轮到你唱,这个时候你拿着麦去上厕所了。 那么我想唱歌,也只能等到你上完厕所,唱完歌,把麦的使用权交接给我,我才可以继续唱。
你上厕所竟然影响到了我唱歌,就是所谓的临界区外运行的进程阻塞了其他想进入临界区的进程。
看来这种解决方案并不是一个很好的