C0nvR3 Lab

Search workspace

Keyboard shortcuts

Ctrl + K
Open search
Esc
Close menu or dialog

research\large_tcache\index.md

2.4x 的 tcache 机制分析

Table of contents

简述

在 2.39 到 2.40 中,malloc.c 并未发生变化,因此我们这里主要讲一些 2.41 到 2.43 的 tcache 相关的一些小细节 在这三个版本中,主要变化可以简单概括为

  • 2.41:
    • calloc 可以分配 tcache 块
    • 小 chunk 被 free 后会直接进入 small bin
  • 2.42
    • large_tcache 的引入
  • 2.43
    • tcache 扩容以补偿 fastbin 删除的性能问题
    • large_tcache 分配改为需要精确 chunk_size
    • mmap chunk 可以进入 tcache

因为 tcache 初始化时机这几个版本一直改来改去的很乱,只做简述 以下为细分的分析,我会粘贴一些源码辅助理解

tcache 基本机制

tcache 结构体的变化

在 glibc 2.42版本中,tcache_perthread_struct 结构进行了调整

  • 2.41
/* We overlay this structure on the user-data portion of a chunk when
   the chunk is stored in the per-thread cache.  */
typedef struct tcache_entry
{
  struct tcache_entry *next;
  /* This field exists to detect double frees.  */
  uintptr_t key;
} tcache_entry;

/* There is one of these for each thread, which contains the
   per-thread cache (hence "tcache_perthread_struct").  Keeping
   overall size low is mildly important.  Note that COUNTS and ENTRIES
   are redundant (we could have just counted the linked list each
   time), this is for performance reasons.  */
typedef struct tcache_perthread_struct
{
  uint16_t counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

static __thread bool tcache_shutting_down = false;
static __thread tcache_perthread_struct *tcache = NULL;
  • 2.42
/* We overlay this structure on the user-data portion of a chunk when
   the chunk is stored in the per-thread cache.  */
typedef struct tcache_entry
{
  struct tcache_entry *next;
  /* This field exists to detect double frees.  */
  uintptr_t key;
} tcache_entry;

/* There is one of these for each thread, which contains the
   per-thread cache (hence "tcache_perthread_struct").  Keeping
   overall size low is mildly important.  The 'entries' field is linked list of
   free blocks, while 'num_slots' contains the number of free blocks that can
   be added.  Each bin may allow a different maximum number of free blocks,
   and can be disabled by initializing 'num_slots' to zero.  */
typedef struct tcache_perthread_struct
{
  uint16_t num_slots[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

entry 的部分基本没有变化,这里主要变化还是原本的 counts 数组变成了 num_slots 数组 原本的 counts 数组表示对应索引下 tcache 链表的长度,现在 num_slots 数组中变为了对应索引下设剩下的空槽位

  • 2.41
/* Caller must ensure that we know tc_idx is valid and there's room
   for more chunks.  */
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

  /* Mark this chunk as "in the tcache" so the test in _int_free will
     detect a double free.  */
  e->key = tcache_key;

  e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

/* Caller must ensure that we know tc_idx is valid and there's
   available chunks to remove.  Removes chunk from the middle of the
   list.  */
static __always_inline void *
tcache_get_n (size_t tc_idx, tcache_entry **ep)
{
  tcache_entry *e;
  if (ep == &(tcache->entries[tc_idx]))
    e = *ep;
  else
    e = REVEAL_PTR (*ep);

  if (__glibc_unlikely (!aligned_OK (e)))
    malloc_printerr ("malloc(): unaligned tcache chunk detected");

  if (ep == &(tcache->entries[tc_idx]))
      *ep = REVEAL_PTR (e->next);
  else
    *ep = PROTECT_PTR (ep, REVEAL_PTR (e->next));

  --(tcache->counts[tc_idx]);
  e->key = 0;
  return (void *) e;
}
  • 2.42
/* Caller must ensure that we know tc_idx is valid and there's room
   for more chunks.  */
static __always_inline void
tcache_put_n (mchunkptr chunk, size_t tc_idx, tcache_entry **ep, bool mangled)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

  /* Mark this chunk as "in the tcache" so the test in __libc_free will
     detect a double free.  */
  e->key = tcache_key;

  if (!mangled)
    {
      e->next = PROTECT_PTR (&e->next, *ep);
      *ep = e;
    }
  else
    {
      e->next = PROTECT_PTR (&e->next, REVEAL_PTR (*ep));
      *ep = PROTECT_PTR (ep, e);
    }
  --(tcache->num_slots[tc_idx]);
}

/* Caller must ensure that we know tc_idx is valid and there's
   available chunks to remove.  Removes chunk from the middle of the
   list.  */
static __always_inline void *
tcache_get_n (size_t tc_idx, tcache_entry **ep, bool mangled)
{
  tcache_entry *e;
  if (!mangled)
    e = *ep;
  else
    e = REVEAL_PTR (*ep);

  if (__glibc_unlikely (misaligned_mem (e)))
    malloc_printerr ("malloc(): unaligned tcache chunk detected");

  void *ne = e == NULL ? NULL : REVEAL_PTR (e->next);
  if (!mangled)
    *ep = ne;
  else
    *ep = PROTECT_PTR (ep, ne);

  ++(tcache->num_slots[tc_idx]);
  e->key = 0;
  return (void *) e;
}

从两个不同版本的源码中对 tcache 的 get 和 put 操作来看,counts 到 num_slots 主要是一个语义反转的变化

malloc_par 结构体的变化

因为这两个结构体比较大这里就只保留关键部分

  • 2.41
struct malloc_par
{
#if USE_TCACHE
  /* Maximum number of buckets to use.  */
  size_t tcache_bins;
  size_t tcache_max_bytes;
  /* Maximum number of chunks in each bucket.  */
  size_t tcache_count;
  /* Maximum number of chunks to remove from the unsorted list, which
     aren't used to prefill the cache.  */
  size_t tcache_unsorted_limit;
#endif
};
  • 2.42
struct malloc_par
{
#if USE_TCACHE
  /* Maximum number of small buckets to use.  */
  size_t tcache_small_bins;
  size_t tcache_max_bytes;
  /* Maximum number of chunks in each bucket.  */
  size_t tcache_count;
  /* Maximum number of chunks to remove from the unsorted list, which
     aren't used to prefill the cache.  */
  size_t tcache_unsorted_limit;
#endif
};

2.41 中的 tcache_bins 被替换为了 tcache_small_bins ,在 mp_ 调整 tcache_small_bins 仅可以控制 small_tcache,而是否允许 large tcache,主要由 tcache_max_bytes 控制

tcache_init()变化

在 2.41 中

static void
tcache_init(void)
{
  mstate ar_ptr;
  void *victim = NULL;
  const size_t bytes = sizeof (tcache_perthread_struct);

  if (tcache_shutting_down)
    return;

  arena_get (ar_ptr, bytes);
  victim = _int_malloc (ar_ptr, bytes);
  if (!victim && ar_ptr != NULL)
    {
      ar_ptr = arena_get_retry (ar_ptr, bytes);
      victim = _int_malloc (ar_ptr, bytes);
    }


  if (ar_ptr != NULL)
    __libc_lock_unlock (ar_ptr->mutex);

  /* In a low memory situation, we may not be able to allocate memory
     - in which case, we just keep trying later.  However, we
     typically do this very early, so either there is sufficient
     memory, or there isn't enough memory to do non-trivial
     allocations anyway.  */
  if (victim)
    {
      tcache = (tcache_perthread_struct *) victim;
      memset (tcache, 0, sizeof (tcache_perthread_struct));
    }

}

主要是获取 arena ,调用 _int_malloc() 分配 sizeof(tcache_perthread_struct) 并 memset 为零 清零后刚好对应

counts[i] = 0
entries[i] = NULL

表示所有 bin 都为空

2.42 中

/* Initialize tcache.  In the rare case there isn't any memory available,
   later calls will retry initialization.  */
static void
tcache_init (void)
{
  if (tcache_shutting_down)
    return;

  /* Check minimum mmap chunk is larger than max tcache size.  This means
     mmap chunks with their different layout are never added to tcache.  */
  if (MAX_TCACHE_SMALL_SIZE >= GLRO (dl_pagesize) / 2)
    malloc_printerr ("max tcache size too large");

  size_t bytes = sizeof (tcache_perthread_struct);
  tcache = (tcache_perthread_struct *) __libc_malloc2 (bytes);

  if (tcache != NULL)
    {
      memset (tcache, 0, bytes);
      for (int i = 0; i < TCACHE_MAX_BINS; i++)
	tcache->num_slots[i] = mp_.tcache_count;
    }
}

通过内部的 _libc_malloc2() 分配结构,清零整个结构,在新语义中表示没有剩余槽位,也就是 bin 已满或被禁用,而不是 bin 为空,所以需要显式设置 tcache->num_slots[i] = mp.tcache_count

large_tcache 机制

这里主要讨论 x86-64 架构下的 large_tcache 相关机制

large_tcache 索引算法

在 glibc 2.42 的版本中,large_tcache 被添加了进来。tcache 机制与 2.41 相比,有了small_tcache和large_tcache的区别 附上 tcache 相关的宏的对比

  • 2.41
#if USE_TCACHE
/* We want 64 entries.  This is an arbitrary limit, which tunables can reduce.  */
# define TCACHE_MAX_BINS		64
# define MAX_TCACHE_SIZE	tidx2usize (TCACHE_MAX_BINS-1)
  • 2.42
#if USE_TCACHE
/* We want 64 entries.  This is an arbitrary limit, which tunables can reduce.  */
# define TCACHE_SMALL_BINS		64
# define TCACHE_LARGE_BINS		12 /* Up to 4M chunks */
# define TCACHE_MAX_BINS	(TCACHE_SMALL_BINS + TCACHE_LARGE_BINS)
# define MAX_TCACHE_SMALL_SIZE	tidx2usize (TCACHE_SMALL_BINS-1)

注意到原本的 TCACHE_MAX_BINS 为 64 被移动到了新的 TCACHE_SAMLL_BINS ,同时使得新定义的 TCACHE_MAX_BINS 变为了 TCACHE_SMALL_BINS 与 TCACHE_LARGE_BINS 的和,也就是说默认情况下,TCACHE 数组的长度来到了 64+12 = 76 个 从 tcache 的 idx = 0 到 idx = 63 排列,仍然为 chunk_size 为0x20 到 0x420 的大小的 tcache ,也就是现在的small_tcache

新的 large_tcache 的大小索引与 small_tcache 有了一个巨大的变化,同一个索引下会有多个不同大小的 tcache chunk,具体的范围计算如下

static __always_inline size_t
large_csize2tidx(size_t nb)
{
  size_t idx = TCACHE_SMALL_BINS
	       + __builtin_clz (MAX_TCACHE_SMALL_SIZE)
	       - __builtin_clz (nb);
  return idx;
}

这里的 MAX_TCACHE_SMALL_SIZE 经过计算也即是刚刚的 0x420 ,TCACHE_SMALL_BINS 为 64, nb 为目标 chunk_size 我们可以简单的写一个程序计算不同大小的 chunk 对应的 idx

//不考虑对齐的情况下,仅计算large_tcache范围
#include <stdio.h>
#include <stdlib.h>

#define MAX_TCACHE_SMALL_SIZE 0x420

size_t calc_large_tcache_idx(size_t size){
  size_t idx;
  idx = 64 + __builtin_clz(MAX_TCACHE_SMALL_SIZE) - __builtin_clz(size);

  return idx;
}

int main(){
  size_t size;
  size_t ret_idx;
  
  scanf("%lx",&size);
  ret_idx = calc_large_tcache_idx(size);
  printf("%ld",ret_idx);
}

因此,我们可以简单的列一个表

tcache idxchunk size 范围对应 malloc 请求范围
640x420 - 0x7f00x409 - 0x7e8
650x800 - 0xff00x7e9 - 0xfe8
660x1000 - 0x1ff00xfe9 - 0x1fe8
670x2000 - 0x3ff00x1fe9 - 0x3fe8
680x4000 - 0x7ff00x3fe9 - 0x7fe8
690x8000 - 0xfff00x7fe9 - 0xffe8
700x10000 - 0x1fff00xffe9 - 0x1ffe8
710x20000 - 0x3fff00x1ffe9 - 0x3ffe8
720x40000 - 0x7fff00x3ffe9 - 0x7ffe8
730x80000 - 0xffff00x7ffe9 - 0xfffe8
740x100000 - 0x1ffff00xfffe9 - 0x1fffe8
750x200000 - 0x3ffff00x1fffe9 - 0x3fffe8

有意思的是 malloc.c 中的注释表示最大可以用到 4M 的tcache,事实上严格的 x86-64 最大 chunk size为0x3ffff0,严格的最大普通请求为0x3fffe8

large_tcache 加入后的 tcache 链表插入算法

在 2.41 中

/* Caller must ensure that we know tc_idx is valid and there's room
   for more chunks.  */
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

  /* Mark this chunk as "in the tcache" so the test in _int_free will
     detect a double free.  */
  e->key = tcache_key;

  e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

从链表插入都是从链表头部插入

在 2.42 中

/* Caller must ensure that we know tc_idx is valid and there's room
   for more chunks.  */
static __always_inline void
tcache_put_n (mchunkptr chunk, size_t tc_idx, tcache_entry **ep, bool mangled)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

  /* Mark this chunk as "in the tcache" so the test in __libc_free will
     detect a double free.  */
  e->key = tcache_key;

  if (!mangled)
    {
      e->next = PROTECT_PTR (&e->next, *ep);
      *ep = e;
    }
  else
    {
      e->next = PROTECT_PTR (&e->next, REVEAL_PTR (*ep));
      *ep = PROTECT_PTR (ep, e);
    }
  --(tcache->num_slots[tc_idx]);
}

在传入 tcache_put_n 的参数中多了 ep 和 mangled ep 在这里可以是链表头,也可以是链表的中间 mangled 用于标记传入的指针是否经过 PROTECT_PTR 处理过,这使得传入的指针可以是被加密过的在 tcache 链表中间的指针

large_tcache 同索引下排序

large_tcache 需要有序链表 2.41 的一个 bin 只有一种尺寸,不需要搜索,2.42 的 large bin 包含多个尺寸,因此增加

static __always_inline tcache_entry **
tcache_location_large (size_t nb, size_t tc_idx, bool *mangled)
{
  tcache_entry **tep = &(tcache->entries[tc_idx]);
  tcache_entry *te = *tep;
  while (te != NULL
         && __glibc_unlikely (chunksize (mem2chunk (te)) < nb))
    {
      tep = & (te->next);
      te = REVEAL_PTR (te->next);
      *mangled = true;
    }

  return tep;
}

这使 large_tcache 链表按 chunk_size 从小到大排列,同尺寸插入时,由于循环条件是 < 而不是 <=,新释放的同尺寸 chunk 会插在旧 chunk 前面,因此同尺寸组内部仍表现为 LIFO 所以 2.42 中存在两种不同链表语义:

small tcache:
    一个 bin 一个精确尺寸
    纯链表头插入
    LIFO

large tcache:
    一个 bin 覆盖多个尺寸
    按 chunk size 升序
    同尺寸内部近似 LIFO

double free检测范围扩大

在 2.41 中

/* Verify if the suspicious tcache_entry is double free.
   It's not expected to execute very often, mark it as noinline.  */
static __attribute__ ((noinline)) void
tcache_double_free_verify (tcache_entry *e, size_t tc_idx)
{
  tcache_entry *tmp;
  size_t cnt = 0;
  LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);
  for (tmp = tcache->entries[tc_idx];
       tmp;
       tmp = REVEAL_PTR (tmp->next), ++cnt)
    {
      if (cnt >= mp_.tcache_count)
	malloc_printerr ("free(): too many chunks detected in tcache");
      if (__glibc_unlikely (!aligned_OK (tmp)))
	malloc_printerr ("free(): unaligned chunk detected in tcache 2");
      if (tmp == e)
	malloc_printerr ("free(): double free detected in tcache 2");
      /* If we get here, it was a coincidence.  We've wasted a
	 few cycles, but don't abort.  */
    }
}

对于 key 的检查只在同索引下检查

在 2.42 中

/* Verify if the suspicious tcache_entry is double free.
   It's not expected to execute very often, mark it as noinline.  */
static __attribute__ ((noinline)) void
tcache_double_free_verify (tcache_entry *e)
{
  tcache_entry *tmp;
  for (size_t tc_idx = 0; tc_idx < TCACHE_MAX_BINS; ++tc_idx)
    {
      size_t cnt = 0;
      LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);
      for (tmp = tcache->entries[tc_idx];
	   tmp;
	   tmp = REVEAL_PTR (tmp->next), ++cnt)
	{
	  if (cnt >= mp_.tcache_count)
	    malloc_printerr ("free(): too many chunks detected in tcache");
	  if (__glibc_unlikely (misaligned_mem (tmp)))
	    malloc_printerr ("free(): unaligned chunk detected in tcache 2");
	  if (tmp == e)
	    malloc_printerr ("free(): double free detected in tcache 2");
	}
    }
  /* No double free detected - it might be in a tcache of another thread,
     or user data that happens to match the key.  Since we are not sure,
     clear the key and retry freeing it.  */
  e->key = 0;
  __libc_free (e);
}

即遍历全部 76 个索引,large_tcache 的一个 bin 不再和某个精确 size 一一对应,因此单纯依赖当前索引进行局部检查更加不可靠,通过单独修改 size 的绕过 double free 检测的方法因此失效了

large_tcache 再次分配

2.43 中 tcache 的变化

tcache 容量扩充

在 2.43 中移除了 fastbin ,需要用更深的 tcache 保持分配性能,将 tcache

/* This is another arbitrary limit, which tunables can change.  Each
   tcache bin will hold at most this number of chunks.  */
# define TCACHE_FILL_COUNT 16

large_tcache 大小精确匹配

在 2.43 中修改了 large_tcache 分配时的大小检验,不再跨尺寸复用

static __always_inline void *
tcache_get_large (size_t tc_idx, size_t nb)
{
  tcache_entry **entry;
  bool mangled = false;
  tcache_entry *te;
  entry = tcache_location_large (nb, tc_idx, &mangled, &te);

  if (te == NULL || nb != chunksize (mem2chunk (te)))
    return NULL;

  return tcache_get_n (tc_idx, entry, mangled);
}

在这里添加了 chunksize 的精确匹配检查

mmap chunk 可以进入 tcache

在2.42中 __libc_free() 会检查 chunk 是否是 mmap 得到的,在 2.43中删除了以下检查

// 2.42
	  if (size >= MINSIZE
	      && !chunk_is_mmapped (p)
              && __glibc_likely (tcache->num_slots[tc_idx] != 0))
	    return tcache_put_large (p, tc_idx);

// 2.43
	  if (size >= MINSIZE
              && __glibc_likely (tcache->num_slots[tc_idx] != 0))
	    return tcache_put_large (p, tc_idx);

__libc_free() 会在判断是否执行 munmap() 之前先尝试把 chunk 放入 tcache

tcache key 生成检查

在 2.43 中添加了对 key 的生成条件检查,不能小于等于0x1000000,不能太接近 ULONG_MAX,还检测生成的 key 二进制数中 1 的数量

static void
tcache_key_initialize (void)
{
  /* We need to use the _nostatus version here, see BZ 29624.  */
  if (__getrandom_nocancel_nostatus_direct (&tcache_key, sizeof(tcache_key),
					    GRND_NONBLOCK)
      != sizeof (tcache_key))
    tcache_key = 0;

  /* We need tcache_key to be non-zero (otherwise tcache_double_free_verify's
     clearing of e->key would go unnoticed and it would loop getting called
     through __libc_free), and we want tcache_key not to be a
     commonly-occurring value in memory, so ensure a minimum amount of one and
     zero bits.  */
  int minimum_bits = __WORDSIZE / 4;
  int maximum_bits = __WORDSIZE - minimum_bits;

  while (tcache_key <= 0x1000000
      || tcache_key >= ((uintptr_t) ULONG_MAX) - 0x1000000
      || stdc_count_ones (tcache_key) < minimum_bits
      || stdc_count_ones (tcache_key) > maximum_bits)
    {
      tcache_key = random_bits ();
#if __WORDSIZE == 64
      tcache_key = (tcache_key << 32) | random_bits ();
#endif
    }
}

Terminal

C0nvR3 Lab terminal ready. Type help for commands.