并发的根因

 

并发问题变幻莫测,一谈到并发就显得非常高深,一般的程序员对于并发问题也是头疼不已,但是随着网络互联越来越普遍,大规模用户访问网站程序也越来越频繁,并发问题又无法避免。

在我们解决并发问题前首先要理解产生并发问题的根源是什么,所有并发处理的工具只是针对这些根源问题的其中一种解决方案,如果只去了解解决方案而不理解问题的根源是什么,那么我们就很难正确的定位问题并对症下药。所以要写好并发程序我们首先就要深入理解并发问题产生根源是什么?

起因:如何最大化的利用CPU

CPU运算速度和IO速度的不平衡一直是计算机优化的一个课题,我们都知道CPU运算速度要以百倍千倍程度快于IO的速度,而在进行任务的执行的时候往往都会需要进行数据的IO,正因为这种速度上的差异,所以当CPU和IO一起协作的时候就产生问题了,CPU执行速度非常快,一个任务执行时候大部分时间都是在等待IO工作完成,在等待IO的过程中CPU是无法进行其它工作的,所以这样就使得CPU的资源根本无法合理的运用起来。

CPU就相当于我们计算机的大脑,如何把CPU资源合理的利用起来就直接关系到我们计算机的效率和性能,所以为了这个课题计算机分别从缓存、任务切换、指令排序优化这几个方向进行了优化 。

一、进程和线程的产生

在最原始的系统里计算机内存中只能允许运行一个程序,这个时候的CPU的能力完全是过剩的,因为CPU在接收到一个任务之后绝大部分时间都是处在IO等待中,CPU根本就利用不起来,所以这个时候就需要一种同时运行多个程序的方法,这样的话当CPU执行一个任务IO等待的时候可以切换到另外一个任务上去执行指令,不必在IO上浪费时间,那么CPU就能很大程度的利用起来,所以基于这种思路就产生了进程和线程。

有了进程后,一个内存可以划分出不同的内存区域分别由多个进程管理,当一个进程IO阻塞的时候可以切换到另外一个进程执行指令,为了合理公平的把CPU分配到各个进程,CPU把自己的时间分为若干个单位的片段,每在一个进程上执行完一个单位的时间就切换到另外一个进程上去执行指令,这就是CPU的时间片概念。有了进程后我们的电脑就可以同时运行多个程序了,我们可以一边看着电影一边聊天,在CPU的利用率又进一步提升了CPU的利用率。

因为进程做任务切换需要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了,现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

并发问题根源之一:CPU切换线程执导致的原子性问题

首先我们先理解什么叫原子性,原子性就指是把一个操作或者多个操作视为一个整体,在执行的过程不能被中断的特性叫原子性。

因为IO、内存、CPU缓存他们的操作速度有着巨大的差距,假如CPU需要把CPU缓存里的一个变量写入到磁盘里面,CPU可以马上发出一条对应的指令,但是指令发出后的很长时间CPU都在等待IO的结束,而在这个等待的过程中CPU是空闲的。

所以为了提升CPU的利用率,操作系统就有了进程和时间片的概念,同一个进程里的所有线程都共享一个内存空间,CPU每执行一个时间段就会切换到另外一个进程处理指令,而这执行的时间长度是是以时间片(比如每个时间片为1毫秒)为单位的,通过这种方式让CPU切换着不同的进程执行,让CPU更好的利用起来,同时也让我们不同的进程可以同时运行,我们可以一边操作word文档,一边用QQ聊天。

后来操作系统又在CPU切换进程执行的基础上做了进一步的优化,以更细的维度“线程”来切换任务执行,更加提高了CPU的利用率。但正是这种CPU可以在不同线程中切换执行的方式会使得我们程序执行的过程中产生原行性问题。

比如说我们以一个变量赋值为例:

语句1:Int number=0;

语句2:number=number+1;

在执行语句2的时候,我们的直觉number=number+1 是一个不可分割的整体,但是实际CPU操作过程中并非如此,我们的编译器会把number=number+1 拆分成多个指令交给CPU执行。

number=number+1的指令可能如下:

指令1:CPU把number从内存拷贝到CPU缓存。

指令2:把number进行+1的操作。

指令3:把number回写到内存。

在这个时候如果有多线程同时去操作number变量,就很有可能出现问题,因为CPU会在执行上面任何一个指令的时候切换线程执行指令,这个时候就可能出现执行结果与我们预期结果不符合的情况。

比如如果现在有两个线程都在执行number=number+1,结果CPU执行流程可能会如下:

执行细节:

1、CPU先执行线程A的执行,把number=0拷贝到CUP寄存器。

2、然后CPU切换到线程B执行指令。

3、线程B 把number=0拷贝到CUP寄存器。

4、线程B 执行number=number+1 操作得到number=1。

5、线程B把number执行结果回写到缓存里面。

6、然后CPU切换到线程A执行指令。

7、线程A执行number=number+1 操作得到numbe=1。

8、线程A把number执行结果回写到缓存里面。

9、最后内存里面number的值为1。


二、高速缓存的产生

为了减少CPU等待IO的时间,让CPU有更多的时间是花在运算上,最简单的思路就是减少IO等待的时间,基于这个思路所以就有了高速缓存增加了高速缓存(L1,L2,L3,主存)。

在计算机系统中,CPU高速缓存是用于减少处理器访问内存所需的时间,其容量远小于内存,但其访问速度却是内存IO的几十上百倍。当处理器发出内存访问请求时,会先查看高速缓存内是否有请求数据。如果存在(命中),则不需要访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。

并发问题根源之二:缓存导致的可见性问题

在有了高速缓存之后,CPU的执行操作数据的过程会是这样的,CPU首先会从内存把数据拷贝到CPU缓存区。

然后CPU再对缓存里面的数据进行更新等操作,最后CPU把缓存区里面的数据更新到内存。

磁盘、内存、CPU缓存会按如下形式协作。

缓存导致的可见性问题就是指我们在操作CPU缓存过程中,由于多个CPU缓存之间独立不可见的特性,导致共享变量的操作结果无法预期。

在单核CPU时代,因为只有一个核心控制器,所以只会有一个CPU缓存区,这时各个线程访问的CPU缓存也都是同一个,在这种情况一个线程把共享变量更新到CPU缓存后另外一个线程是可以马上看见的,因为他们操作的是同一个缓存,所以他们操作后的结果不存在可见性问题。

而随着CPU的发展,CPU逐渐发展成了多核,CPU可以同时使用多个核心控制器执行线程任务,当然CPU处理同时处理线程任务的速度也越来越快了,但随之也产生了一个问题,多核CPU每个核心控制器工作的时候都会有自己独立的CPU缓存,每个核心控制器都执行任务的时候都是操作的自己的CPU缓存,CPU1与CPU2它们之间的缓存是相互不可见的。

这种情况下多个线程操作共享变量就因为缓存不可见而带来问题,多线程的情况下线程并不一定是在同一个CUP上执行,它们如果同时操作一个共享变量,但因为在不同的CPU执行所以他们只能查看和更新自己CPU缓存里的变量值,线程各自的执行结果对于别的线程来说是不可见的,所以在并发的情况下会因为这种缓存不可见的情况会导致问题出现。

比如下面的程序:

两个线程同时调用addNumber() 方法对number属性进行+1 ,循环10W次,等两个线程执行结束后,我们的预期结果number的值应该是20000,可是我们在多核CPU的环境下执行结果并非我们预期的值。

 public class TestCase {
 ​
  private  int number=0;
 ​
  public void addNumber(){
  for (int i=0;i<100000;i++){
  number=number+1;
         }
 ​
     }
 ​
  public static void main(String[] args) throws Exception {
         TestCase testCase=new TestCase();
          Thread threadA=new Thread(new Runnable() {
  @Override
  public void run() {
  testCase.addNumber();
              }
          });
 ​
         Thread threadB=new Thread(new Runnable() {
  @Override
  public void run() {
  testCase.addNumber();
             }
         });
         threadA.start();
         threadB.start();
         threadA.join();
         threadB.join();
        System.out.println("number="+testCase.number);
     }
 }

打印结果:


三、指令优化

进程和线程本质上是增加并行的任务数量来提升CPU的利用率,缓存是通过把IO时间减少来提升CPU的利用率,而指令顺序优化的初衷的初衷就是想通过调整CPU指令的执行顺序和异步化的操作来提升CPU执行指令任务的效率。

指令顺序优化可能发生在编译、CPU指令执行、缓存优化几个阶,其优化原则就是只要能保证重排序后不影响单线程的运行结果,那么就允许指令重排序的发生。其重排序的大体逻辑就是优先把CPU比较耗时的指令放到最先执行,然后在这些指令执行的空余时间来执行其他指令,就像我们做菜的时候会把熟的最慢的菜最先开始煮,然后在这个菜熟的时间段去做其它的菜,通过这种方式减少CPU的等待,更好的利用CPU的资源。

并发问题根源之三:指令优化导致的重排序问题

下面的程序代码如果init()方法的代码经过了指令重排序后,两个方法在两个不同的线程里面调用就可能出现问题。

  private static int value;
     private static boolean flag;
 ​
     public static  void  init(){
         value=8;     //语句1
         flag=true;  //语句2
     }
 ​
     public static void getValue(){
         if(flag){
             System.out.println(value);
         }
     }

根据上面代码,如果程序代码运行都是按顺序的,那么getValue() 中打印的value值必定是等于8的,不过如果init()方法经过了指令重排序,那么结果就不一定了。根据重排序原则,init()方法进行指令重排序重排序后并不会影响其运行结果,因为语句1和语句2之间没有依赖关系。 所以进行重排序后代码执行顺序可能如下。

  flag=true;  //语句2  
  value=8;     //语句1

如果init()方法经过了指令重排序后,这个时候两个线程分别调用 init()和getValue()方法,那么就有可能出现下图的情况,导致最终打印出来的value数据等于0。

猜你喜欢

转载自blog.csdn.net/heijunwei/article/details/113058940