TCMalloc介绍

 

动机

TCMalloc是一个非常快速的内存管理库,它比glibc 2.3malloc以及其他的一些内存管理库都要更高效。ptmalloc22.8GHz P4机器上执行一次malloc/free(分配释放小的内存块)大约耗时300纳秒。相同的执行操作,TCMalloc的实现只需要大约50纳秒。

TCMalloc同时也为多线程编程减少了锁的竞争,对于小块内存分配,TCMalloc实际上没有锁开销。对于大块内存,TCMalloc使用非常高效的自旋锁。Ptmalloc2通过给每个线程一个内存池来实现减少锁竞争,但是ptmalloc2在使用每线程一个内存池时存在一个较大的问题。它的各个线程的内存池分配的内存不可以互相移动。这导致巨大的空间浪费

如何使用

TCMalloc使用起来非常简单,只要在编译链接程序是添加-ltcmalloc链接选项即可。

TCMalloc编译后同时生产动态链接库和静态链接库,应用程序默认是链接动态链接库的,个人不喜欢动态链接,我将编译后的动态链接库移到另一个目录,这样应用程序链接时就链接静态库了。

TCMalloc同时包含了heap checkerheap profiler工具。

如果应用程序不想包含这两个库,那么只链接-ltcmalloc_minimal即可。

TCMalloc总览

TCMalloc的英文全称是:Thead-Caching Malloc。顾名思义,TCMalloc给每个线程都分配一个局部缓存,采用线程局部数据技术。小块内存的分配从线程局部缓存分配即可满足。内存对象根据应用程序需要从中心堆栈移到线程局部缓存中,同时周期性的将线程缓存中过多的内存回收到中心堆栈。


TCMalloc<=32K的内存与大内存块的处理不同。TCMalloc使用页级别(内存页以4k字节对齐)的内存分配器直接从中心堆分配。即,大块内存对象是页对齐的,通常占用整数倍个页面。

一系列的内存页可以被分割为很多大小相同的小内存对象,例如,一个4k内存页面可以被分割成32128字节小内存对象。、

小内存对象的分配

每一个小内存对象都可映射在大约60个可分配的固定大小内存对象池中的一个。例如,所有分配8331024字节大小的内存都将映射到1024。对象池根据大小按照8字节,16字节,32字节,等进行对齐。假设一个应用程序需要分配一个N+1个字节大小的内存对象,而N刚好是一个对齐对象的大小,那么N+1个字节就要被映射到下一个对齐对象N+k,那么k-1个字节就被浪费了。为了防止这种过多的内存被浪费,最大的对齐间隔是被控制的。

每一个线程局部缓存对保存了每一个空闲对齐对象的单链表,如下图:


小内存对象分配过程:

1) 将要分配的大小映射到对应的对齐对象。

2) 在当前线程的局部缓存中查找该对齐对象链表。

3) 如果该链表不为空,删除链表第一个节点并返回给调用者。

通过上述快速的过程,TCMalloc没有请求任何锁。无锁机制显著的提高了内存分配速度,因为一对lockunlock操作在2.8GHzXeon上大概会耗时100纳秒。

如果空闲链表是空的:

1) 从中心堆链表取出一大串该对齐对象,中心堆栈被所有线程共享。

2) 把这些对象添加到线程局部缓存的链表中。

3) 返回一个新取到的对象。

如果中心堆栈链表也是空的:

1) 从中心页面分配器中分配一系列页面。

2) 将这些页分割成该对齐对象的大小。

3) 将分割后的对齐对象添加到中心堆栈链表。

4) 如前所述,从中心堆栈的链表移动一些到线程局部缓存链表中去。

调整线程局部缓存链表的大小

调整线程局部缓存链表到合适的大小是非常重要的。如果链表太小,那么就需要经常去中心共享的链表中取对象。如果局部缓存的链表太大,又浪费了太多的空闲内存。

需要注意的是线程局部缓存对释放对象也同分配对象一样重要。如果没有线程局部缓存,每一次释放内存对需要将内存对象移动到中心共享链表中去。同时一些线程的分配和释放并不是对称的(例如生产者和消费者线程),因此调整局部缓存链表的到一个合适的大小变得更加的复杂了。

为了合适的调整局部缓存空闲链表的大小,TCMalloc使用了慢速启动算法来决定各个线程的空闲链表最大长度。因为空闲链表被频繁的使用,它的最大长度就增大。然而,如果空闲链表被内存释放使用多于内存分配使用,它的最大长度将只增长到一个点,在这个点的时候整个链表可以高效的被一次移动到中心共享链表中去。

下面这段伪代码将说明慢速启动算法。需要注意的是num_objects_to_move针对每一种对齐的内存对象。通过使用共识的长度移动空闲链表,中心共享缓存可以高效在这些线程缓存直接传递链表。如果一个线程缓存想要获得比num_objects_to_move少的内存快个数,中心缓存上的操作只有线性复杂度。经常使用 num_objects_to_move作为中心缓存链表传入或传出对象的个数在这些并不需要这么多内存的线程中就会浪费内存。

Start each freelist max_length at 1.

Allocation

  if freelist empty {

    fetch min(max_length, num_objects_to_move) from central list;

    if max_length < num_objects_to_move {  // slow-start

      max_length++;

    } else {

      max_length += num_objects_to_move;

    }

  }

Deallocation

  if length > max_length {

    // Don't try to release num_objects_to_move if we don't have that many.

    release min(max_length, num_objects_to_move) objects to central list

    if max_length < num_objects_to_move {

      // Slow-start up to num_objects_to_move.

      max_length++;

    } else if max_length > num_objects_to_move {

      // If we consistently go over max_length, shrink max_length.

      overages++;

      if overages > kMaxOverages {

        max_length -= num_objects_to_move;

        overages = 0;

      }

    }

  }

参加垃圾回收章节描述它如何影响max_length

大内存对象的分配

大对象(>32K)的大小被规整到页面(4K)对齐并且被一个中心页面堆所处理。中心页面堆是一个空闲列表数组。对于i<256,第k项就是一个由包含K个页面的系列组成的空闲链表。第256项是由包含超过256个页面系列组成的空闲列表。


分配k个页面只需要在第k个空闲链表中查找即可满足。如果该链表为空,再下一个链表中查找,以此类推。如果前面都查找失败,那么就最后的链表中查找。如果依然失败,我们就从系统中获取内存(使用sbrk, mmap, 或者映射部分/dev/mem

如果从长度大于k个连续页面中分配k个页面,那么剩余的系列页面将被重新插入到中心堆栈中合适的空闲链表中去。

Spans

TCMalloc的堆管理是由一系列页面组成。一系列连续的页面被称作为一个Spans对象。一个Spans可以被分配了的也可以是空闲的。如果是空闲的,该Spans是堆栈链表中的某一项。如果是被分配的,它是一个已经被移交给应用程序的大对象,或者是已经被切割成一组小对象的系列页面。如果被分割成小对象,这些小对象的大小会被记录在spans中。

由页面号索引的中心数组可以被用来查找一个页面所属的spans。例如,span a占据2个页面,span b占据一个页面,span c占据5个页面和span d占据3个页面。


在32位地址空间中,中心数组用一个2层的基数树来表示,树的根节点包含32项,每个叶子节点包含2^15项(一个32位的地址空间包含了2^20 4k页面,所以这里的树的第一层用2^5整除2^20个页面)。这导致中心数组一开始就要使用128K内存(2^15*4 bytes,这看起来还可以接受。

64位机器上就要使用三层的基数树了。

内存释放

当一个内存被释放时,我们计算它的页号并在中心数组中查找其对应的Span对象。该Span告诉我们该对象是不是小对象,如果是小对象还告诉我们它的对齐对象的尺寸。如果是小对象,我们就把它插入到当前线程对应的线程局部缓存中去。如果该线程局部缓存现在超过了预设置的大小(默认2MB),我们就执行垃圾回收将不使用的内存从线程局部缓存移到中心堆栈链表中去。

如果是大对象,span会告诉我们该对象覆盖页面的范围。假设该范围是[p,q]。同时我们也去查找page p-1page q+1span。如果这些相邻的span也是空闲的,我们将他们和[p,q] span合并。合并的结果插入到中心堆栈合适的链表中去。

小对象的中心空闲链表

如前所述,我们为每一种尺寸的对齐对象保存一个中心空闲链表。每一个中心空闲链表由一个两层的数据结构组成:一个Spans集合,每个span有一个链表。

从中心空闲链表分配数据时直接返回一些span的链表第一个节点。(如果中心链表的所有spans均为空的,那么就从中心页堆中分配合适大小的span

当中心空闲链表回收对象时,把该对象添加到它对应的span的链表中去。如果此刻链表的长度等于span中所有小对象的个数,那么该span就是完全空闲的了,它将被中心页堆回收。

线程局部缓存的垃圾回收

从线程局部缓存进行对象的垃圾回收保证了缓存的大小可控并返回这些对象到中心空闲链表。一些线程需要很大的缓存,而另一些线程可能需要很小或者不需要缓存。当一个线程局部缓存的大小超过了它的max_size,垃圾回收开始执行,同时一些线程与另一些线程竞争更大的缓存。

垃圾回收只发生在内存释放时。我们遍历缓存中的空闲链表,并且移动一些对象到其对应的中心空闲链表。

从缓存的空闲链表移除的对象个数取决于每个链表的低阈值LL记录了从上次垃圾回收以来链表最小的长度。需要注意的是,我们可能在上一次垃圾回收中只是把空闲链表缩短了L个对象,而没有对象中心空闲链表进行任何额外的访问。我们使用过去的历史来预测未来的访问,并且将L/2个对象从局部缓存链表移动到其对应的中心空闲链表。该算法有一个非常好的特性,即当某个线程不再使用某一种大小的对齐对象时,该缓存中所有该对象将被迅速的移到中心空闲链表,这样就可以被其他线程所使用。

如果一个线程连续释放某大小的对齐对象的速度超过该对象分配的速度,这种L/2的行为将导致始终有L/2个对象在空闲链表中。为避免这种内存浪费,我们收缩链表的最大长度向num_objects_to_move集中靠拢。(参靠 调整线程局部缓存链表大小)

Garbage Collection

  if (L != 0 && max_length > num_objects_to_move) {

    max_length = max(max_length - num_objects_to_move, num_objects_to_move)

  }

事实上如果线程局部缓存向超过它的max_size就表示该线程将要需要更大的缓存。简单的增加max_size将使拥有大量活动线程的程序过度使用大量的内存。开发者可以使用flag --tcmalloc_max_total_thread_cache_bytes来限制内存。

每一个线程的起始max_size非常小(64k),因为空闲线程不需要预分配内存,因为他们不需要。每次缓存执行垃圾回收,它就会尝试增大max_size。如果线程缓存中所有对齐对象的大小之和小于tcmalloc_max_total_thread_cache_bytesmax_size增长得很容易。如果不是,线程缓存1将通过减少线程缓存2max_size来从缓存2偷取(循环获取)。通过这种方法,比较活跃的线程往往比窃取自己内存更加频繁的窃取其他线程的内存。通常空闲的线程止于小缓存,活跃的线程止于大缓存。需要注意的是,这种窃取将导致所有线程的缓存的大小大于--tcmalloc_max_total_thread_cache_bytes 直到线程缓存2释放一些内存来触发垃圾回收。

展开阅读全文

没有更多推荐了,返回首页