宏观理解“临界区”和原子性解决方案!(Sychronized锁)

宏观理解“临界区”和原子性解决方案!(Sychronized锁)

首页角色扮演临界仙魔录更新时间:2024-05-04
原子性问题溯源

原⼦性问题的源头就是 线程切换,但在多核 CPU 的⼤背景下,不允许线程切换是不可能的,正所谓「魔⾼⼀尺,道⾼⼀丈」,新规矩来了:

互斥:同⼀时刻只有⼀个线程执行

实际上,上面这句话的意思是:对共享变量的修改是互斥的,也就是说线程 A 修改共享变量时其他线程不能修改,这就不存在操作被打断的问题了,那么如何实现互斥呢?

对并发有所了解的⼩伙伴⻢上就能想到 这个概念,并且你的第一反应很可能就是使⽤ synchronized,这⾥列出来你常⻅的 synchronized 的三种⽤法:

public class ThreeSync { private static final Object object = new Object(); public synchronized void normalSyncMethod(){ //临界区 } public static synchronized void staticSyncMethod(){ //临界区 } public void syncBlockMethod(){ synchronized (object){ //临界区 } } }

三种 synchronized 锁的内容有⼀些差别:

我特意在三种 synchronized 代码⾥⾯添加了「临界区」字样的注释,那什么是临界

区呢?

临界区: 我们把需要互斥执⾏的代码看成为临界区

说到这里,和大家串的知识都是表层认知,如何用锁保护有效的临界区才是关键,这直接关系到你是否会写出并发的 bug,了解过本章内容后,你会发现无论是隐式锁/内置锁 (synchronized) 还是显示锁 (Lock) 的使⽤都是在找寻这种关系,关系对了,⼀切就对了,且看上⾯锁的三种⽅式都可以⽤下图来表达:

线程进入临界区之前,尝试加锁 lock(),加锁成功,则进⼊临界区(对共享变量进行修改),持有锁的线程执⾏完临界区代码后,执行 unlock(),释放锁。针对这个模型,大家经常用抢占厕所坑位来形容:

在学习 Java 早期我就是这样记忆与理解锁的,但落实到代码上,我们很容易忽略两点:

将这两句话联合起来就是你的锁能否对临界区的资源起到保护的作⽤?所以我们要将上⾯的模型进一步细化

现实中,我们都知道⾃⼰的锁来锁⾃⼰需要保护的东⻄ ,这句话翻译成你的⾏动语⾔之后你已经明确知道了:你锁的是什么? 你保护的资源是什么?

CPU 可不像我们⼤脑这么智能,我们要明确说明我们锁的是什么,我们要保护的资源是什么,它才会⽤锁保护我们想要保护的资源(共享变量)

拿上图来说,资源 R (共享变量) 就是我们要保护的资源,所以我们就要创建资源 R的锁来保护资源 R,细⼼的朋友可能发现上图⼏个问题:

LR 和 R 之间有明确的指向关系 我们编写程序时,往往脑⼦中的模型是对的,但是忽略了这个指向关系,导致⾃⼰的锁不能起到保护资源 R 的作⽤(⽤别⼈家的锁保护⾃⼰家的东⻄或⽤⾃⼰家的锁保护别⼈家的东⻄),最终引发并发bug,所以在你勾画草图时,要明确找到这个关系

左图 LR 虚线指向了⾮共享变量 我们写程序的时候很容易这么做,不确定哪个是要保护的资源,直接⼤杂烩,⽤ LR 将要保护的资源 R 和没必要保护的⾮共享变量⼀起保护起来了,举两个例⼦来说你就明⽩这么做的坏处了

  1. 编写串⾏程序时,是不建议 try...catch 整个⽅法的,这样如果出现问是很难定位的,道理⼀样,我们要⽤锁精确的锁住我们要保护的资源就够了,其他⽆意义的资源是不要锁的
  2. 锁保护的东⻄越多,临界区就越⼤,⼀个线程从⾛⼊临界区到⾛出临界区的时间就越⻓,这就让其他线程等待的时间越久,这样并发的效率就有所下降,其实这是涉及到锁粒度的问题。

作为程序猿还是简单拿代码说明⼀下⼼⾥⽐较踏实,且看:

public class ValidLock { private static final Object object = new Object(); private int count; public synchronized void badSync(){ //其他与共享变量count⽆关的业务逻辑 count ; } public void goodSync(){ //其他与共享变量count⽆关的业务逻辑 synchronized (object){ count ; } } }

这⾥并不是说 synchronized 放在⽅法上不好,只是提醒⼤家⽤合适的锁的粒度才会更⾼效

在计数器程序例⼦中,我们会经常这么写:

public class SafeCounter { private int count; public synchronized void counter(){ count ; } public synchronized int getCount(){ return count; } }

下图就是上⾯程序的模型展示:

这⾥我们锁的是 this,可以保护 this.count。但有些同学认为 getCount ⽅法没必要加 synchronized 关键字,因为是读的操作,不会对共享变量做修改,如果不加上synchronized 关键字,就违背了 happens-before 规则中的监视器(不知道看我合集文章,有专门讲到happens-before)

锁规则:

对⼀个锁的解锁 happens-before 于随后对这个锁的加锁也就是说对 count 的写很可能对 count 的读不可见,也就导致脏读(一个事务读到另外一个事务还没有提交的数据,称之为脏读。)

上⾯我们看到⼀个 this 锁是可以保护多个资源的,那⽤多个不同的锁保护⼀个资源可以吗?来看⼀段程序:

public class UnsafeCounter { private static int count; public synchronized void counter(){ count ; } public static synchronized int calc(){ return count ; } }

仔细看,⼀个锁的是 this,⼀个锁的是 UnsafeCounter.class, 他们都想保护共享变量 count,你觉得如何?下图就是程序的模型展示:

两个临界区是⽤两个不同的锁来保护的,所以临界区没有互斥关系,也就不能保护count,所以这样加锁是⽆意义的。

总结:
  1. 解决原⼦性问题,就是要互斥,就是要保证中间状态对外不可⻅
  2. 锁是解决原⼦性问题的关键,明确知道我们锁的是什么,要保护的资源是什么,更重要的要知道你的锁能否保护这个受保护的资源(图中的箭头指向)
  3. 有效的临界区是⼀个⼊⼝和⼀个出⼝,多个临界区保护⼀个资源,也就是⼀个资源有多个并⾏的⼊⼝和多个出⼝,这就没有起到互斥的保护作⽤,临界区形同虚设
  4. 锁⾃⼰家⻔能保护资源就没必要锁整个⼩区,如果锁了整个⼩区,这严重影响其他业主的活动(锁粒度的问题)
,
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved