伪共享的产生原因和优化方案

  一 flase sharing产生原因

     在谈到false sharing问题之前我们先说cpu缓存的问题。

    CPU 缓存(Cache Memory)是位于 CPU 与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。高速缓存的出现主要是为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,因为 CPU 运算速度要比内存读写速度快很多,这样会使 CPU 花费很长时间等待数据到来或把数据写入内存。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内 CPU 即将访问的,当 CPU调用大量数据时,就可避开内存的开销直接从缓存中调用,从而加快读取速度。     

    按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存,二级缓存,部分高端 CPU 还具有三级缓存。每一级缓存中所储存的全部数据都是下一级缓存的一部分,越靠近 CPU 的缓存越快也越小。所以 L1 缓存很小但很快(译注:L1 表示一级缓存),并且紧靠着在使用它的 CPU 内核。L2 大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3 在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。

   这种缓存策略在单核环境下近乎完美,可以大大的提高数据的访问速度,但是现在的cpu架构发展到今天的多核,就会又问题。先看一下多核环境下的缓存结构:

                                                                                         

 我们可以看到在多核环境下,共用了L3 ,cpu的缓存以缓存行(cache line)为单位(通常是64字节,因系统而定),当我们从内存中读取数据是,先从各级缓存中查看数据地址是否在缓存中,如果在直接从缓存中读取,如果不在把数据从内存中放入缓存中,每次放入的最小单位为一个缓存行64个字节。当我们改变了缓存中的数据后,会回写到内存中,但是回写策略有很多:

1 每次更新都回写. write-through cache,

2 更新后不回写,标记为dirty, 仅当cache entry被evict时才回写 

3 更新后, 把cache entry送如回写队列, 待队列收集到多个entry时批量回写.

    问题就出在这里,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里,这也是voliate关键字存在的意义。

   看下面的情况:

                                                                                               

       当线程1要访问某个数据时,系统选择了内存中的一段数据读到一个缓存行中,假如thread1访问的时缓存行中的第二个元素,此时该缓存行锁在的cache entry被标记为dirty,并且导致了thread0的缓存失效,此时thread0要访问缓存行中的第1个数据,即便不是同一个数据也因为缓存失效(cache miss)而影响了cpu的正常处理过程,反过来thread0的操作也会导致thread1的cache miss,这就是所谓的false sharing。当发生这种情况时会大大降低cpu的处理速度,此时的处理速度大概为正常速度的十分之1。

对伪共享造成的性能损失的证明实验

二 相关问题的优化方案

    缓冲行补充:最终的目标就是避免不同线程访问的变量在一个缓存行中,所以我们要进行缓存行填充(Padding)操作 。我们知道一条缓存行有 64 字节,所以我们只要补充对象的字节数超过64个字节,就避免了伪共享。对于这种缓冲行补充我们可以用代码来实现,也可以在编译的时候通过相关的操作来实现。还有一种解决方案:点击打开链接,我也不是很懂,这里不在多说。

三 问题扩展

       我们分析一个经典的生产者消费者问题,一个生产者线程thread1和一个消费者线程thread2。他们之间有一个数据队列,通常我们的做法是给队列加锁来保证队列数据的同步,这种做法除了锁的性能消耗外,还有什么缺点呢,我们来分析一下。

       假设我们的计算机是双核,core1和core2,thread1运行在core1中,当thread1运行时遇到lock,进入sleep状态,此时thread2激活,此时系统给thread2分配cpu时间片,此时core1和core2都空闲假如此时系统把core1分配给了thread2,此时core1中的缓存对于thread2来说是无用的,所以重新冲内存中读取缓存,当thread2遇到lock时,thread1激活,假如又分配给了core1,此时缓存又是无用的。这种情况就会导致频繁的读取缓存降低cpu的效率。我们能不能让缓存的有效时间长一点呢,最直接最简单的办法就是不使用锁。  

        这里可以参考java的并发框架Disruptor的实现原理。不得不说java社区还是有很多值得学习的优秀的框架的。

       简单说下吧,了解的也不是很多。

        

       首先Disruptor有一个ringbuffer,是一个循环队列如上图,有个cursor,里面有sequencenumber,数据类型是long。如果不考虑consumer,只有一个producer在写,就是不停的往entry里写东西,然后增加cursor上的sequence number。为了避免cursor里的sequence number和其他variable变量产生false sharing,disruptor定义了7个long型,并没有给它们赋值,然后再定义cursor,这样cursor就不会和其他variable同时出现在一个cache line里,这就是我们上面提到的在程序层进行缓存行补齐。

     我们首先看有一个生产者,一个或多个消费者的情况, 当我们的消费者读到5,7时,生产者写道18,此时18会被标记为inavalid,Consumer每次在访问时需要先检查sequence number是否available,当读到18时发现此位置不可用,此时会有多种策略,latency最高的一种是盲等。producer在写的时候,需要检查最低的sequence number在哪儿,判断当前位置是否有消费者在读取数据,如果没有则修改当前位置的数据,并且把当前位置的sequencenumber增加(上图如果修改了3位置的数据,3会变成19)。

    如果有多个生产者呢,怎么保证数据的同步呢,我还没有彻底搞懂他的实现机制,推荐一篇文章吧,里面详细讲述了它如何应对多个生产者的情况:点击打开链接,文章下面有很多关于该框架的文章可以看一下,最近我也在看,搞java的应该对这个很熟悉。

    最后说一下我对于cache line优化的观点吧,这种优化我认为是在你系统对于处理性能和并发等方面有一个特别高的要求,并且你的系统优化到了极致,这种时候你再考虑做着这方面的优化,比如你的系统子io读取,cpu计算,还有网络等方面有很大的优化空间你首先要解决这些问题,而不是直接去优化cpu 的cache问题,否则会得不偿失。

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值