美文网首页
并发编程—原子性问题是怎么解决的

并发编程—原子性问题是怎么解决的

作者: 瞎胡扯1 | 来源:发表于2020-11-28 08:23 被阅读0次

一、概述

在《并发编程—可见性、原子性、有序性 BUG源头》一章中我们提到了,一个或者多个操作在CPU执行过程中不被中断的特性称之为“原子性”,而其中原子性的源头又是线程切换,如果能够禁止线程切换那不就可以解决原子性问题了吗?而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就可以禁止线程切换了。

在早期的单核CPU时代,这个方案是可行的,而且也有很多应用案例,但在多核场景就不适用了。在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以多个操作一定是:要么都被执行,要么都不被执行,具有原子性。

但是在多核场景下,同一时刻,有可能有两个线程在不同的CPU核心上执行,比如线程A在CPU-1上执行,线程B在CPU-2上执行,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,所有也就不能保证原子性,诡异的Bug还会存在。

同一时刻只有一个线程执行” 这个我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就能保证原子性了。谈到互斥,我们首先想到的应该就是杀手级方案:锁。我们把需要互斥执行的代码成为临界区。线程进入临界区之前,首先尝试加锁,如果加锁成功,则进入临界区,此时这个线程就持有锁;如果加锁失败,就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁。如下图示:

image

<figcaption contenteditable="true" data-cke-widget-editable="caption" data-cke-enter-mode="2" data-cke-filter="17018" class="cke_widget_editable" data-cke-display-name="标题">加锁简易模型</figcaption>

image.gif

二、Java中的锁技术:synchronized关键字

1、synchronized关键字简介

锁是一种通用技术方案,Java语言中提供了 synchronized关键字示锁。

synchronized关键字可以用来修饰方法,也可用来修饰代码块。如下所示:

public class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  
image.gif

看到上面代码示例,可能觉得有点怪,这和我们说的模型对不上号啊,他的加锁 lock 和解锁 unlock在哪里呢?其实这两个操作都是Java帮我们默默加上了,Java编译器在编译的时候会在synchronized修饰的方法或代码块前后自动加上加锁 lock 和 解锁 unlock 操作,这样做的好处就是能够保证 加锁 lock 和解锁 unlock 能够成对出现,毕竟如果忘记解锁 unlock 会导致致命的Bug(意味着其他线程将会死等下去)。

上面代码我们只看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:

❑ 对于普通同步方法,锁是当前实例对象。

❑ 对于静态同步方法,锁是当前类的Class对象。

❑ 对于同步方法块,锁是Synchonized括号里配置的对象。

对于上面的例子,修饰静态方法相当于:

class X {
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}
image.gif

非静方法相当于:

class X {
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}
image.gif

2、用synchronized解决 count+=1 问题

我们在前面的章节中提到过,类似于 i++, i+=1 这样的操作是非原子性的,那么我们使用 synchronized 来小试牛刀一把。如下代码所示:

class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}
image.gif

我们看到 addOne() 方法,首先可以肯定的是,被synchronized修饰后,无论是单核CPU还是多核CPU有,同一时刻只能有一个线程执行 addOne() 方法,所有保证了原子性,那么可见性呢?根据上一章提到的监视器规则

对一个锁的解锁,happens-before于随后对这个锁的加锁。

可知又能保证可见性的。addOne() 方法通过 synchronized 关键字加锁后既保证了原子性,有保证了可见性。那么 get() 方法又有没有加锁就不能保证了,要想解决需要给 get() 方法也加上锁。代码如下所示:

class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}
image.gif

三、锁和受保护资源的关系

受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?一个合理的关系是:受保护资源和锁之间的关系时 N:1 的关系。也就是一个锁可以保护多个资源,就像我们现实生活中的抢坑位一样,一个坑位只能有一个门,一把锁,如果锁某个坑位有两个们分别两把锁锁着,那就回出问题了。

还是上面的代码如果改成如下所示:

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}
image.gif

把 addOne() 改成了静态方法,由上文,我们可知静态方法的锁为 SafeCalc.class, 而 get() 方法的锁为 this,两把锁同时保护 value 这个共享资源,那么就不能保证并发问题了。

四、总结

互斥锁,在并发领域的知名度很高,只要有了并发问题,大家首先想到的就是加锁,因为大家知道,加锁能够保证临界区代码的互斥性。只有深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。

synchronized 是 Java 在语言层面提供的互斥原语,其实 Java 里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。

参考:

      [Java并发编程实战](https://time.geekbang.org/column/article/84344)

      [并发编程之synchronized原理分析](http://yby.ink/?p=16)​​​​​​​

相关文章

网友评论

      本文标题:并发编程—原子性问题是怎么解决的

      本文链接:https://www.haomeiwen.com/subject/clvxwktx.html