volatile关键字

一、volatile关键字

    1、一开始我是真的一无所知对于这个关键字。看完链接【http://www.importnew.com/29860.html】讲解如下:

    2、首先介绍volatile关键字之前需要了解很多的硬件内存,cpu知识【有表达不准确的赶紧告诉我哦,大佬们】,以及内存知识库中的一角。

    3、讲解要领:从硬件的角度来理解什么是内存屏障,以及内存屏障如何让volatile工作,说明了在多线程中,如何使用volatile来提升性能

    4、CPU的多级缓存结构中,最快的是store buffer和invalide queues,并且这两个是每个CPU私有的。所以会出现一些调度问题。CPU读取数据是以行的形式载入,成为缓存行(cacheline),对于多核CPU如果CPU1需要读取的数据在其他CPU中,则这些CPU之间会使用MESI缓存一致性协议来进行协调。以保证数据一致性。而store buffer的引入是为了不浪费CPU的使用率,满足CPU1在发出了read invalidete消息后,等待其他CPU返回read response 和 invalidate ack消息时,CPU继续做其他事情。但是引入以后对于单核的CPU又会出现数据读取错误情况,导致程序错误,例如模拟1:

模拟1:

1

2

3

a = 1;

b = a+1;

assert b==2;

假设a不在本cpu的cacheline中。在其他cpu的cacheline中,值为0.会有如下的步骤

序号操作内容
1发现a的地址不在本cpu的cacheline中,向其他的cpu发送read invalidate消息
2将数据写入store buffer中
3收到其他cpu响应的read response和invalidate ack消息
4执行b=a+1,因为a这个时候已经在cacheline中,读取到值为0,加1后为1,写入到b中
5执行assert b==2 失败,因为b是1
6store buffer中的值刷新到a的cacheline中,修改a的值为1,但是已经太晚了

    5、为了避免这个问题,所以对于store buffer的设计中增加一个策略叫做store forwarding。就是说cpu在读取数据的时候会先查看store buffer,如果store buffer中有数据,直接用store buffer中的。这样,也就避免了使用错误数据的问题了。
store forwarding可以解决在单线程中的数据不一致问题,但是store buffer所带来的复杂性远不止如此。在多线程环境下,会有其他的问题。下面是模拟代码2:

    模拟2:

1

2

3

4

5

6

7

8

9

public void set(){

  a=1;

  b=1;

}

public void print(){

 while(b==0)

 ;

 assert a==1;

}

假设a和b的值都是0,其中b在cpu0中,a在cpu1中。cpu0执行set方法,cpu1执行print方法。

序号cpu0的步骤(执行set)cpu1的步骤(执行print)
1想写入a=1,但是由于a不在自身的cacheline中,向cpu1发送read invalidate消息执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息
2向store buffer中写入a=1等待cpu0响应的read response消息
3b在自身的cacheline中,并且此时状态为M或者E,写入b=1等待cpu0响应的read response消息
4收到cpu1的read请求,将b=1的值用read response消息传递,同时将b所在的cacheline修改状态为s等待cpu0响应的read response消息
5等待cpu1的read response和invalidate ack消息收到cpu0的read response消息,将b置为1,因此程序跳出循环
6等待cpu1的read response和invalidate ack消息因为a在自身的cacheline中,所以读取后进行比对。assert a==1失败。因为此时a在自身cacheline中的值还是0,而且该cacheline尚未失效
7等待cpu1的read response和invalidate ack消息收到cpu0发送的read invalidate消息,将a所在的cacheline设置为无效,但是 为时已晚,错误的判断结果已经产生了
8收到cpu1响应的read response和invalidate ack消息,将store buffer中的值写入cacheline中

    6、通过上面的例子可以看到,在多核系统中,store buffer的存在让程序的结果与我们的预期不相符合。上面的程序中,由于store buffer的存在,所以在cacheline中的操作顺序实际上先b=1然后a=1。为了解决这样的问题,cpu提供了一些操作指令,内存屏障(英文fence,也翻译叫做栅栏)。来看下面的模拟3:

    模拟3:

1

2

3

4

5

6

7

8

9

10

public void set(){

  a=1;

  smp_mb();

  b=1;

}

public void print(){

 while(b==0)

 ;

 assert a==1;

}

smb_mb()就是内存屏障指令,英文memory barries。它的作用,是在后续的store动作之前,将sotre buffer中的内容刷新到cacheline。这个操作的效果是让本地的cacheline的操作顺序和代码的顺序一致,也就是让其他cpu观察到的该cpu的cacheline操作顺序被分为smp_mb()之前和之后。要达到这个目的有两种方式

  • 遇到smp_mb()指令时,暂停cpu执行,将当前的store_buffer全部刷新到cacheline中,完成后cpu继续执行
  • 遇到smp_mb()指令时,cpu继续执行,但是所有后续的store操作都进入到了store buffer中,直到store buffer之前的内容都被刷新到cacheline,即使此时需要store的内容的cacheline是M或者E状态,也只能先写入store buffer中。这样的策略,既可以提升cpu效率,也保证了正确性。当之前store buffer的内容被刷新到cacheline完成后,后面新增加的内容也会有合适的时机刷新到cacheline。把store buffer想象成一个FIFO的队列就可以了。

    7、下面来看,当有了smp_mb()之后,程序的执行情况。所有的初始假设与上面相同。

    模拟4:

 

序号cpu0的步骤(执行set)cpu1的步骤(执行print)
1想写入a=1,但是由于a不在自身的cacheline中,向cpu1发送read invalidate消息执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息
2向store buffer中写入a=1等待cpu0响应的read response消息
3遇到smp_mb(),等待直到可以将store buffer中的内容刷新到cacheline等待cpu0响应的read response消息
4等待直到可以将store buffer中的内容刷新到cacheline收到cpu0发来的read invalidate消息,发送a=0的值,同时将自身a所在的cacheline修改为invalidate状态
5收到cpu1响应的read response和invalidate ack消息,将a=0的值设置到cacheline,随后store buffer中a=1的值刷新到cacheline,设置cacheline状态为M等待cpu0响应的read response消息
6由于b就在自身的cacheline中,并且状态为M或者E,设置值为b=1等待cpu0响应的read response消息
7收到cpu1的read请求,将b=1的值传递回去,同时设置该cacheline状态为s等待cpu0响应的read response消息
8收到cpu0的read response信息,将b设置为1,程序跳出循环
9由于a所在的cacheline被设置为invalidate,因此向cpu0发送read请求
10收到cpu1的read请求,以a=1响应,并且将自身的cacheline状态修改为s等待cpu0的read response响应
11收到read response请求,将a设置为1,执行程序判断,结果为真

可以看到,在有了内存屏障之后,程序的真实结果就和我们的预期结果相同了。

    8、CPU中store buffer容量小,容易满。而满的原因是因为收到其他cpu的invalidate ack的速度太慢,而cpu发送invalidate ack的速度太慢是因为cpu要等到将对应的cacheline设置为invalidate后才能发送invalidate ack。有的时候太多invalidate请求,cpu的处理速度就跟不上。为了加速这个流程,硬件设计者设计了invaldate queue来加速这个过程。收到的invalidate请求先放入invalidate queue,然后之后立刻响应invalidate ack消息。而cpu可以在随后慢慢的处理这些invalidate消息。当然,这里必须不能太慢。也就是说,cpu实际上给出了一个承诺,如果一个invalidatge请求在invalidate queue中,那么对于这个请求相关的cacheline,在该请求被处理完成前,cpu不会再发送任何与该cacheline相关的MESI消息。在有了store buffer和invalidate queue后,cpu的处理速度又可以更高。下面是结构图。

但是在引入了invalidate queue又会导致另外一个问题。下面先来看代码模拟5:

    模拟5:

1

2

3

4

5

6

7

8

9

10

public void set(){

  a=1;

  smp_mb();

  b=1;

}

public void print(){

 while(b==0)

 ;

 assert a==1;

}

代码与上面的例子相同,但是初始条件不同了。这次a同时存在于cpu0和cpu1之中,状态为s。b是cpu0独享,状态为E或者M。

序号cpu0的步骤(执行set)cpu1的步骤(执行print)
1想写入a=1,但是由于a的状态是s,向cpu1发送invalidate消息执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息
2向store buffer中写入a=1收到cpu0的invalidate消息,放入invalidate queue,响应invalidate ack消息。
3遇到smp_mb(),等待直到可以将store buffer中的内容刷新到cacheline。立刻收到cpu0的invalidate ack,将store buffer中的a=1写入到cacheline,并且修改状态为M等待cpu0响应的read response消息
4由于b就在自己的cacheline中,写入b=1,修改状态为M等待cpu0响应的read response消息
5收到cpu1响应的read请求,将b=1作为响应回传,同时将cacheline的状态修改为s。等待cpu0响应的read response消息
6收到read response,将b=1写入cacheline,程序跳出循环
7由于a所在的cacheline还未失效,load值,进行比对,assert失败
8cpu处理invalidate queue的消息,将a所在的cacheline设置为invalidate,但是已经太晚了

    9、上面的例子,看起来就好像第一个一样,仍然是b=1先生效,a=1后生效。导致了cpu1执行的错误。其实这个问题的触发,就是因为invalidate queue没有在需要被处理的时候处理完成,造成了原本早该失效的cacheline仍然被cpu认为是有效,出现了错误的结果。那么只要让内存屏障增加一个让invalidate queue全部处理完成的功能即可。

硬件的设计者也是这么考虑的,请看下面的代码模拟6:

    模拟6:

1

2

3

4

5

6

7

8

9

10

11

public void set(){

  a=1;

  smp_mb();

  b=1;

}

public void print(){

 while(b==0)

 ;

 smp_mb();

 assert a==1;

}

a同时存在于cpu0和cpu1之中,状态为s。b是cpu0独享,状态为E或者M。

序号cpu0的步骤(执行set)cpu1的步骤(执行print)
1想写入a=1,但是由于a的状态是s,向cpu1发送invalidate消息执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息
2向store buffer中写入a=1收到cpu0的invalidate消息,放入invalidate queue,响应invalidate ack消息。
3遇到smp_mb(),等待直到可以将store buffer中的内容刷新到cacheline。立刻收到cpu0的invalidate ack,将store buffer中的a=1写入到cacheline,并且修改状态为M等待cpu0响应的read response消息
4由于b就在自己的cacheline中,写入b=1,修改状态为M等待cpu0响应的read response消息
5收到cpu1响应的read请求,将b=1作为响应回传,同时将cacheline的状态修改为s。等待cpu0响应的read response消息
6收到read response,将b=1写入cacheline,程序跳出循环
7遇见smp_mb(),让cpu将invalidate queue中的消息全部处理完后,才能继续向下执行。此时将a所在的cacheline设置为invalidate
8由于a所在的cacheline已经无效,向cpu0发送read消息
9收到read请求,以a=1发送响应收到cpu0发送的响应,以a=1写入cacheline,执行assert a==1.判断成功

    10、可以看到,由于内存屏障的加入,程序正确了。内存屏障存在两方面作用:

  •     强制cpu将store buffer中的内容写入到cacheline中
  •     强制cpu将invalidate queue中的请求处理完毕

但是有些时候,我们只需要其中一个功能即可,所以硬件设计者们就将功能细化,分别是

  • 读屏障: 强制cpu将invalidate queue中的请求处理完毕。也被称之为smp_rmb
  • 写屏障: 强制cpu将store buffer中的内容写入到cacheline中或者将该指令之后的写操作写入store buffer直到之前的内容被写入cacheline.也被称之为smp_wmb
  • 读写屏障: 强制刷新store buffer中的内容到cacheline,强制cpu处理完invalidate queue中的内容。也被称之为smp_mb

    11、JMM内存模型

    在上面描述中可以看到硬件为我们提供了很多的额外指令来保证程序的正确性。但是也带来了复杂性。JMM为了方便我们理解和使用,提供了一些抽象概念的内存屏障。注意,下文开始讨论的内存屏障都是指的是JMM的抽象内存屏障,它并不代表实际的cpu操作指令,而是代表一种效果。

  • LoadLoad Barriers
    该屏障保证了在屏障前的读取操作效果先于屏障后的读取操作效果发生。在各个不同平台上会插入的编译指令不相同,可能的一种做法是插入也被称之为smp_rmb指令,强制处理完成当前的invalidate queue中的内容
  • StoreStore Barriers
    该屏障保证了在屏障前的写操作效果先于屏障后的写操作效果发生。可能的做法是使用smp_wmb指令,而且是使用该指令中,将后续写入数据先写入到store buffer的那种处理方式。因为这种方式消耗比较小
  • LoadStore Barriers
    该屏障保证了屏障前的读操作效果先于屏障后的写操作效果发生。
  • StoreLoad Barriers
    该屏障保证了屏障前的写操作效果先于屏障后的读操作效果发生。该屏障兼具上面三者的功能,是开销最大的一种屏障。可能的做法就是插入一个smp_mb指令来完成。

内存屏障在volatile关键中的使用

内存屏障在很多地方使用,这里主要说下对于volatile关键字,内存屏障的使用方式。

  • 在每个volatile写操作的前面插入一个StoreStore写写屏障。

  • 在每个volatile写操作的后面插入一个StoreLoad写读屏障。

  • 在每个volatile读操作的后面插入一个LoadLoad读读屏障。

  • 在每个volatile读操作的后面插入一个LoadStore读写屏障。

上面的内存屏障方式主要是规定了在处理器级别的一些重排序要求。而JMM本身,对于volatile变量在编译器级别的重排序也制定了相关的规则。可以用下面的图来表示

volatile变量除了在编译器重排序方面的语义以外,还存在一条约束保证。如果cpu硬件上存在类似invalidate queue的东西,可以在进行变量读取操作之前,会先处理完毕queue上的内容。这样就能保证volatile变量始终是读取最新的最后写入的值。

Happen-before

JMM为了简化对编程复杂的理解,使用了HB来表达不同操作之间的可见性。HB关系在不同的书籍中有不同的表达。这里推荐一种比较好理解的。

A Happen before B,说明A操作的效果先于B操作的效果发生。这种偏序关系在单线程中是没有什么作用的,因为单线程中,执行效果要求和代码顺序一致。但是在多线程中,其可见性作用就非常明显了。举个例子,在线程1中进行进行a,b操作,操作存在hb关系。那么当线程2观察到b操作的效果时,必然也能观察到a操作的效果,因为a操作Happen before b操作。

在java中,存在HB关系的操作一共有8种,如下。

  1. 程序次序法则,如果A一定在B之前发生,则happen before
  2. 监视器法则,对一个监视器的解锁一定发生在后续对同一监视器加锁之前
  3. Volatie变量法则:写volatile变量一定发生在后续对它的读之前
  4. 线程启动法则:Thread.start一定发生在线程中的动作前
  5. 线程终结法则:线程中的任何动作一定发生在线程终结之前(其他线程检测到这个线程已经终止,从Thread.join调用成功返回,Thread.isAlive()返回false)
  6. 中断法则:一个线程调用另一个线程的interrupt一定发生在另一线程发现中断之前。
  7. 终结法则:一个对象的构造函数结束一定发生在对象的finalizer之前
  8. 传递性:A发生在B之前,B发生在C之前,A一定发生在C之前。

使用HB关系,在多线程开发时就可以尽量少的避免使用锁,而是直接利用hb关系和volatile关键字来达到信息传递并且可见的目的。

比如很常见的一个线程处理一些数据并且修改标识位后,另外的线程检测到标识位发生改变,就接手后续的流程。此时如何保证前一个线程对数据做出的更改后一个线程全部可见呢。先来看下面的代码例子

1

2

3

4

5

6

7

8

9

10

11

12

13

14

class VolatileExample {

    int a = 0;

    volatile boolean flag = false;

 

    public void writer() {

        a = 1;     //1

        flag = true;    //2

    }

 

    public void reader() {

        while(flag==false); //3

        int i=a; //4

    }

}

有两个不同的线程分别执行writer和reader方法,根据Hb规则,有如下的顺序执行图。假设,CPU0执行writer,CPU1执行readder。则CPU0置1,readder判断flag,则在判断flag之前,需要先写flag,flag为true,所以跳出循环,执行,i=1.
这样的顺序,i读取到的a的值就是最新的,也即是1.

 

 

    20190318更新

    1、今早在牛客上面练习时,做了一道关于volatile的选择题,volatile非线程安全,它只是保证数据获取的是最新的。

 

在当今计算机视觉领域,深度学习模型在图像分割任务中发挥着关键作用,其中 UNet 是一种在医学影像分析、遥感图像处理等领域广泛应用的经典架构。然而,面对复杂结构和多尺度特征的图像,UNet 的性能存在局限性。因此,Nested UNet(也称 UNet++)应运而生,它通过改进 UNet 的结构,增强了特征融合能力,提升了复杂图像的分割效果。 UNet 是 Ronneberger 等人在 2015 年提出的一种卷积神经网络,主要用于生物医学图像分割。它采用对称的编码器 - 解码器结构,编码器负责提取图像特征,解码器则将特征映射回原始空间,生成像素级预测结果。其跳跃连接设计能够有效传递低层次的细节信息,从而提高分割精度。 尽管 UNet 在许多场景中表现出色,但在处理复杂结构和多尺度特征的图像时,性能会有所下降。Nested UNet 通过引入更深层次的特征融合来解决这一问题。它在不同尺度上建立了密集的连接路径,增强了特征的传递与融合。这种“嵌套”结构不仅保持了较高分辨率,还增加了特征学习的深度,使模型能够更好地捕获不同层次的特征,从而显著提升了复杂结构的分割效果。 模型结构:在 PyTorch 中,可以使用 nn.Module 构建 Nested UNet 的网络结构。编码器部分包含多个卷积层和池化层,并通过跳跃连接传递信息;解码器部分则包含上采样层和卷积层,并与编码器的跳跃连接融合。每个阶段的连接路径需要精心设计,以确保不同尺度信息的有效融合。 编码器 - 解码器连接:Nested UNet 的核心在于多层次的连接。通过在解码器中引入“skip connection blocks”,将编码器的输出与解码器的输入相结合,形成一个密集的连接网络,从而实现特征的深度融合。 训练与优化:训练 Nested UNet 时,需要选择合适的损失函数和优化器。对于图像分割任务,常用的损失
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值