.NET高性能编程之C#玩转CPU高速缓存(附示例)
写在前面
好久没有写博客了,一直在不断地探索响应式DDD,又get到了很多新知识,解惑了很多老问题,最近读了Martin Fowler大师一篇非常精彩的博客The LMAX Architecture,里面有一个术语Mechanical Sympathy,姑且翻译成软硬件协同编程(Hardware and software working together in harmony),很有感悟,说的是要把编程与底层硬件协同起来,这样对于开发低延迟、高并发的系统特别地重要,为什么呢,今天我们就来讲讲CPU的高速缓存。
电脑的缓存系统
电脑的缓存系统分了很多层级,从外到内依次是主内存、三级高速缓存、二级高速缓存、一级高速缓存,所以,在我们的脑海里,觉点磁盘的读写速度是很慢的,而内存的读写速度确是快速的,的确如此,从上图磁盘和内存距离CPU的远近距离就看出来。这里先说明一个概念,主内存被所有CPU共享;三级缓存被同一个插槽内的CPU所共享;单个CPU独享自己的一级、二级缓存,即高速缓存。CPU是真正做事情的地方,它会先从高速缓存中去获取所需的数据,如果找不到,再去三级缓存中查找,如果还是找不到最终就去会主内存查找,如果每一次都这样来来回回地取数据,那么无疑是非常耗时。如果能够把数据缓存到高速缓存中就好了,这样不仅CPU第一次就可以直接从高速缓存中命中数据,而且每个CPU都独占自己的高速缓存,多线程下也不存在临界资源的问题,这才是真正的低延迟,但是这个地方对我们而言根本不透明,肿么办?
探索高速缓存的构造
我们先来看一张使用鲁大师检测的处理器信息截图,如下:
从上图可以看到,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/MDA/blob/master/tests/MDA.Test.Disruptor/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高性能编程感兴趣的话可以【关注我】,我会定期的在博客分享我的学习心得。
欢迎转载,请在明显位置给出出处及链接。
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
什么是Nodejs和npm-安装和概述
什么是Nodejs和npm-安装和概述http://www.bieryun.com/4639.html 很明显,JavaScript已经发展了多年。从用于HTML的语言制作丰富的前端页面,到每个人都能说和理解的语言。 除非你一直生活在岩石下,否则你已经听说过nodejs。通过节点平台,JavaScript已经发展到更多。 什么是NODE JS? Nodejs是一个运行时环境,可以在引擎盖下运行chrome的V8引擎。Node允许您在Web浏览器之外编译和运行JavaScript代码。这大大增加了JavaScript的使用次数。今天,JavaScript用于创建Web应用程序,桌面应用程序,移动应用程序以及您可以想象的几乎所有利基。 什么是NPM? NPM是Nodejs Package Manager的缩写。它是一个小工具,可让您下载和安装JavaScript库和包。它基本上是Nodejs应用程序的依赖管理器。默认情况下,它会在任何系统上全新安装节点。除了NPM,yarn是另一个可以与节点一起使用的包管理器。但是,纱线不带节点,您需要单独安装。 安装节点和NPM Node是高度跨平台的...
- 下一篇
Python基础练习二超市存包柜模拟
题目描述 模拟超市存包柜的存放物品和取出物品操作,存放物品时选择空储物格,然后分配密码即为存放完成;取出物品时,输入对应的密码,打开对应的箱门即为取出物品 题目分析 1.题目练习的是自定义方法,比如存放物品方法,取出物品方法,检测是否有空储物格的方法等2.练习使用while和if....elif的用法 代码 为了练习一下类的使用,所以在这里定义了一个类,默认设定有100个储物格,用列表表示每个格子;格子里面的数据为0时表示空格子,use的时候,存入随机生成的密码 #!/usr/bin/python3 #-*- coding:UTF-8 -*- import random ''' 模拟超市存包柜程序,设置100个箱子,存满即止 每次存放物品之前从第一个箱子检测是否为空,遇到第一个为空的即可存入物品 ''' class Locker(object): def __init__(self): self.cell_num = 100 self.use = 0 self.surplus = self.cell_num self.cell = [0]*100 def show_cell_detai...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
-
Docker使用Oracle官方镜像安装(12C,18C,19C)
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- CentOS8编译安装MySQL8.0.19
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- MySQL8.0.19开启GTID主从同步CentOS8
- CentOS7,8上快速安装Gitea,搭建Git服务器
- Jdk安装(Linux,MacOS,Windows),包含三大操作系统的最全安装
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果
推荐阅读
最新文章
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- CentOS8,CentOS7,CentOS6编译安装Redis5.0.7
- MySQL8.0.19开启GTID主从同步CentOS8
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- SpringBoot2整合Redis,开启缓存,提高访问速度
- Windows10,CentOS7,CentOS8安装Nodejs环境
- CentOS7编译安装Cmake3.16.3,解决mysql等软件编译问题
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果
- SpringBoot2更换Tomcat为Jetty,小型站点的福音
- CentOS7设置SWAP分区,小内存服务器的救世主