多线程的性能杀手

博文小编

2023-05-30

假设CPU需要访问一个4字节的整数,但并没有命中cache,接下来该怎么办呢?

你可能会想,这还不简单,把内存中这4字节整数加载到cache中就好了,如图1所示。

图1 将内存数据加载到cache

然而,事实并非如此。

01
cache与内存交互的基本单位:cache line

程序的空间局部性原理告诉了我们什么?

如果你的程序访问了一块数据,那么接下来很可能还会访问与其相邻的数据,因此如果只把需要访问的数据加载到cache是很不明智的,更好的办法是把该数据所在的“一整块”数据都搬到cache中。

这“一整块”数据有一个名字——cache line,很形象,翻译为一行数据,如图2所示。

图2 将内存中的“一整块”数据加载到cache

现在你应该知道了,原来cache与内存之间交互的最小单位是cache line,也就是一整块数据,这一整块的大小通常为64字节,也就是说如果未能命中cache,那么会把包含该数据的一整块内存都加载到cache中。

cache与内存之间的这个交互细节会给多线程编程带来一些匪夷所思但又极其有趣的问题。

cache的理论部分至此全部讲解完毕,是时候检验成果了,接下来我们看几段很有趣的代码。

02
性能杀手一:cache 乒乓问题

有这样两个C++程序,第一个程序:

这两个程序都很简单,第一个程序开启两个线程,每个线程都对全局变量a加 500000000次;第二个程序只有一个线程,对全局变量a加1000000000次。

你觉得哪个程序运行得更快?

你可能会这样想:第一个程序是两个线程并行对a进行加法操作,而第二个程序是单个线程在运行,所以第一个程序要更快,并且运行时间只有第二个程序运行时间的一半。

实际结果怎么样呢?

在作者多核计算机上,第一个程序运行了16s,第二个程序只运行了8s,单线程程序竟然要比多线程程序运行得更快,有的读者看到这里可能会大吃一惊,这看上去违背常识,多线程可是在并行计算呀,为什么反而会比单线程慢呢!

让我们再次用Linux下的perf工具分析一下这两个程序,使用perf stat命令来统计其运 行时的各种关键信息,得到的数据如下,图3所示为多线程程序的统计信息,图4所示为单线程程序的统计信息。

图3 多线程程序的统计信息

图4 单线程程序的统计信息

我们注意最右侧“insn per cycle”这一项,该项告诉我们一个时钟周期内CPU执行了该程序中的多少条机器指令,这一项可以从执行机器指令数量的角度给我们一个直观的 关于程序运行速度的信息,就好比汽车的运行速度一样(注意,这仅仅是一个维度,程序的运行速度不仅仅取决于该项)。

多线程程序中的“insn per cycle”为0.15,意思是一个时钟周期内执行了0.15条机器指令;而单线程程序中的“insn per cycle”为0.6,一个时钟周期内执行了0.6条机器指令,其是多线程程序中的4倍。

注意,为了与多线程程序减少差异,在单线程程序中全局变量a也被定义为atomic原子变量,如果我们把单线程程序中的变量a定义为普通的int值,那么在作者的计算机上单线程程序的运行时间仅仅只有2s,速度是多线程程序的8倍,其“insn per cycle”一项的统计数据为1.03,一个时钟周期内能执行一条机器指令。

因此这里的问题就是,多线程程序的性能为什么这么差呢?

如果你真的理解了cache一致性协议的话就不会惊讶了。

我们之前说过,为了保证cache的一致性,如果两个核心的cache中都是用了同一个 变量,就像这个示例中的全局变量a,那么该变量会分别出现在C1 cache和C2 cache中, 而在作者的多核计算机上,操作系统显然有极大概率将两个线程分配给了两个CPU核 心,这里假设为C1和C2,如图5所示。

图5.25 两个核心同时读写变量a

然后,两个线程都需要对该变量执行加1操作,假设此时线程1开始对变量a执行加法操作,如图6所示,为保证cache一致性必须将C2 cache中的变量a置为无效:乒!!!

图6 C1写变量a时需要将C2 cache中的变量a置为无效

此后,C2将不得不从内存中读取变量a的值,然而C2也需要将全局变量a加1,如图7所示,为保证cache一致性必须将C1 cache中的变量a置为无效:乓!!!

图7 C2写变量a时需要将C1 cache中的变量a置为无效

此后,C1将不得不从内存中读取a的最新值,但不巧的是此后C1又需要继续修改变 量a,又不得不将C2 cache置为无效:乒!!!

就这样C1 cache和C2 cache不断的乒乒乓乓乒乒乓乓……

图8 cache 乒乓问题

频繁地维持cache一致性导致缓存不但没有起到应有的作用反而拖累了程序性能,这就是有趣的cache ping-ponging问题。

在这种情况下,维护cache的开销以及从内存中读取数据的开销占据了主导地位,这样的多线程程序其性能反而不如单线程程序。

这个示例告诉我们,如果有办法避免多线程间共享数据,那么就应该尽量避免。

你可能会想,如果不同享数据肯定就没有问题了吧!哪有那么简单!我们接着往下看。

03
性能杀手二:伪共享问题

有这样一个数据结构data,我们用其定义了一个全局变量:

这个程序开启了两个线程,分别对结构体中的变量a和变量b加500000000次。

第二个程序:

第二个程序是单线程的,同样也是对变量a和变量b加500000000次。

现在问你哪个程序运行得更快,快多少?

你吸取上一个示例中的经验,仔细看了下,这两个线程没有共享任何变量,因此不 会有上面提到的cache ping-ponging问题,因此你大胆推断:第一个程序运行得快,而且 比第二个单线程程序的运行速度快2倍。

实际结果怎么样呢?

在作者的4核机器上,第一个多线程程序运行耗时3s,第二个单线程程序只用了2s, 有的读者可能会再次大吃一惊,为什么充分利用多核且没有共享任何变量的多线程程序 竟然还是比单线程程序运行得慢?

原来,尽管这两个线程没有共享任何变量,但这两个变量极有可能位于同一个cache line上,也就是说这两个变量可能会共享同一个cache line。不要忘记,cache和内存之间 是按照cache line为单位来交互的,当访问变量a未能命中cache时,会把a所在的cache line一并加载到缓存中,而变量b极有可能也会被加载进来,如图9所示。

图9 变量a、b可能位于同一个cache line,并同时加载到cache

也就是说,尽管看上去这两个线程没有共享任何数据,但cache的工作方式导致其可能会共享cache line,这就是有趣的False Sharing问题,直译过来就是伪共享,这同样会导致cache ping-ponging问题。

现在你应该明白为什么多线程程序运行得慢了吧。

知道了原因改进也就非常简单,这里仅提供一种思路,在这两个变量之间填充些无用数据就好了,就像这样:

由于作者机器的cache line大小为64字节,因此在变量a和变量b之间填充了一个包含16个元素的int 数组,这样变量a和变量b被数组隔开而不会位于同一个cache line了,如图10所示。

图10 将变量a和b隔离在不同的 cache line中

修改代码再次测试,这次多线程程序的运行时间从原来的3s降低到了1s,这下多线程程序真的就比单线程程序快一倍了。

当然,除了在变量之间填充数据,也可以调整变量顺序,假设你确定了是变量a和变 量b导致的false sharing问题,并且该结构体中还有其他变量,就像这样:

这样变量a和变量b就不会共享同一个cache line了。

在本节中,我们认识了两位多线程程序的性能杀手:一个是cache乒乓问题,一个是伪共享问题,其中cache乒乓问题是由多线程共享资源导致的,而伪共享同样也会导致 cache乒乓问题。

在本节的实验中也可以看到,如果你的多线程程序出现性能瓶颈,仔细进行性能测试且排除掉其他可能依然找不出问题原因的话那么就要警惕这里的cache 乒乓问题了, 通常该问题可以通过避免线程间共享资源来解决,但避免多线程共享资源的同时也要确保不掉进伪共享的陷阱里。

怎么样,看似简单的cache确实给计算机的方方面面带来了不小的影响吧!

本文节选自《计算机底层的秘密》一书,欢迎阅读本书了解更多相关内容。

读者评论

相关专题

相关博文

  • (三)spring cloud云服务架构代码结构详细讲解

    Omaye 2017-11-28

    上一篇我们介绍了spring cloud云服务架构 - particle云架构代码结构,简单的按照几个大的部分去构建代码模块,让我们来回顾一下: 第一部分: 针对于普通服务的基础框架封装(entity、dao、service、co...

    Omaye 2017-11-28
    1283 1 4 4
  • Spring Cloud构建微服务架构—配置中心

    醜人 2017-11-17

    Spring Cloud Config是Spring Cloud团队创建的一个全新项目,用来为分布式系统中的基础设施和微服务应用提供集中化的外部配置支持,它分为服务端与客户端两个部分。其中服务端也称为分布式配置中心,它是一个独立的微服务...

    醜人 2017-11-17
    526 2 2 2
  •  Spring Cloud构建微服务架构—服务容错保护(Hystrix服务降级)

    Spring Cloud构建微服务架构—服务容错保护(Hystrix服务降级)

    醜人 2017-11-17

    在开始使用Spring Cloud Hystrix实现断路器之前,我们先拿之前实现的一些内容作为基础,其中包括: eureka-server工程:服务注册中心,端口:1001 eureka-client工程:服务提供者,两个实例启动...

    醜人 2017-11-17
    502 2 2 2