searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

并发编程问题源头和解决思路

2023-04-28 09:02:26
6
0

一、为什么会出现并发问题?

为了合理利用 CPU 的高性能,平衡CPU、内存和IO设备这三者的速度差异,在计算机体系结构、操作系统、编译程序等方面都做了许多优化,这些优化带来性能提升的同时,也带来了一些问题:

  1. CPU 增加了缓存,以均衡与内存的速度差异;但导致了以下问题:
    1. 导致可见性问题
      1. 两个cpu缓存看到的值不一致,写入内存时可能导致覆盖问题
  1. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
    1. 线程切换带来原子性问题
      1. 高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1,至少需要三条 CPU 指令。
        1. 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
        2. 指令 2:之后,在寄存器中执行 +1 操作;
        3. 指令 3:最后,将结果写入内存
      1. 操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。
  1. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
    1. 编译优化带来的有序性问题
      1. instance = new Singleton();
        1. 我们以为的 new 操作应该是:
          1. 分配一块内存 M;
          2. 在内存 M 上初始化 Singleton 对象;
          3. 然后 M 的地址赋值给 instance 变量。
        1. 但是实际上优化后的执行路径却是这样的:
          1. 分配一块内存 M;
          2. 将 M 的地址赋值给 instance 变量;
          3. 最后在内存 M 上初始化 Singleton 对象。

总结:只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。

二、并发问题的现象例子

1、可见性例子

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行add()操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

直觉告诉我们应该是 20000,因为在单线程里调用两次 add10K() 方法,count 的值就是 20000,但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢?

我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2

2、原子性例子

  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
    1. 线程切换带来原子性问题
      1. 高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
        1. 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
        2. 指令 2:之后,在寄存器中执行 +1 操作;
        3. 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
      1. 操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。

3、有序性例子

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}
    1. 编译优化带来的有序性问题
      1. instance = new Singleton();
        1. 我们以为的 new 操作应该是:
          1. 分配一块内存 M;
          2. 在内存 M 上初始化 Singleton 对象;
          3. 然后 M 的地址赋值给 instance 变量。
        1. 但是实际上优化后的执行路径却是这样的:
          1. 分配一块内存 M;
          2. 将 M 的地址赋值给 instance 变量;
          3. 最后在内存 M 上初始化 Singleton 对象
    1. 双重检查创建单例的异常执行路径

三、解决并发问题的方案

1、java内存模型-解决可见性和有序性问题

  • 解决思路
    • 那解决可见性、有序性最直接的办法就是禁用缓存和编译优化
  • 合理的方案
    • 应该是按需禁用缓存以及编译优化
    • Java 内存模型
      • 规范了 JVM 如何提供按需禁用缓存和编译优化的方法
      • 这些方法包括 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before 规则
  • volatile 关键字
    • 作用:
      • 并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存
    • 例子:
      • volatile int x = 0
      • 告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。
  • Happens-Before 规则
    • 作用:
      • Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见
      • 1. 程序的顺序性规则
        • 同一线程内,前一个变量的结果,对后续可见
      • 2. volatile 变量规则
        • 多线程下,线程1操作volatile的值,对后续线程可见
      • 3. 传递性
        • 多线程下,A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。v是volitile,根据这个传递性规则,我们得到结果:“x=42”
      • 4. 管程中锁的规则
        • synchronized 是 Java 里对管程的实现。
        • 多线程下,synchronized前面修改的值,对后面进来的值可见
      • 5. 线程 start() 规则
        • 它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
      • 6. 线程 join() 规则
        • 它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作

2、互斥锁-解决原子性问题

  • 原子性问题的源头是线程切换
    • 例子:32位系统,赋值long类型(8个字节64位)问题
      • 在 32 位 CPU 上执行写操作会被拆分成两次写操作
      • 如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现我们开头提及的诡异 Bug 了。明明已经把变量成功写入内存,重新读出来却不是自己写入的
  • 临界区
    • 我们把一段需要互斥执行的代码称为临界区
  • Java 语言提供的锁技术:synchronized
    • 将共享变量value封装起来,提供统一的访问路径(封装临界区)
  • 细粒度锁
    • 用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁
  • 使用锁的正确姿势
    • 很简单,只要我们的锁能覆盖所有受保护资源就可以了
  • “原子性”的本质是什么?
    • 其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见

四、参考

JSR 133 (Java Memory Model) FAQ

Java 内存模型

FAQJSR-133: JavaTM Memory Model and Thread Specification

Java 并发编程实战

0条评论
作者已关闭评论
q****n
20文章数
0粉丝数
q****n
20 文章 | 0 粉丝
q****n
20文章数
0粉丝数
q****n
20 文章 | 0 粉丝
原创

并发编程问题源头和解决思路

2023-04-28 09:02:26
6
0

一、为什么会出现并发问题?

为了合理利用 CPU 的高性能,平衡CPU、内存和IO设备这三者的速度差异,在计算机体系结构、操作系统、编译程序等方面都做了许多优化,这些优化带来性能提升的同时,也带来了一些问题:

  1. CPU 增加了缓存,以均衡与内存的速度差异;但导致了以下问题:
    1. 导致可见性问题
      1. 两个cpu缓存看到的值不一致,写入内存时可能导致覆盖问题
  1. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
    1. 线程切换带来原子性问题
      1. 高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1,至少需要三条 CPU 指令。
        1. 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
        2. 指令 2:之后,在寄存器中执行 +1 操作;
        3. 指令 3:最后,将结果写入内存
      1. 操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。
  1. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
    1. 编译优化带来的有序性问题
      1. instance = new Singleton();
        1. 我们以为的 new 操作应该是:
          1. 分配一块内存 M;
          2. 在内存 M 上初始化 Singleton 对象;
          3. 然后 M 的地址赋值给 instance 变量。
        1. 但是实际上优化后的执行路径却是这样的:
          1. 分配一块内存 M;
          2. 将 M 的地址赋值给 instance 变量;
          3. 最后在内存 M 上初始化 Singleton 对象。

总结:只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。

二、并发问题的现象例子

1、可见性例子

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行add()操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

直觉告诉我们应该是 20000,因为在单线程里调用两次 add10K() 方法,count 的值就是 20000,但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢?

我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2

2、原子性例子

  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
    1. 线程切换带来原子性问题
      1. 高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
        1. 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
        2. 指令 2:之后,在寄存器中执行 +1 操作;
        3. 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
      1. 操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。

3、有序性例子

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}
    1. 编译优化带来的有序性问题
      1. instance = new Singleton();
        1. 我们以为的 new 操作应该是:
          1. 分配一块内存 M;
          2. 在内存 M 上初始化 Singleton 对象;
          3. 然后 M 的地址赋值给 instance 变量。
        1. 但是实际上优化后的执行路径却是这样的:
          1. 分配一块内存 M;
          2. 将 M 的地址赋值给 instance 变量;
          3. 最后在内存 M 上初始化 Singleton 对象
    1. 双重检查创建单例的异常执行路径

三、解决并发问题的方案

1、java内存模型-解决可见性和有序性问题

  • 解决思路
    • 那解决可见性、有序性最直接的办法就是禁用缓存和编译优化
  • 合理的方案
    • 应该是按需禁用缓存以及编译优化
    • Java 内存模型
      • 规范了 JVM 如何提供按需禁用缓存和编译优化的方法
      • 这些方法包括 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before 规则
  • volatile 关键字
    • 作用:
      • 并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存
    • 例子:
      • volatile int x = 0
      • 告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。
  • Happens-Before 规则
    • 作用:
      • Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见
      • 1. 程序的顺序性规则
        • 同一线程内,前一个变量的结果,对后续可见
      • 2. volatile 变量规则
        • 多线程下,线程1操作volatile的值,对后续线程可见
      • 3. 传递性
        • 多线程下,A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。v是volitile,根据这个传递性规则,我们得到结果:“x=42”
      • 4. 管程中锁的规则
        • synchronized 是 Java 里对管程的实现。
        • 多线程下,synchronized前面修改的值,对后面进来的值可见
      • 5. 线程 start() 规则
        • 它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
      • 6. 线程 join() 规则
        • 它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作

2、互斥锁-解决原子性问题

  • 原子性问题的源头是线程切换
    • 例子:32位系统,赋值long类型(8个字节64位)问题
      • 在 32 位 CPU 上执行写操作会被拆分成两次写操作
      • 如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现我们开头提及的诡异 Bug 了。明明已经把变量成功写入内存,重新读出来却不是自己写入的
  • 临界区
    • 我们把一段需要互斥执行的代码称为临界区
  • Java 语言提供的锁技术:synchronized
    • 将共享变量value封装起来,提供统一的访问路径(封装临界区)
  • 细粒度锁
    • 用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁
  • 使用锁的正确姿势
    • 很简单,只要我们的锁能覆盖所有受保护资源就可以了
  • “原子性”的本质是什么?
    • 其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见

四、参考

JSR 133 (Java Memory Model) FAQ

Java 内存模型

FAQJSR-133: JavaTM Memory Model and Thread Specification

Java 并发编程实战

文章来自个人专栏
云技术专栏
20 文章 | 1 订阅
0条评论
作者已关闭评论
作者已关闭评论
0
0