项目整体框架 + thread cache设计及实现_开源

你好,我是安然无虞。

文章目录

  • ​​整体框架设计​​
  • ​​thread cache设计​​

整体框架设计

我们知道, 现代的很多开发环境都是多核多线程的, 所以在申请内存的时候, 必然存在激烈的锁竞争问题. malloc 本身已经很优秀了, 但是在一些特定的场景下还不够优秀, 那么我们项目的原型 TCMalloc 就是在多线程高并发的场景下更胜一筹, 所以这次我们实现的内存池需要考虑以下 3 方面的问题:

  • 性能问题;
  • 内存碎片问题;
  • ​多线程环境下, 锁竞争问题.​

在项目整体框架设计上, 主要由以下 3 部分构成(这里只做简单介绍, 方便大家了解大体框架, 后面会一一详细讲解):

  1. ​thread cache​​: 线程缓存是每一个线程独有的, 用于小于 256KB 的内存分配(256*1024byte, 大部分情况下我们申请的内存都小于这个值), 因为每个线程都有一个 thread cache, 所以线程从这里申请内存不需要加锁, 有效避免了锁竞争的问题, 这也就是并发线程池高效的地方;
  2. ​central cache​​: 中心缓存是在 thread cache 上面的一层缓存, 被所有线程共享, thread cache 按需从 central cache 中获取内存对象. central cache 也会在合适的时候回收 thread cache 中的内存对象, 避免出现一个线程占用了太多的内存, 其他线程内存吃紧的情况, 从而达到了内存分配在多个线程中更均衡的按需调度的目的. central cache 是存在竞争的, 所以在这里取内存对象需要加锁, 由于 central cache 本身的结构是用哈希桶实现的, 所以这里的锁比较特殊, 采用的是桶锁, 因为只有在 thread cache 没有内存对象时才会找 central cache, 而且需要两个线程同时访问同一个桶时才会出现竞争的情况, 所以这里的竞争不会很激烈;
  3. ​page cache​​: 页缓存是在 central cache 上面的一层缓存, 管理的内存是以页为单位进行存储及分配的, 当 central cache 没有内存对象时, 会从 page cache 分配出一定数量的页, 并切割成定长的小块内存分配给 central cache. 当一个 span 的几个跨度页的对象都回收以后, page cache 会回收 central cache 满足条件的 span 对象, 并且合并相邻的页, 组合成更大的页, 从而缓解内存碎片的问题.

项目整体框架 + thread cache设计及实现_开源_02


上面是高并发内存池项目的整体框架设计, 我们在这里了解即可, 我会在后面的章节中逐一详细讲解.


thread cache设计

thread cache 是一个哈希桶结构, 每个桶是一个按桶位置映射大小的内存块对象的自由链表.

​每个线程都会有一个 thread cache 对象, 这样每个线程在这里获取对象和释放对象是无锁的​​.

thread cache 用于小于256KB 的内存分配, 256KB大小为 256*1024 字节, 所以如果每个字节都要对应一个自由链表桶的话, 那么就需要开辟20多万个自由链表桶, 单单是存储这么多个桶就需要花大量内存, 所以是得不偿失的, 我们需要用别的方法, 可以​​分段, 每一段给一个对齐数​​, 这样就可以极大地减少桶的数量, 为了方便理解, 下面给出按照 8byte 对齐的方式:

项目整体框架 + thread cache设计及实现_字节数_03


按照上面 8字节 的对齐方式, 如果你要10字节, 我给你16字节, 剩下的6字节用不上, 也就变成了内碎片(由于对齐的需求, 有的小块内存用不上).

如果按照上面给出的 8byte 对齐方式, 需要 256 × 1024 / 8 = 32768 个桶, 这样的话还是有点多啊, 所以这里给出的解决办法是​​分段处理, 每一段给出一个对齐数, 但是需要注意的是整体控制在最多10%左右的内碎片浪费.​

字节数

对齐数

哈希桶下标

[1, 128]

8byte对齐

freeList [0, 16)

[128+1, 1024]

16byte对齐

freeList [16, 72)

[1024+1, 8×1024]

128byte对齐

freeList [72, 128)

[8×1024+1, 64×1024]

1024byte对齐

freeList [128, 184)

[64×1024+1, 256×1024]

8×1024byte对齐

freeList [184,208)

之所以一开始就选择8byte对齐, 是因为不管是在32位平台还是在64位平台, 至少要存的下一个地址的大小, 所以一开始就要选择8byte对齐.

对齐和映射相关函数的编写

我们需要根据字节数计算出对齐后的字节数, 以及根据字节数计算出映射到哈希桶的位置, 所以可以将它们封装到一个类当中, 如下:

// 计算对象大小的对齐映射规则
class SizeClass
{
public:
// 计算对齐后的字节数
static inline size_t RoundUp(size_t size);

// 计算哈希桶下标
static inline size_t Index(size_t bytes);
};

上面两个函数之所以使用 static 修饰, 是为了不用实例化出对象, 使用类名就可以直接调用, 再加上 inline 修饰, 是因为会频繁调用上述函数, 直接在调用处展开, 没有压栈的开销.

在计算对齐后的字节数之前呢, 我们首先要根据提供的字节数计算出它在哪一个区间, 再利用子函数计算出对齐后所需要的字节数.

static inline size_t RoundUp(size_t size)
{
if (size <= 128)
{
return _RoundUp(size, 8);
}
else if (size <= 1024)
{
return _RoundUp(size, 16);
}
else if (size <= 8 * 1024)
{
return _RoundUp(size, 128);
}
else if (size <= 64 * 1024)
{
return _RoundUp(size, 1024);
}
else if (size <= 256 * 1024)
{
return _RoundUp(size, 8 * 1024);
}
else
{
assert(false);
}
return -1;
}

封装的子函数如下:

// 对齐大小的计算
static inline size_t _RoundUp(size_t size, size_t alignNum)
{
size_t alignSize = 0;
if (size % alignNum != 0)
{
alignSize = (size / alignNum + 1) * alignNum;
}
else
{
alignSize = size;
}

return alignSize;
}

上面是我自己写的函数, 有点low, 我们来看看大神提供的使用位运算的解法:

// 对齐大小的计算
static inline size_t _RoundUp(size_t size, size_t alignNum)
{
return ((size + alignNum - 1) & ~(alignNum - 1));
}

计算映射到哪一个自由链表桶的方法跟上面计算对齐后的字节数方法很相似, 首先根据提供的字节数计算出它在哪一个区间, 然后再通过调用子函数计算出映射到哪一个自由链表桶.

static inline size_t Index(size_t bytes)
{
// 前四个区间桶的数量
static int group_array[4] = { 16, 56, 56, 56 };

if (bytes <= 128)
{
return _Index(bytes, 8);
}
else if (bytes <= 1024)
{
return _Index(bytes, 16) + group_array[0];
}
else if (bytes <= 8 * 1024)
{
return _Index(bytes, 7) + group_array[0] + group_array[1];
}
else if (bytes <= 64 * 1024)
{
return _Index(bytes, 10) + group_array[0] + group_array[1] + group_array[2];
}
else if (bytes <= 256 * 1024)
{
return _Index(bytes, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
}
else
{
assert(false);
}
return -1;
}

封装的子函数如下:

// 计算映射到哪一个自由链表桶
static inline size_t _Index(size_t bytes, size_t alignNum)
{
if (bytes % alignNum == 0)
{
return bytes / alignNum - 1;
}
else
{
return bytes / alignNum;
}
}

我们再来看看大神给出的使用位运算的解法:

static inline size_t _Index(size_t bytes, size_t align_shift)
{
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}

但是如果采用位运算的解法时, 上面的代码需要做部分修改:

static inline size_t Index(size_t bytes)
{
// 前四个区间桶的数量
static int group_array[4] = { 16, 56, 56, 56 };

if (bytes <= 128)
{
return _Index(bytes, 3);
}
else if (bytes <= 1024)
{
return _Index(bytes, 4) + group_array[0];
}
else if (bytes <= 8 * 1024)
{
return _Index(bytes, 7) + group_array[0] + group_array[1];
}
else if (bytes <= 64 * 1024)
{
return _Index(bytes, 10) + group_array[0] + group_array[1] + group_array[2];
}
else if (bytes <= 256 * 1024)
{
return _Index(bytes, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
}
else
{
assert(false);
}
return -1;
}

因为 thread cache 本质上是一个自由链表数组, 所以我们需要封装一个自由链表类, 鉴于项目比较复杂, 暂时只提供 thread cache 使用到的成员变量和成员函数, 剩下的后面使用到时会再进行补充.

//管理切分好的小对象的自由链表
class FreeList
{
public:
// 头插
void Push(void* obj)
{
assert(obj);

NextObj(obj) = _freeList;
_freeList = obj;
}

// 头删
void* Pop()
{
assert(_freeList);

void* obj = _freeList;
_freeList = NextObj(_freeList);

return obj;
}

// 判空
bool Empty()
{
return _freeList == nullptr;
}

private:
void* _freeList = nullptr;
};

thread cache 类

根据上面的计算, 自由链表桶的个数是208个, 以及 thread cache 允许申请的最大内存大小是 256KB, 所以我们可以按照如下定义:

// 小于等于 MAX_BYTES, 就找 thread cache 申请
// 大于 MAX_BYTES, 直接找 page cache 或者系统堆申请
static const size_t MAX_BYTES = 256 * 1024;

// thread cache 和 central cache 自由链表桶的个数
static const size_t NFREELISTS = 208;

我们已经知道 thread cache 是一个自由链表桶, 一共有208个桶, 下面我们就可以对 thread cache类进行定义了:

// thread cache 本质是由一个哈希映射的自由链表构成
class ThreadCache
{
public:
// 申请内存对象
void* Allocate(size_t size);

private:
FreeList _freeLists[NFREELISTS];
};

这里我们暂时只提供一个申请内存对象的函数, 后面再进行补充. 对于申请内存对象的过程, 首先根据提供的字节数计算出映射到自由链表桶的下标, 如果这个自由链表非空, 则头删即可, 反之, 如果这个自由链表是空的, 则需要从 central cache 中获取, 这时需要使用 FetchFromCentralCache 函数, 这个我们会在后面补充.

// 申请内存对象
void* ThreadCache::Allocate(size_t size)
{
assert(size <= MAX_BYTES);

size_t alignSize = SizeClass::RoundUp(size);
size_t index = SizeClass::Index(size);

if (!_freeLists[index].Empty())
{
return _freeLists[index].Pop();
}
else
{
return FetchFromCentralCache(index, alignSize);
}
}

thread local storage: 线程局部存储

前面我们提到了 thread cache 高效的地方是有效的避免了锁竞争的问题, 因为每个线程都有一个 thread cache 对象.

那我们该如何创建上面提到的这个 thread cache 呢?

这里就需要补充线程局部存储的概念了, 线程局部存储TLS,是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性. 而我们熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度.

//TLS - Thread Local Storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

注意:

​并不是每个线程被创建时就立马有了属于自己的thread cache,而是当该线程调用相关申请内存的接口时才会创建自己的thread cache,因此在申请内存的函数中会包含以下逻辑.​

//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}