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

电脑的缓存系统分了很多层级,从外到内依次是主内存、三级高速缓存、二级高速缓存、一级高速缓存,所以,在我们的脑海里,觉点磁盘的读写速度是很慢的,而内存的读写速度确是快速的,的确如此,从上图磁盘和内存距离CPU的远近距离就看出来。这里先说明一个概念,主内存被所有CPU共享;三级缓存被同一个插槽内的CPU所共享;单个CPU独享自己的一级、二级缓存,即高速缓存。CPU是真正做事情的地方,它会先从高速缓存中去获取所需的数据,如果找不到,再去三级缓存中查找,如果还是找不到最终就去会主内存查找,并且找到数据后,先要复制到缓存(L1、L2、L3),然后在返回数据;如果每一次都这样来来回回地复制和读取数据,那么无疑是非常耗时。如果能够把数据缓存到高速缓存中就好了,这样不仅CPU第一次就可以直接从高速缓存中命中数据,而且每个CPU都独占自己的高速缓存,多线程下也不存在临界资源的问题,这才是真正的低延迟,但是这个地方对高层开发人员而言根本不透明,肿么办?
对于CPU而言,只有第一、二、三级才是缓存区,主内存不是,如果需要到主内存读取数据,这种情况称为缓存未命中(cache miss)。
探索高速缓存的构造
我们先来看一张使用鲁大师检测的处理器信息截图,如下:

从上图可以看到,CPU高速缓存(一、二级)的存储单元为Line,大小为64 bytes,也就是说无论我们的数据大小是多少,高速缓存都是以64 bytes为单位缓存数据,比如一个8位的long类型数组,即使只有第一位有数据,每次高速缓存加载数据的时候,都会顺带把后面7位数据也一起加载(因为数组内元素的内存地址是连续的),这就是底层硬件CPU的工作机制,所以我们要利用这个天然的优势,让数据独占整个缓存行,这样CPU命中的缓存行中就一定有我们的数据。
示例
使用不同的线程数,对一个long类型的数值计数500亿次。
备注:统计分析图表和总结在最后。
1. 一般的实现方式
大多数程序员都会这样子构造数据,老铁没毛病。
代码
///// <summary>
///// CPU伪共享高速缓存行条目(伪共享)
///// </summary>
public class FalseSharingCacheLineEntry
{
public long Value = 0L;
}
单线程

平均响应时间 = 1508.56 毫秒。
双线程

平均响应时间 = 4460.40 毫秒。
三线程

平均响应时间 = 7719.02 毫秒。
四线程

平均响应时间 = 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。
单线程

平均响应时间 = 1516.33 毫秒。
双线程

平均响应时间 = 1529.97 毫秒。
三线程

平均响应时间 = 1563.65 毫秒。
四线程

平均响应时间 = 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;
}
}
单线程

平均响应时间 = 2008.12 毫秒。
双线程

平均响应时间 = 2046.33 毫秒。
三线程

平均响应时间 = 2081.75 毫秒。
四线程

平均响应时间 = 2163.092 毫秒。
3. 统计分析

上面的图表已经一目了然了吧,一般实现方式的持续时间随线程数呈线性增长,多线程下表现的非常糟糕,而通过直接、内存布局方式填充了数据后,响应时间与线程数的多少没有无关,达到了真正的低延迟。其中直接填充数据的方式,效率最高,内存布局方式填充次之,在四线程的情况下,一般实现方式持续时间为10.4秒多,直接填充数据的方式为1.6秒,内存布局填充方式为2.2秒,延迟还是比较明显,为什么会有这么大的差距呢?
刨根问底
在C#下,一个long类型占8 byte,对于一般的实现方式,在多线程的情况下,隶属于每个独立线程的数据会共用同一个缓存行,所以只要有一个线程更新了缓存行的数据,那么整个缓存行就自动失效,这样就导致CPU永远无法直接从高速缓存中命中数据,每次都要经过一、二、三级缓存到主内存中重新获取数据,时间就是被浪费在了这样的来来回回中。而对数据进行填充后,隶属于每个独立线程的数据不仅被缓存到了CPU的高速缓存中,而且每个数据都独占整个缓存行,其他的线程更新数据,并不会导致自己的缓存行失效,所以每次CPU都可以直接命中,不管是单线程也好,还是多线程也好,只要线程数小于等于CPU的核数都和单线程一样的快速,正如我们经常在一些性能测试软件,都会看到的建议,线程数最好小于等于CPU核数,最多为CPU核数的两倍,这样压测的结果才是比较准确的,现在明白了吧。
最后来看一下大师们总结的未命中缓存的测试结果
| 从CPU到 | 大约需要的 CPU 周期 | 大约需要的时间 |
|---|---|---|
| 主存 | 约60-80纳秒 | |
| QPI 总线传输 (between sockets, not drawn) | 约20ns | |
| L3 cache | 约40-45 cycles | 约15ns |
| L2 cache | 约10 cycles, | 约3ns |
| L1 cache | 约3-4 cycles | 约1ns |
| 寄存器 | 寄存器 |
源码参考:
https://github.com/justmine66/Disruptor/blob/master/tests/Disruptor.ConsoleTest/FalseSharingTest.cs
延伸阅读
Magic cache line padding
The LMAX Architecture
补充
感谢@ firstrose同学主动测试后的提醒,大家应该向他学习,带着疑惑看博客,不明白的自己动手测试。对于内存布局填充方式,去掉属性后,经过测试性能与直接填充方式几乎无差别了,不过本示例代码仅仅作为一个测试参考,主要目的是给大家布道如何利用CPU高速缓存工作机制,通过缓存行的填充来避免假共享,从而写出真正低延迟的代码。
/// <summary>
/// CPU高速缓存行条目(控制内存布局)
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 120)]
public class CacheLineEntryOne
{
[FieldOffset(56)]
public long Value;
}
总结
编写单、多线程下表现都相同的代码,历来都是非常困难的,需要不断地从深度、广度上积累知识,学无止境,无痴迷,不成功,希望大家能有所收获。
写在最后
如果有什么疑问和见解,欢迎评论区交流。
如果你觉得本篇文章对您有帮助的话,感谢您的【推荐】。
如果你对.NET高性能编程感兴趣的话可以【关注我】,我会定期的在博客分享我的学习心得。
欢迎转载,请在明显位置给出出处及链接。
软硬件协同编程 - C#玩转CPU高速缓存(附示例)的更多相关文章
- CPU高速缓存行与内存关系 及并发MESI 协议
先来一个整体图 一. 大致关系: CPU Cache --> 前端总线 FSB (下图中的Bus) --> Memory 内存 CPU 为了更快的执行代码.于是当从内存中读取数据时,并不是 ...
- a=a+1背后的内存模型和CPU高速缓存
学过JAVA的人都知道,程序运行过程中的临时数据,都是从外部存储设备调入内存(物理内存)中,再进行读写操作的.而计算机在执行程序时,对程序的每条指令都是在CPU中执行的,而指令的执行,势必涉及到对数据 ...
- 充分利用CPU高速缓存,提高程序效率(原理篇)
提高程序效率应该充分利用CPU的高速缓存.要想编写出对CPU缓存友好的程序就得先明白CPU高速缓存的运行机制. i5-2400S: 1.有三级缓存分别为 32k(数据.指令缓存分开,分为32k),25 ...
- 在C#编程中玩转枚举,分享我的EnumHelper。
在C#编程中玩转枚举,分享我的EnumHelper. 在软件开发过程中,我们经常会为特定的场景下的特定数据定义逻辑意义.比如在用户表中,我们可能会有一个用户状态字段,该字段为整形.如果该字段的值为1则 ...
- CPU高速缓存
目录 Code: 物理结构: 缓存行Cache Line 伪共享: 概念: 解决办法: 内存屏障: 理解: 参考: Code: public class Main { static long[][] ...
- 每个程序员都应该了解的 CPU 高速缓存
每个程序员都应该了解的 CPU 高速缓存 英文原文:Memory part 2: CPU caches 来源:oschina [编者按:这是Ulrich Drepper写“程序员都该知道存储器”的第二 ...
- 深入理解 Linux Cgroup 系列(二):玩转 CPU
原文链接:深入理解 Linux Cgroup 系列(二):玩转 CPU 上篇文章主要介绍了 cgroup 的一些基本概念,包括其在 CentOS 系统中的默认设置和控制工具,并以 CPU 为例阐述 c ...
- php为什么需要异步编程?php异步编程的详解(附示例)
本篇文章给大家带来的内容是关于php为什么需要异步编程?php异步编程的详解(附示例),有一定的参考价值,有需要的朋友可以参考一下,希望对你有所帮助. 我对 php 异步的知识还比较混乱,写这篇是为了 ...
- C#编程总结(七)数据加密——附源码
C#编程总结(七)数据加密——附源码 概述 数据加密的基本过程就是对原来为明文的文件或数据按某种算法进行处理,使其成为不可读的一段代码,通常称为“密文”,使其只能在输入相应的密钥之后才能显示出本来内容 ...
随机推荐
- /usr/lib/uwsgi/plugins/python_plugin.so: cannot open shared object file: No such file or directory
Django uwsgi部署方式下产生这个Bug,后来发现把uwsgi配置ini文件里面的 #plugins = python 把上面这句配置语句注释掉,uwsgi就可以运行了,当然,是正常可用运行状 ...
- AngularJS - 使用RequireJS还是Browserify?
http://www.html-js.com/article/2126 AngularJS - 使用RequireJS还是Browserify? AngularJS之所以吸引了很多开发者的关注,很大一 ...
- mysql高级之编程优化
★编程优化一.字符编码(mysql控制台乱码输出解决:character_set_results='gbk')表/列编码设置 列:alter table 表名 change 列名 列名 数据类型 c ...
- JDK AtomicInteger 源码分析
@(JDK)[AtomicInteger] JDK AtomicInteger 源码分析 Unsafe 实例化 Unsafe在创建实例的时候,不能仅仅通过new Unsafe()或者Unsafe.ge ...
- 关于Python的super用法研究
一.问题的发现与提出 在Python类的方法(method)中,要调用父类的某个方法,在python 2.2以前,通常的写法如代码段1: 代码段1: class A: def __init__(se ...
- 利用百度地图api实现定位
使用百度地图api前需要先获取一个百度地图开放平台的访问应用AK, 获取百度地图开放平台访问应用AK方式:注册百度账号-->申请百度开发者-->获取密匙-->使用相关功能. 注册账号 ...
- CSS命名规则常用的css命名规则
CSS命名规则常用的css命名规则 头:header 内容:content/container 尾:footer 导航:nav 侧栏:sidebar 栏目:column 页面外围控制整体布局宽度:wr ...
- sqlilabs 5
第一个1不断返回true,2可以进行更改?id=-1' union select 1,2,3 and '1?id=-1' union select 1,2,3 and 1='1 ?id=-1' uni ...
- PAT1100:Mars Numbers
1100. Mars Numbers (20) 时间限制 400 ms 内存限制 65536 kB 代码长度限制 16000 B 判题程序 Standard 作者 CHEN, Yue People o ...
- Intent里ACTION的CALL和DIAL的区别?
Intent在进行activity之间的跳转的时候有一种方式是通过设置ACTION的方式来进行跳转的,这个ACTION是设置在manifest文件里Intent-filter里的,我们可以通过跳转自定 ...