高并发之volatile、synchronized关键和内存屏障(Memory Barrier)

举报
YuShiwen 发表于 2022/03/31 09:56:50 2022/03/31
【摘要】 在了解内存屏障之前,首先我们得知道电脑中时钟脉冲的作用,如下: 电脑通过使用时钟来同步执行指令,时钟脉冲的频率(称为时钟频率)基本上是固定的。在电脑中它就是由一个频率相当精确和稳定的脉冲信号发生器发出的。 脉冲信号通常是由晶体通电振荡产生的。 同样内存缓存也有自己的时许,比如内存时序(英语:Memory timings或RAM timings)是描述同步动态随机存取存储器(SDRAM)性能的四个
							✨ 我是喜欢分享知识、喜欢写博客的YuShiwen,与大家一起学习,共同成长!
									  📢 闻到有先后,学到了就是自己的,大家加油!
📢 导读:
本期总共有4个章节,
⛳️ 第一个章节是让大家了解电脑时钟脉冲,它是什么,有什么作用。
⛳️ 第二个章是介绍为什么要引入缓存以及他的物理结构图。
⛳️ 第三个章节是与第二章节环环相扣,在3.1章节中首先抛出缓存存在的问题,然后在3.2章节中来引入Store Buffer解决3.1章节中的问题,然后又引入Invalidate Queue对3.2章节进行了优化,并且详细介绍了它们的原理还给出了结构图。
⛳️ 第四个章节是关于内存屏障的讲解,这个章节是全文的重点。
文章的整体内容层层递,展现了之前的大佬们分析问题和解决问题的思路,希望大家可以耐心看完,保证会有所收获,该篇文章笔者花费2.5天时间创作完成,质量不会差,大家加油!
								

⛳️1.了解时钟脉冲

在了解内存屏障之前,首先我们得知道电脑中时钟脉冲的作用,如下:

  • 电脑通过使用时钟来同步执行指令,时钟脉冲的频率(称为时钟频率)基本上是固定的。在电脑中它就是由一个频率相当精确和稳定的脉冲信号发生器发出的。
  • 脉冲信号通常是由晶体通电振荡产生的。
  • 同样内存缓存也有自己的时许,比如内存时序(英语:Memory timings或RAM timings)是描述同步动态随机存取存储器(SDRAM)性能的四个参数:CL、TRCD、TRP和TRAS,单位为时钟周期。
  • 当你买了一台1.5GHz的电脑,1.5GHz就是时钟频率,即每秒15亿次的时钟脉冲。时钟并不记录分和秒。它以不变的速率简单跳动。电子计算机通过使用这个跳动来正确执行它们的操作,就像节拍器的跳动如何来帮助你以正确的节奏播放音乐。一个指令需要跳动的次数(或就像他们经常说的执行周期)依赖CPU的产生和模仿。周期的次数取决于它之前的指令和其他因素。
  • 总的来说,时钟脉冲他可以统一协调各部件的工作,算是一个统一的节拍。

⛳️2.引入缓存

我们都知道对于i++操作:(在不考虑缓存的情况下,下面我将用这个例子引出缓存)

  1. 读内存,也就是加载操作(load), 从内存读到寄存器(关于CPU与寄存器可参考本人该篇博文:CPU和寄存器详解
  2. 在通用寄存器中将i的值自增1
  3. 写内存,就就是存储操作(store),从寄存器写入到内存

如果直接使用内存与CPU中的寄存器交互,会存在如下问题:

  • 我们知道,CPU和内存之间存在速度差异。程序的指令,如果需要从CPU Register里面取数据,CPU只需要0 cycles(CPU周期,即上述提到的一次脉冲); 如果需要直接访问内存,则需要几百个cycles。(另外,如果能从高速缓存取到目标数据,需要4-75个cycles);一个非常之快的CPU(处理器),却要和一个更慢的RAM(内存)交换数据,自己也会从一个cycles变成几百个cycles。毫无疑问,如果直接交换数据,那么CPU会因为RAM太慢,拖慢自己的速度。
  • CPU访问个存储位置所需的时间如下:
CPU访问 大约需要的周期(cycle) 大约需要的时间
寄存器 1 cycle 0ns
L1 Cache 3—4 cycle 1ns
L2 Cache 10—20 cycle 3ns
L3 Cache 40—45 cycle 15ns
内存 60—90ns

那么如何解决这个问题呢?引入三级缓存:

  • 为了解决两者之间数据交换数据的速度差异,计算机科学家们了就引入了高速缓存(Cache):让CPU和RAM之间使用多层级的缓存,避免两者直接交换数据,而是从临近的缓存区交换数据。越靠近CPU寄存器的缓存速度越快,造价越越贵,下图的三级缓存与寄存器之间的结构图:(其中的Thread0、Thread1可以理解成CPU0的寄存器、CPU1的寄存器)
    在这里插入图片描述
    上图可以简化为下图:

在这里插入图片描述

小插曲,缓存行的概念:

  • 为了简化与RAM之间的通信,高速缓存控制器是针对数据块,而不是字节进行操作的。从程序设计的角度讲,高速缓存其实就是一组称之为缓存行(cache line)的固定大小的数据块,目前一般为64Byte。关于缓存行的深入理解,大家可以看下笔者的这篇文章:
    高并发之伪共享和缓存行填充(缓存行对齐)(@Contended)

⛳️3.引入Store Buffer

3.1.缓存存在的问题:

如果CPU0发起一次对某个地址的写操作,但是其本地缓存中没有数据,这个数据存放在CPU1的本地缓存中。那么此时会进行如下操作:(缓存一致性协议MESI,具体的内容见笔者该篇文章:“了解高并发底层原理”,面试官:讲一下MESI(缓存一致性协议)吧)
根据MESI协议,在CPU缓存之间会进行如下沟通:

  1. 为了完成这次操作,CPU0会发出一个invalidate的信号,使其他CPU的cache数据无效(因为CPU0需要重新写这个地址中的值,说明这个地址中的值将被改变,如果不把其他CPU中存放的该地址的值无效,那么就有可能会出现数据不一致的问题)。
  2. CPU0的invalidate信号后,会等待其他CPU对于该信号的回复,即其他CPU需要回复invalidate acknowledge信号(消息)来告知CPU0我们已经接收到了invalidate信号,把cache数据变为无效的了。
  3. 而这个数据可能不只在CPU1的缓存中,还可能在CPU2、CPU3的缓存中,在所有CPU都回复给CPU0信号后才能真正发起写操作。
  4. 这个需要等待非常长的时间,这就导致了性能上的损耗。

如何解决上面的这个问题呢?这个时候我们引入Store Buffer。

3.2 引入Store Buffer来解决3.1中的问题

加入了这个Store Buffer存储缓存区硬件结构后:
此时CPU0需要往某个地址中写入一个数据时:

  1. 它不需要去关心其他的CPU的local cache中有没有这个地址的数据,它只需要把它需要写的值直接存放到store buffer中,然后发出invalidate的信号。
  2. 等到其他CPU回复invalidate的信号后,再把CPU0存放在store buffer中的数据推到CPU0的本地缓存中。
  3. 这样就避免了CPU0等待其他CPU的响应了。

ps:每一个CPU core都拥有自己私有的store buffer,一个CPU只能访问自己私有的那个store buffer。

3.3 引入Invalidate Queue对3.2的进一步优化

  1. store buffer的大小是有限的,所有的写入操作发生cache missing(数据不再本地)都会使用store buffer,因此store buffer很容易会满;
  2. 当store buffer满了之后,需要写如数据的cpu还是会等待其他的CPU响应Invalidate信号以处理store buffer中的数据,即把store buffer中的数据推到CPU的本地缓存中。
  3. 因此还是要回到其他CPU响应Invalidate信号上面来,其他CPU回复invalidate acknowledge信号(消息)来告知CPU0我们已经接收到了invalidate信号,把cache line数据变为无效的了。
  4. 如果一个CPU很忙,可能导致需要回复信号的cpu无法按时回复invalidate acknowledge信号(消息),这就可能会导致写入数据的cpu在等它回Invalidate ACK。
  5. 解决思路还是化同步为异步: cpu不必要处理了cache line之后才回Invalidate ACK,而是可以先将Invalid消息放到某个请求队列Invalid Queue,然后就返回Invalidate ACK。CPU可以后续再处理Invalid Queue中的消息,大幅度降低Invalidate ACK响应时间。

如下图:
在这里插入图片描述


⛳️4.内存屏障

说到这里,内存屏障它终于来了。

4.1volatile和synchronized关键字与内存屏障的关系:

  • 我们都知道在Java中,如果不使用volatile和synchronized指令可能会发生重排,指令重排分为编译器指令重排和CPU指令重排。
  • Java多线程程序通常使用高层程序设计语言中的同步原语,比如volatile和synchronized,因此一般不需要明确使用内存屏障。
  • 也就是说在Java中我们使用的是volatile和synchronized关键字,javac编译转化成字节码的时候,还是用到了内存屏障。

4.2内存屏障能解决哪些问题:

  1. 可见性
    内存可见性问题,主要是高速缓存与内存的一致性问题。一个处理器上的线程修改了某数据,而在另一处理器上的线程可能仍然使用着该数据在专用cache中的老值,这就是可见性出了问题。
  2. 禁止重排
    编译器和CPU为了提高代码的执行效率,可能会对代码进行重新排序。

4.3底层的内存屏障是什么:

大多数处理器提供了内存屏障指令:

  1. 完全内存屏障(full memory barrier)确保内存读和写操作;保障了内存屏障前的读写操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的读写操作。
  2. 内存读屏障(read memory barrier)仅确保了内存读操作;保障了内存屏障前的操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的操作。
  3. 内存写屏障(write memory barrier)仅保证了内存写操作。保障了内存屏障前的操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的操作。

内存屏障是底层原语,是内存排序的一部分,在不同体系结构下变化很大而不适合推广。需要认真研读硬件的手册以确定内存屏障的办法。

4.4Java中的内存屏障

上面提到了内存屏障可简单分为读屏障和写屏障。这两种的组合就有如下这几种情况:
read read ,write write ,read write ,write read实际上也是上述两种的组合,完成一系列的屏障和数据同步功能:

  • `read_read屏障:对于这样的语句read1语句;read_read屏障; read2语句,在read2及后续读取操作要读取的数据被访问前,保证read1要读取的数据被读取完毕。
  • write_write屏障:对于这样的语句write1语句; write_write屏障; write2语句,在write2及后续写入操作执行前,保证write1的写入操作对其它处理器可见。
  • read_write屏障:对于这样的语句read语句; read_write屏障;write语句,在write及后续写入操作被刷出前,保证read要读取的数据被读取完毕。
  • write_read屏障:对于这样的语句write语句; write_read屏障; read语句,在read及后续所有读取操作执行前,保证write的写入对所有处理器可见。

这里拿volatile举例,volatile它非常的悲观且严格,volatile的具体屏障如下:

volatile long i = 1;
  • 对于写:

    • 在每个volatile写操作前插入write_write屏障,在写操作后插入write_read屏障;
    • 也就是 语句 write语句; write_write屏障 ;long i = 1语句 ; write_read屏障;read语句。
  • 对于读:

    • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
    • 也就是 语句 read语句; LoadLoad屏障 ;long i = 1语句 ; LoadStore屏障;write语句。
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。