此文章为《百度C++工程师的那些极限优化》文章的笔记总结

tcmalloc和jemalloc的内存优化

这里不细说两者的原理,仅作为例子引入

jemalloc 工具 jemalloc tcmalloc区别_多线程

竞争性

这两个内存分配库在多线程的角度做了优化。jemalloc和tcmalloc都针对每个线程分配了一段独立缓存进行申请和释放,这样就避免了在多线程环境下存在的内存分配竞争问题。

而两个库之间存在的区别在于,当线程的缓存被击穿时,tcmalloc是从一个全局唯一的heap中进行分配,而jemalloc则是分为了若干的arena来进行分配。这种将内存分片的方法可以有效降低多线程下线程内存缓存被击穿所带来的竞争开销。这也是jemalloc在多线程环境下性能比tcmalloc好的原因之一。

连续性

既然线程的缓存被击穿会存在全局的竞争问题,那我们尽可能的增大线程的缓存空间是不是能够进一步提升分配的性能呢?

实际上提升是很有限的,这就引入了另一个概念,内存的连续性问题。

熟悉计算机基础的同学都清楚,内存是局部性,访问的内存的附近,大概率是后续访问的热点。所以硬件对连续的内存有很好的支持,在CPU会将访问的附近的内存进行缓存。

回到上面的问题,我们给每个线程分配的缓存,实际上会被切割成一块一块的内存块通过链表等数据结构进行组织,在我们频繁的分配和释放的过程中,实际上我们的内存块之间已经不再连续。如果在线程的缓存分配比较小的情况下,即使内存块不连续,但依然有很大概率前后之间的内存被CPU所缓存。相反,如果每个线程分配的缓存过大,内存块之间访问命中CPU缓存的概率越低,因此导致了负优化。

jemalloc 工具 jemalloc tcmalloc区别_缓存_02


上图表现了,在内存的频繁分配和释放中,内存已不再连续,cache命中率大幅度降低。换一种说法,如果上图中的容器大小变为2,那么无论怎么分配释放,内存都会被cpu cache所命中。

malloc小结

jemalloc和tcmalloc通过对线程做内存分配的缓存,不仅减少了线程间的竞争问题,同时顾及了内存的局部性原理,对CPU友好。

上层的内存优化

理想的内存分配模型

所谓理想的内存分配模型,实际上就是能够遵循上面所说的两个概念,即竞争足够的小,内存足够连续。如果深入了解的话,如果仅凭借malloc,可以发现如今的后台架构很难满足这两种特性。

在最早的线程池模型下,每个线程闭环处理每一个任务,一个任务的内存分配和释放,都是在一个线程中进行,这种情况下,对malloc时友好的,任务的生命周期只会在一个线程中,这种模型完美满足的竞争性和连续性。

jemalloc 工具 jemalloc tcmalloc区别_java_03


而如今微服务、流式计算、缓存,这几种业务模型几乎涵盖了所有主流的后端服务场景。每一个任务都会被拆分成多个小任务利用多核的特性去并发处理。这就导致了我们会任意的在不同的线程进行随机的分配和释放,malloc的线程缓存的优化效果就十分有限了。

jemalloc 工具 jemalloc tcmalloc区别_java_04

造成这一情况的根本原因就在于我们模型的转变,jemalloc和tcmalloc是已线程为单位,而如今的架构是已一个job为单位,一个job会在多个线程中轮转,每个线程只是负责子job的工作。所以我们需要的是job级别的内存缓存,而不是线程级别的缓存。然而这对于malloc来说是无法实现的,因为业务对于malloc是透明的,因此,便需要用户态来实现相关的缓存实现了。

job anena

我的理解,这就是很多应用层对象池或者资源池的原理。每个job有一个独立的内存分配器,job中使用的动态内存注册到job的arena中。因为job生命周期明确,中途释放的动态内存被认为无需立即回收,也不会显著增大内存占用。在无需考虑回收的情况下,内存分配不用再考虑分块对齐,每个线程内可以完全连续。最终job结束后,整块内存直接全部释放掉,大幅减少实际的竞争发生。

STL的allocator和protobuf的Arena就是这么做的,对于每一个job,有会分配一块空间用于内存的分配和回收,他与不同的job之间不存在竞争关系,同时相同job分配的对象在一块连续的空间中。使其能够同时兼顾竞争性和连续性,提升性能~

---------------------分割线-----------------------

false sharing

首先介绍CPU缓存的一些简单概念:

  1. CPU的L1、L2缓存都是每个核独占的,L3是共享的
  2. CPU的缓存是以cache line为单位划分,每个cache line为64字节
  3. cache line通过MESI协议来保证一致性,即每个cache line存在Modified、Exclusive、Shared 和Invalid四种状态。状态机的转换很复杂,关键的点就是当我们在cache line上修改一个变量时变为Modified,刷回主存后变为Exclusive,Exclusive可以被其他CPU读取变为Shared,但当其中一个CPU修改的时候,其他CPU的cache line就变为了Invalid。

jemalloc 工具 jemalloc tcmalloc区别_多线程_05


当两个独立的变量在一个cache line的时候,这时两个CPU分别读取这两个变量,在逻辑上,这两个CPU不存在任何的竞争关系,但因为这两个变量在一个cache line下,导致了这两个CPU都缓存了这个cache line,当CPU0修改变量的值时,通过一致性协议,会导致CPU1的cache line失效,就会重新从主存刷最新的数据。而CPU1的修改,也会影响CPU0的cache line的状态。这样就导致了在逻辑上完全独立的两个变量的修改,却造成了缓存的反复刷新,影响性能,这就是false sharing。

解决方法:以空间换时间,cache line大小为64字节,所以我们的结构体只要做到64字节对齐,不足64字节则用空白填充,就能保证这个结构体创建的对象不会被其他共用一个cache line,从而避免false sharing。C/C++中通过ALIGNAS关键字来设置结构体的对齐大小。

缓存一致性

首先看一下上面简单介绍的MESI协议的流程大概是怎样的

jemalloc 工具 jemalloc tcmalloc区别_java_06


MESI协议并不是本文的重点,上图大体想表达的是,当竞争写入发生时,需要竞争所有权,未获得所有权的核心,只能等待同步到修改的最新结果之后,才能继续自己的修改,这就类似于我们多线程编程下修改同一个变量,我们需要通过锁这类方式来同步保证一致性修改,这就会导致没有获得锁的线程进行等待。

jemalloc 工具 jemalloc tcmalloc区别_缓存_07


如上图,CPU的缓存一致性与主存类似,只不过主存是cpu1等待cpu2核写完,cache是当更新了一个共享的cache line时,需要将其他CPU所共享的cache line都置为invalid后才能完成更新。这里缓存为了避免阻塞影响后面的工作,从而引入了store buffer和invalid buffer的概念。

jemalloc 工具 jemalloc tcmalloc区别_jemalloc 工具_08


CPU可以在更新值等待其他CPU invalid回包的等待过程缓存到store buffer中,然后继续去做其他工作,提升效率。这就表示了后续的读操作很可能先于写操作执行,虽然对于缓存来说数据依然是一致的,但是对于代码逻辑上,实际上发生了指令的乱序执行,导致storeload操作变为了loadstore,而对于有些架构,甚至不能保证store buffer的FIFO,所以可能还存在storestore的乱序。store buffer提升了一定的执行效率,但是store buffer仍需要等待其他CPU将写缓存失效,随着CPU个数增加,这种强一致的协议容易导致store buffer的阻塞,这时就引入了invalid queue。

jemalloc 工具 jemalloc tcmalloc区别_java_09


只要将写缓存失效的操作写入invalid queue中,就可以立刻回包,避免store buffer等待回包造成的阻塞。这也表明了,在一个CPU消费完invalid queue中的失效操作前,他会读到失效的消息,破环了缓存的一致性。

ps:我的理解,MESI协议保证了一致性,但是由于store buffer和invalid buffer的引入,导致MESI协议变为了最终一致性的协议,且他只能保证数据的最终一致,即写进内存的为1,那他就不会变成2或者3,但是他不能保证强一致。两个点,第一个是这个1不会被其他CPU立即读到,会读到旧数据。第二个是他不保证指令的顺序,只保证最终的结果一致,所以依赖这个1作为条件变量去判断很有可能是错的。

因此内存屏障应运而生,写屏障用于保证store buffer消费完后,即写操作全部更新完后再继续执行,读屏障用于保证invalid buffer消费完后,即失效操作全部更新完后再继续执行。由于这些操作与硬件强相关,所以c++的memory order便出现了。

memory order

对应上面的写屏障和读屏障就是release和acquire

int payload = 0;
std::atomic<int> flag {0};
void release_writer(int i) {
    payload = flag.load(std::memory_order_relaxed) + i;
    flag.store(1, std::memory_order_release);
}
int acquire_reader() {
    while (flag.load(std::memory_order_acquire) == 0) {
    }
    return payload;
}

release表示,执行该指令之前的,store buffer必须处理完,所以该指令执行之前的写操作一定是一致的。acquire表示,执行该指令之前,invalid buffer必须处理完,所以该指令执行之前的读到的变量一定是一致的。

而memory order中还有一个最强的级别,即sequentially-consistent,代表这cache line完全遵循MESI协议,所有buffer必须消费完才能执行,是一个强一致的级别。具体memory order的其他级别不详细介绍了,这里只是探讨其原理。

在多核编程为了提升性能,引入大量缓存来避免阻塞,从而带来的指令乱序的问题。而这个问题只能通过程序员去解决,如果通过加锁的方式可以避免指令乱序,但如果需要提升性能,在无锁编程下一定需要考虑内存序的问题,这就需要程序员有非常强的多核编程经验,也是我所要去追求的。

回到false sharing的问题

在探讨false sharing问题时,我们还不是很明白CPU cache的一致性原理,只知道对共享的cache line更新会导致其他CPU的cache line失效,从而导致性能问题。但当我们深入了解cache line的原理机制后,我们知道cache line通过store buffer和invalid buffer来异步的处理失效的问题,所以一致性问题对于性能的影响其实大大减小了。

看一组我从原文章copy的数据

1、多线程竞争写入近邻地址sequentially-consistent:0.71单位时间
2、多线程竞争写入近邻地址release:0.006单位时间
3、多线程竞争写入cache line隔离地址sequentially-consistent:0.38单位时间
4、多线程竞争写入cache line隔离地址release:0.02单位时间

直接抛出结论,在强一致的条件下,cache line隔离可以有效提升性能,因为避免了false sharing问题。而release级别下,buffer起到了对数据一致的缓存作用,从而缓解了false sharing带来的竞争问题,而cache line隔离带来的传输交互反而成为了更大的开销。

小结一下:在没有多线程竞争的情况下,通过内存对齐进行隔离可以避免false sharing的问题,而在多线程竞争的情况下,如果对于内存序的要求没那么高的话,false sharing的开销可能比cache line隔离带来的读取开销更小,不过我们仍需要通过内存对齐保证这两个变量在同一个cache line中。而在sequentially-consistent级别下,cache line强一致,又有可能提升false sharing的成本。

总结

曾经的我对于cpu cache只是浮于表面的理解,只知道有局部性啊,会指令乱序,但从不知其原理。百度这篇文章让我醍醐灌顶,对cpu的理解更近了一个层次,而我自己的学习还任重而道远,成为一个优秀的程序员还有很长的路要走,学计算机真有意思啊~