多线程编程中一个看似无害的优化,竟能让程序慢上5倍。我把一个简单的需求丢给AI助手:创建四个线程,每个线程只递增数组里属于自己的计数器,互不干扰。代码干净极了: ```csharp long[] counters = new long[threadCount]; // 线程 t 执行: counters[t]++ ``` 每个线程只写自己的槽位,毫无竞态可言。任何代码审查都会通过,所有测试也会通过。可当我把这段代码和同样逻辑、却加入内存填充的版本进行对比时,正确的那份却输了——慢了整整5倍。 我是在自己的机器上进行的测试(Apple Silicon,.NET 10,4线程,每个线程执行2亿次递增)。结果令人瞠目: - 第1轮:相邻存放(共享同一缓存行):464 毫秒 填充版本(每线程独占一行):85 毫秒 两者速度比:5.4倍 - 第2轮:相邻存放:397 毫秒 填充版本:85 毫秒 速度比:4.7倍 循环逻辑完全一样,递增次数完全一样,同样是“每个线程只碰自己的计数器”。唯一的不同,就是计数器在内存里的摆放位置。 为什么会这样?因为CPU从不一次只从内存读一个字节。它是以固定大小的块来搬运数据的,这个块叫作**缓存行**。在x86架构上是64字节,而Apple Silicon上则是128字节。哪怕你只要一个`long`,整个缓存行都会被拖进核心的私有缓存里。就像厨师去地下冷库取食材:下去一趟太贵了,不会只拿一根胡萝卜,而是把整箱搬上来。 通常情况下,这对我们有利。数组元素肩并肩排着,遍历数组非常迅速——你碰了一个元素,相邻的十几个元素就免费跟着同一“箱子”进来了。可一旦涉及多个核心,问题就来了。规则很简单:要想往一个缓存行里写入数据,核心必须**独占**这一整行。核心1一写入,其他所有核心里这份缓存行的副本就立刻被标为失效。而独占权的单位,不是你的变量,是整条缓存行。 现在回看我的四个计数器:四个`long`值,每个8字节,总共32字节,刚好全挤在一条128字节的缓存行内。线程1在自己的槽位写一个数字,整行就被核心1独占,其他核心若也想写自己槽位,就必须先抢夺所有权——缓存行像烫手山芋一样在核心之间来回奔波。表面上是四个独立变量,背后却是四个核心在争抢同一条缓存行的所有权。这就是所谓的**伪共享**,性能杀手藏在最无辜的代码里。 解决办法简单得不可思议:把计数器拉开距离,让每个线程独占一条缓存行。用填充字段撑满128字节,四个计数器变成四条各自独立的缓存行。此后,每个核心可以真正独立地写自己的行,无需再为看不见的所有权打架。仅仅是调整内存布局,速度就暴涨5倍。 这次的教训很清晰:多线程性能不只看逻辑是否正确,还要看数据是否真的“独立”到了硬件层面。下次写出干净的并发代码时,记得多问一句——这些变量是不是又偷偷挤在同一辆昂贵的“缓存行班车”上了。
![]()
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.