探索线程安全性背后的可见性、伪共享、缓存一致性协议MESI

一、前言

有没有很多同学在学习多线程并发环节的时候,针对synchronized,volatile关键字的学习,感觉很枯燥,很多时候纯粹为了应付面试,当面试问底层原理的时候,依旧比较懵逼,立马可以识别出来初级成员和高级程序员那道鸿沟;当然博主本人当时也这么困惑过,好在自己没有放弃,坚持学习,希望今天的分享,能够给大家打开一扇窗。

二、计算机架构

1、程序猿的内在修为:

我记得刚毕业从事程序猿那会,总觉得在学校学的内容一点用处都没有,但是随着学习的深入,计算机网络,计算机原理等等知识逐渐走进大家的视野,例如学习socket编程涉及到的TCP/IP通信,OSI7层模型;学习MySQ存储引擎时,涉猎到的二叉树,平衡二叉树,B树,B+树等等,其实不是学校的知识没有用,是因为咱们修炼的level没有达到预期的等级,这些就像内功心法。

这里带大家学习一下,多线程并发情况下,多核处理器,如何解决原子性、可见性、指令重排等等相关问题;线性的调度涉及CPU时间片的切换,需要从主内存中,加载指令计算需要用到的存储变量的值。

2、计算机的高速缓存:

通常大家的印象中,计算机主机有以下几个组成部分(CPU、内存、磁盘),CPU计算的数据需要从内存中加载,但是CPU的速度比内存快了N倍,为了解决性能上的差异,在CPU和DRM内存之间架设了一层高速缓存,通常包含三个层次L1层距离CPU最近,包含指令缓存和数据缓存。L1和L2都有CPU专有,L3是CPU之间公有。

打开任务管理器,可以看到大家机器的L1到L3的缓存大小:

3、多线程并发下的可见性问题:

熟悉Mysql的事务管理的同学,应该记得事务隔离级别有四种,读未提交、读已提交、不可重复读、串行读。Mysql针对并发线程对共享数据访问,采用MVCC的策略,来保障数据的一致性;在计算机多核的年代,共享数据被多个CPU加载访问,又是如何保障原子性、可见性问题。可见性问题其实就是如何保障共享数据的一致性问题。

所谓原子性,即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子性就像数据库里面的事务一样,他们是一个团队,同生共死。

所谓可见性,是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

扫描二维码关注公众号,回复: 13594008 查看本文章

 

在多线程并发中,我们为了保证原子操作,可以采用给方法或者代码块加锁的方式来落地,关键字是synchronized,但是可见性问题,咱们又是如何保障的呢???

我们用一个demo来测试一下可见性问题:

package com.jason.test;

public class TestMain {
    public volatile static boolean flag=false;//volatile 可以控制变量的可见性、以及指令重排
    public static void main(String[] args) throws InterruptedException {


            Thread  t1=new Thread("thread1--"){
                @Override
                public void run() {
                    int pointer=1;
                    while(!flag)
                    {
                        pointer++;
                        //System.out.println(Thread.currentThread().getName()+"---"+pointer++);
                        try {
                            sleep(5000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            };
            t1.start();
            Thread.sleep(100);
            flag=true;
            t1.join();
            System.out.println("main结束");
        }
    }

在上面的代码片段中,如果没有volatile修饰flag,当main线程修改变量的值时,线程中while(!flag),一直取的是CPU高速缓存中的值,其实当前的变量已经改变。

3.1、MESI一致性协议:

通过上面的demo示例,我们大概能意识到可见性问题的根本,就是主内存和CPU高速缓存中的数据不一致,导致程序的运行结果并不是咱们想要的结果,当然我们意识到这个问题,肯定是有通用的解决方案,继续踩在巨人的肩膀上学习一下计算机解决缓存一致性的方案,目前市面上主要是MESI:

     M表示修改

     E表示独占

     S表示共享

      I表示失效

3.2、缓存行(cache line):

在解密MESI工作原理的时候,先给大家带来一个概念,缓存行(cache line),它是 cache 和 RAM 交换数据的最小单位,通常为 64 Bytes。

CPU 访问内存时,首先查询 cache 是否已缓存该数据。如果有,则返回数据,无需访问内存;如果不存在,则需把数据从内存中载入 cache,最后返回给处理器。在处理器看来,缓存是一个透明部件,旨在提高处理器访问内存的速率,所以从逻辑的角度而言,编程时无需关注它,但是从性能的角度而言,理解其原理和机制有助于写出性能更好的程序。Cache 之所以有效,是因为程序对内存的访问存在一种概率上的局部特征:

Spatial Locality:对于刚被访问的数据,其相邻的数据在将来被访问的概率高。
Temporal Locality:对于刚被访问的数据,其本身在将来被访问的概率高。

3.3、缓存行(cache line)的伪共享问题:

计算机在解决缓存一致性问题,对共享数据的修改采用缓存行加锁的方式来解决一致性,淘汰总线加锁的低效的工作方式。当一条缓存行碰巧存了CPU1,CPU2运算用到的存储数据,这时就存在互相竞争资源的情况,我们称这样的情景为缓存的伪共享。

伪共享的问题,前人又是如何优化解决这样的性能低下的问题的呢?

解决方案:

一、为了避免由于 false sharing 导致 Cache Line 从 L1,L2,L3 到主存之间重复载入,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine,这样可以确保一个缓存行中的数据只会归属到一个CPU。这本质是一种空间换时间的做法。

在java 8 中已经提供了官方的解决方案,Java 8 中新增了一个注解: @sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在 jvm 启动时设置 -XX:-RestrictContended 才会生效。

3.4、MESI一致性协议流程:

流程说明:

1、CPU1数据读取:
    A、CPU1发出一条指令,从主内存读取flag
    B、CPU1从主内存通过消息总线读取flag信息到CPU1的高速缓存,并且设置缓存行的状态为E(独占)
   
2、CPU2数据读取:   
   A、CPU2发出一条指令,从主内存读取flag
   B、CPU2试图从主内存中读取flag时,CPU1检查到了地址冲突,这是CPU1对相关数据做出响应,flag数据缓存在CPU1,CPU2中,缓存行的状态为S(共享)
   
如果到此为止,也就没有必要存在MESI的协议了, 好戏开始,如何在后续的运算中,保持CPU1,CPU2任何一方修改数据,另一方保持对数据的可见性??


3、CPU2尝试修改flag=true(参考上面的demo,main 线程修改数据)
   A、CPU2发出修改指令,修改flag的值
   B、CPU2将缓存行的状态设置为M,并通知了缓存了flag的CPU1,CPU1将本地高速缓存中,缓存了flag的缓存行设置为I(失效)
   C、CPU2将flag改为false

4、CPU1数据读取:
    A、在上述的demo案例中,cpu1循环使用flag变量,决定是否继续循环
    B、CPU1发出读取flag的指令
    C、CPU1通知CPU2,CPU2将修改后的数据同步到主内存时,CPU1与CPU2中flag的缓存行状态改为S.    

3.5、高速缓存的替换策略:

通过任务管理器,看到的三个level,每个Level的缓存容量是有限的,当缓存容易已满,类似redis缓存打满后的替换策略,CPU高速缓存也有类似的替换算法:

1、随机算法

2、FIFO-先进先出算法

3、LFU-最不经常使用算法

     优先淘汰最不经常使用的字块,因为他要识别最不常使用的字块,所以需额外空间记录字块的使用频率,内存紧张。

4、LRU-最近最少使用算法:

     使用双向链表实现优先淘汰一段时间内没有使用的字块,把当前的访问节点置于链表最前面,就需要重新排序,是看最近一次使用的字块而LFU是看一段时间内最不经常使用的字块。   

猜你喜欢

转载自blog.csdn.net/jason_jiahongfei/article/details/117093873