推荐 原创 视频 Java开发 iOS开发 前端开发 JavaScript开发 Android开发 PHP开发 数据库 开发工具 Python开发 Kotlin开发 Ruby开发 .NET开发 服务器运维 开放平台 架构师 大数据 云计算 人工智能 开发语言 其它开发
Lambda在线 > 朗坤技术团队 > 高并发、低延迟之玩转CPU高速缓存(附C#示例)

高并发、低延迟之玩转CPU高速缓存(附C#示例)

朗坤技术团队 2018-10-29

点击上面蓝字朗坤研发中心  ▲ 订阅

内容来源:智能制造研发部--贲圣锋 

来源:无痴迷,不成功

cnblogs.com/justmine/p/9696160.html


写在前面


一直在不断地探索响应式DDD,又get到了很多新知识,解惑了很多老问题,最近读了Martin Fowler大师一篇非常精彩的博客The LMAX Architecture,里面有一个术语Mechanical Sympathy,姑且翻译成软硬件协同编程(Hardware and software working together in harmony),很有感悟,说的是要把编程与底层硬件协同起来,这样对于开发低延迟、高并发的系统特别地重要,为什么呢,今天我们就来讲讲CPU的高速缓存。


电脑的缓存系统



电脑的缓存系统分了很多层级,从外到内依次是主内存、三级高速缓存、二级高速缓存、一次高速缓存,所以,在我们的脑海里,觉点磁盘的读写速度是很慢的,而内存的读写速度确是快速的,的确如此,从上图磁盘和内存距离CPU的远近距离就看出来。


这里要说明一个概念,主内存被所有CPU共享,三级缓存被同一个插槽内的CPU所共享,单个CPU独享自己的一级、二级缓存。


CPU是真正处理事情的地方,它会首先从高速缓存中去获取所需的数据,如果找不到,再去三级缓存中查找,如果还是找不到最终就去会主内存查找,如果每一次都这样来来回回地取数据,那么无疑是非常耗时。


如果能够把数据缓存到高速缓存中就好了,这样CPU第一次就可以直接从高速缓存中命中数据,但是这个地方对我们而言根本不透明,肿么办?


探索高速缓存的构造


我们先来看一张使用鲁大师检测的处理器信息截图,如下:


高并发、低延迟之玩转CPU高速缓存(附C#示例)


从上图可以看到,CPU高速缓存(一、二级)的存储单元为Line,大小为64 bytes,也就是说无论我们的数据大小是多少,高速缓存都是以64 bytes为单位缓存数据,比如一个8位的long类型数组,即使只有第一位有数据,每次高速缓存加载数据的时候,都会顺带把后面7位数据也一起加载,这是底层硬件运作的方式,所以我们要利用这个天然的优势,让数据独占整个缓存行,这样CPU命中的缓存行中就一定有我们的数据。


示例


使用不同的线程数,对一个long类型的数值计数500亿次。


备注:统计分析图表和总结在最后。


1. 一般的实现方式


大多数程序员都会这样子构造数据,老铁没毛病。


代码


///// <summary>

///// CPU伪共享高速缓存行条目(伪共享)

///// </summary>

public class FalseSharingCacheLineEntry

{

    public long Value = 0L;

}


单线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 1508.56 毫秒。


双线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 4460.40 毫秒。


三线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 7719.02 毫秒。


四线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 10404.30 毫秒。


2. 独占缓存行,直接命中高速缓存。


2.1 直接填充


代码


/// <summary>

/// CPU高速缓存行条目(直接填充)

/// </summary>

public class CacheLineEntry

{

    protected long P1, P2, P3, P4, P5, P6, P7;

    public long Value = 0L;

    protected long P9, P10, P11, P12, P13, P14, P15;

}


为了保证高速缓存行中一定有我们的数据,所以前后都填充7个long。


单线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 1516.33 毫秒。


双线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 1529.97 毫秒。


三线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 1563.65 毫秒。


四线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 1616.12 毫秒。


2.2 内存布局填充


作为一个C#程序员,必须写出优雅的代码,可以使用StructLayout、FieldOffset来控制class、struct的内存布局。


备注:就是上面直接填充的优雅实现方式而已。


代码


/// <summary>

/// CPU高速缓存行条目(控制内存布局)

/// </summary>

[StructLayout(LayoutKind.Explicit, Size = 120)]

public class CacheLineEntryOne

{

    [FieldOffset(56)]

    private long _value;


    public long Value

    {

        get => _value;

        set => _value = value;

    }

}


单线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 2008.12 毫秒。


双线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 2046.33 毫秒。


三线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 2081.75 毫秒。


四线程


高并发、低延迟之玩转CPU高速缓存(附C#示例)


平均响应时间 = 2163.092 毫秒。


3. 统计分析



上面的图表已经一目了然了吧,一般实现方式的持续时间随线程数呈线性增长,多线程下表现的非常糟糕,而通过直接、内存布局方式填充了数据后,响应时间与线程数的多少没有无关,达到了真正的低延迟。


其中直接填充数据的方式,效率最高,内存布局方式填充次之,在四线程的情况下,一般实现方式持续时间为10.4秒多,直接填充数据的方式为1.6秒,内存布局填充方式为2.2秒,延迟还是比较明显,为什么会有这么大的差距呢?


刨根问底


在C#下,一个long类型占8 byte,对于一般的实现方式,在多线程的情况下,隶属于每个独立线程的数据会共用同一个缓存行,所以只要有一个线程更新了缓存行的数据,那么整个缓存行就自动失效,这样就导致CPU永远无法直接从高速缓存中命中数据,每次都要经过一、二、三级缓存到主内存中重新获取数据,时间就是被浪费在了这样的来来回回中。


而对数据进行填充后,隶属于每个独立线程的数据不仅被缓存到了CPU的高速缓存中,而且每个数据都独占整个缓存行,其他的线程更新数据,并不会导致自己的缓存行失效,所以每次CPU都可以直接命中,不管是单线程也好,还是多线程也好,只要线程数小于等于CPU的核数都和单线程一样的快速,正如我们经常在一些性能测试软件,都会看到的建议,线程数最好小于等于CPU核数,最多为CPU核数的两倍,这样压测的结果才是比较准确的,现在明白了吧。


最后来看一下大师们总结的未命中缓存的测试结果



源码参考:https://github.com/justmine66/MDA/blob/master/tests/MDA.Test.Disruptor/FalseSharingTest.cs


版权声明:本站内容全部来自于腾讯微信公众号,属第三方自助推荐收录。《高并发、低延迟之玩转CPU高速缓存(附C#示例)》的版权归原作者「朗坤研发中心」所有,文章言论观点不代表Lambda在线的观点, Lambda在线不承担任何法律责任。如需删除可联系QQ:516101458

文章来源: 阅读原文

相关阅读

关注朗坤研发中心微信公众号

朗坤研发中心微信公众号:gh_9000620767dc

朗坤研发中心

手机扫描上方二维码即可关注朗坤研发中心微信公众号

朗坤研发中心最新文章

精品公众号随机推荐