模拟tcmalloc的小型高并发内存池项目

前言

本项目仅为了学习并提升代码能力,不作为实际运用。
项目完整代码地址:gitee仓库地址
请添加图片描述

1.项目介绍

项目原型是google的开源项目tcmalloc。即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数mallocfree
项目特点:1.比较难 2.知名度高(很多的大厂程序员都知道这个项目,并且go语言的内存分配器就是这个)所以面试官可能会问的很细。
知识点:C/C++,数据结构(链表,哈希桶),操作系统内存管理,单例模式,多线程,互斥锁

2.什么是内存池

2.1 池化技术

“池化技术”就是程序向系统先申请过量资源,然后自己管理,以备不是之需。因为每一次申请资源都需要较大的开销,所以提前申请好了资源,这样在使用的时候,就会大大提高程序运行的效率。
除了内存池,还有连接池,线程池,对象池等等。以线程池为例,它的主要思想就是:先启动若干数量的线程,让它们先处于睡眠状态,当接收客户端的请求的时候,唤醒线程池中的某个睡眠的线程来处于客户端的请求,当处理完这个请求后,该线程再进入睡眠状态。

2.2 内存池

原理和线程池类似

2.3 内存池主要解决的问题

内存池主要可以解决两个方面的问题:

  1. 效率问题
  2. 内存碎片问题

内存碎片分两种

  1. 外碎片
  2. 内碎片

例子:![[Pasted image 20220117151529.png]]
(外碎片)

2.4 malloc

![[Pasted image 20220117152050.png]]

malloc实际就是一个内存池,malloc相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给不同的进程。
但是malloc的实现方式有很多种。windows中有自己vs系列的一套,linux中有ptmalloc
有很多的文章讲到了malloc的实现方式可以看看malloc的实现。

tcmalloc比普通的malloc要快,并且在多线程高发可以很快

3. 先设计一个定长的内存池

先做一个定长的内存池,一方面可以先熟悉一下内存池,另一方面可以作为后面项目的一个基础组件。

定长内存池的功能:

  • 定长的内存池可以解决,固定大小的内存申请释放需求。这里为了后面可以方便当成组件使用。所以这里需要传入一个对象,根据对象的大小可以分配内存。

项目特点:

  1. 性能达到极值
  2. 不考虑内存碎片的问题

设计思想:

  • 特点在于使用自由链表来管理归还后的资源。每一次申请T对象大小的内存的时候先去链表中找,如果链表中没有资源了,再直接去向系统申请。

问题1:不采用void* _memory,而是采用char* _memory

使用char*方便后面可以方便切内存使用

问题2:如何处理归归还之后的内存呢?

采用自由链表的方式。使用void* freeList存储第一个归还的内存块的地址,然后第二个内存块(32位下必须大于4字节)的头4字节去存储第二个内存块的地址,最后一个在freeList中的内存块指向nullptr即可。也就是freeList中的节点就是一个个归还回来的内存块 。
![[Pasted image 20220117160013.png]]

问题3:如何知道何时再分配资源?

引入成员变量_leftBytes,统计当前内存池还剩下可用的内存块的字节数。当剩余空间小于一个申请对象的时候,这个时候就说明当前的内存块不够用了,所以就需要重新的申请空间。而当前剩下的一些内存就不要了。

问题4:如何将第一个内存块放入一个空的_freeList中?

我们只需要将_freeList指向第一块内存即可。但是又有一个问题,此时这个内存块既是第一个内存块也是最后一个内存块,所以需要将内存块的前部分指向nullptr,而空指针是4个字节,因为需要将头4个字节填上nullptr。这里有一个技巧取用头4个字节:可以先将obj强转成(int*),然后再解引用就可以拿到4个字节了。(使用不同类型的指针访问内存是一个技巧)。

问题5:32位上程序是没有问题的,但是64位上程序就不对了。

因为32位下的指针大小是4字节,64位下的指针是8字节,那么为了保证开辟的空间大小正确,可以开辟一个指针的大小同时也可以转成一个指针,所以就可以强转成(void**)(解释:将void*看成一个整体,那么我们就需要开辟一个void*大小的空间,此时指针void*就可以随着平台的不同而产生变化也就可以满足我们的需求),然后再解引用,即*(void**)obj = nullptr。当然如果麻烦一点的话,就可以直接判断当前平台下一个指针的大小,根据一个指针的大小使用if判断开多大的空间。

问题6:删除操作的简便写法。

我们在处理归还的费第一个节点时,采用头插法的效率最高。那么其实就可以直接所有的插入操作都写成头插,这样也不用特殊处理第一个节点了。

问题7:在分配空间的最开始,应该考虑_freeList是否有可用的空间

在分配空间的时候,需要先考虑回收的自由链表中是否存在可用的内存。如果自由链表中存在可用的内存,那么就不用向系统再申请内存空间了。

问题8:如果归还的内存块不满4/8个字节也就是不能存放一个指针的大小,也就无法保存下一个空间的地址,怎么办?

为了让一个内存块一定可以保存一个指针,所以在分配内存空间的时候,我们需要判断一个内存块的大小,如果大于一个指针的大小,那么可以直接分配;如果小于一个指针的大小,就可以分配一个指针的大小。这样就可以保证每一个内存块都一定可以保存一个指针的大小。

问题9:需要主动处理内存块中对象的内容

当分配空间的时候,需要使用定位new去主动的调用T对象的构造函数。在将内存块回收的时候,需要主动调用T对象的析构函数。

  • placement new 有两个作用
    • 1.在使用operator new分配好内存空间后,可以使用定位去驱主动的调用构造函数
    • 2.使用定位new可以返回指向这个对象的地址

问题10:如果我们想要使得我们制作的内存池更加纯粹的话,那么申请空间的时候就不使用malloc而是直接向系统申请内存。

malloc是一个内存池,所以为了使得申请内存资源的操作更加纯粹的话,可以直接使用相关的系统接口,以页为单位向系统直接申请系统内存。

如果想要直接向系统申请内存的话,在windows下可以使用VirtualAlloc,在linux下可以使用brk()或者mmap()

  • mmap可以将文件的内容映射进进程的虚拟地址空间,这样就可以不用readwrite对文件进行操作。
  • brk是将数据段的最高地址指针_edata指针往高地址推
#include <unistd.h>
int brk(void* addr);
  • 作用
    • brk指针指向addr的位置上
  • 参数
    • addr:将brk推到addr的位置上
  • 返回值
    • 成功返回0,失败返回-1
#include <unistd.h>
void* sbrk(intptr_t increment);
  • 作用
    • 推动brk指针,增加increment大小的内存
  • 参数
    • increment:增加的内存大小
  • 返回值
    • 返回旧的brk指向的位置

**使用技巧:**使用sbrk可以更方便地分配指定的内存空间,因为在释放空间的时候必须要重新定位指针的位置。使用brk可以更方便地释放内存,因为不能确定brk指针的位置。

所以设置一个brk指针的锚点,使用sbrk动态分配内存,而brk可以以锚点为基础回收内存。

![[Pasted image 20220225200224.png]]

#pragma once
#include <iostream>
using std::cout;
using std::endl;


#ifdef _WIN32
// 因为是在vs下变成,所以使用windows系统分配内存的接口
#include <Windows.h>
#else
// 如果是Linux就要使用Linux下直接分配内存的接口
#include <sys/mman.h>
#endif

// 按页分配,一页是8k
// (1 << 13)就是8*1024
inline static void* SystemAlloc(size_t kpage)
{
   
#ifdef _WIN32
   void* ptr = VirtualAlloc(0, kpage*(1 << 13), MEM_COMMIT | MEM_RESERVE, PAGE_READONLY);
#else
   // linux下mmap接口
   void* ptr = mmap(0,//首地址,0代表内核指定
       kpage * (1 << 13), // 开辟K页内存
       PROT_READ|PROT_WRITE,//权限
       MAP_PRIVATE|MAP_ANONYMOUS,//私有匿名 针对
       0,0);//文件描述符
#endif
}

// 定长内存池
// 非类型模板参数直接确定内存池的大小
//template<size_t N>
//class ObjectPool
//{};

// 但是为了后面的项目准备,所以这里写成class T,而T对象的大小也是固定的,也是可以当做一个常数使用的
template<class T>
class ObjectPool
{
   
public:
   T* New()
   {
   
   	T* obj = nullptr;
   	// 问题7
   	if (_freeList != nullptr)
   	{
   
   		void* next = *(void**)_freeList;
   		obj = (T*)_freeList;
   		_freeList = next;
   	}
   	else
   	{
   
   		// 问题3
   		// 剩余内存不够一个对象大小是,重新开空间
   		if (_leftBytes < sizeof(T))
   		{
   
   			// 问题10
   			_leftBytes = 128 * 1024;
   			// _memory = (char*)malloc(_leftBytes);
   			_memory = (char*)SystemAlloc(_leftBytes >> 13); // 16页
   			if (_memory == nullptr)
   			{
   
   				throw std::bad_alloc();
   			}
   		}
   		obj = (T*)_memory;
   		// 问题8
   		// _memory += sizeof(T);
   		// _leftBytes -= sizeof(T);
   		size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
   		_memory += objSize;
   		_leftBytes -= objSize;
   	}
   	// 问题9
   	new(obj)T;

   	return obj;
   }

   void Delete(T* obj)
   {
   
   	/**
   	// 问题4
   	if (nullptr == _freeList)
   	{
   		_freeList = obj;
   		// 问题5
   		// *(int*)obj = nullptr;
   		*(void**)obj = nullptr;
   	}
   	else // 头插
   	{
   		*(void**)obj = _freeList;
   		_freeList = obj;
   	}
   	*/
   	// 问题9
   	obj->~T();
   	// 问题6 && 问题8
   	*(void**)obj = _freeList;
   	_freeList = obj;
   }

private:
   // 可以直接给缺省值,就不用写构造函数了
   // 指向大块内存的指针
   char* _memory = nullptr; // 问题1
   // 大块内存中剩余字节数
   size_t _leftBytes = 0;
   // 还回来的内存形成的单链表
   void* _freeList = nullptr; // 问题2
};

4.高并发内存池整体框架设计

malloc本身已经很优秀了,但是本项目中tcmalloc多线程高并发的场景下更胜一筹,所以实现的内存池需要考虑一下问题:

  1. 性能问题
  2. 多线程环境下,锁竞争问题
  3. 内存碎片问题

![[Pasted image 20220118113739.png]]

ConcurrentMemoryPool主要有以下的3个部分组成:

  1. thread cache:线程缓存是每个线程独有(后面会讲实现) 的,用于小于256KB的内存分配,线程从这个申请内存是不需要加锁的,每一个线程独享一个cache,这就是并发线程池高效的问题。
  2. central cache:中心缓存是所有线程共享的。thread cache按需从central cache中获取对象的。central cache适合的时机(后面会讲实现) 回收thread cache中的对象,避免一个线程会占用太多的资源,而其他的线程会资源紧缺,达到了内存分配在多个线程中更均衡的按需调度的目的。 central cache在资源调度的时候,是存在资源竞争的,所以 取内存对象的时候需要加锁。但是这个采用的时候桶锁,所以只要当多个线程竞争同一个桶中的资源的时候才会加锁,而且是由threal cache没有内存对象的时候才会申请资源,所以这个内存申请资源不会很激烈。
  3. page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储以及分配的。central没有缓存的时候,从page cache中分配出一定数量的page并且并且切割成定长大小的小块内存,分配给central cachecentral cache中一个span的几个跨度页的对象都回收回来之后,page cache会回收central cache中满足条件的span对象并且会合并成相邻的页,组成更大的页,缓解了内存碎片的问题。

5.thread cache整体设计

![[Pasted image 20220118113800.png]]

前面定长内存池使用自由链表的结构来分配内存,但是链表中的节点都是定长的。为了适应不同长度的内存块分配情况,可以使用多个连接着不同字节大小的内存块的链表。
但是thread cache中最大的内存块是256KB,如果我们为了精确分配的内存的话,需要使用256*1024个链表(256KB=256×1024B)的话就太浪费了。所以我们可以使用8B,16B,24B256KB这样粗略地分一下即可,在申请资源的时候是要去大于等于当前申请内存的最小内存块即可 (按照一定大小进行内存对齐)。

这样设计缺点在于可能会有很多的空间浪费,造成内存碎片,并且是内碎片。

  • 外碎片:分配空间在归还之后,导致内存空间不连续,不能连续分配。
  • 内碎片:在分配内存给对象之后,由于内存对齐等缘故导致内存块中有一个空间不可能使用到,但是已经分配过内存了。

另外thread cache采用哈希桶结构,每一个桶中是按桶的大小去映射的,即桶中的自由链表的内存块对象大小等于桶大小,使用哈希映射可以快速得到线程星想要得到的内存块的大小。这样设计使得每一个线程都有一个一个thread cache对象,每一个线程获取对象和释放对象时是无锁的。


问题1:处理哈希桶中自由链表问题。

由于每一个哈表桶中都需要挂一个自由链表,所以可以将自由链表封装成一个类专门管理小内存块。

// 统一写法,取出一块内存头部的4/8个字节存放下一个内存块的地址
void*& NextObj(void* obj)
{
   
	return *(void**)obj;
}
// 管理切好的小块内存的自由链表
class FreeList
{
   
public:
	// 采用头插
	void Push(void* obj)
	{
   
		// 如果obj为nullptr则不能插入
		assert(obj);
		// 头插内存块
		//*(void**)obj = _freeList;
		NextObj(obj) = _freeList;
		_freeList = obj;
	}
	// 采用头删
	void* Pop()
	{
   
		// 如果_freeList为nullptr则不能删除
		assert(_freeList);
		// 头删内存块
		void* obj = _freeList;
		_freeList = NextObj(_freeList);
		return obj;
	}

private:
	void* _freeList;
};
class ThreadCache
{
   
public:
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);
private:
	// 问题1
};

6.哈希桶映射对齐规则

问题1:给一个需要内存块的大小size,怎么将这个内存块对齐呢?

使用一个类专门来管理和计算对象大小的内存对齐的映射规则。其中至少要按8字节对齐,因为64平台下一个指针都8字节。但是如果256KB都按8字节对齐的话,需要3万多个哈希桶,所以可以进一步的改造一下,每一个字节范围内按一个字节数来对齐。

  • [1, 128]字节按8bytes对齐
    • freeList(桶位置)[0, 16)
  • [128 + 1, 1024]字节按16bytes对齐
    • freeList(桶位置)[16, 72)
  • [1024 + 1, 1024 * 8]字节按128bytes对齐
    • freeList(桶位置)[72, 128)
  • [8 * 1024 + 1, 64 * 1024 ]字节按1024bytes对齐
    • freeList(桶位置)[128, 184)
  • [64 * 1024 + 1, 256 * 1024]字节按8* 1024 bytes对齐
    • freeList(桶位置)[184, 208)

这样就可以控制最多10%左右的内存碎片浪费。前期的对齐数小一点,后面的对齐数变大。

// "common.h"中
// 最大的自由链表数量
static const size_t NFREE_LISTS = 208;
// threadcache中最大分配的内存块的大小
static const size_t MAX_BYTES = 256 * 1024;

class SizeClass
{
   
public:
	static inline size_t _RoundUp(size_t size, size_t alignNum)
	{
   
		// 将size按alignNum对齐数对齐
		return ((size + alignNum - 1) & ~(alignNum - 1));
		// 也可以这样
		//return (size + alignNum - 1) / alignNum * alignNum;
	}
	// 为了保证在类外可以直接调用函数,而不是使用对象调用函数
	// 所以可以将函数设置成static的
	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
		{
   
			// 分配的内存不能大于256KB
			assert(false);
			return -1;
			// 其实如果超过256KB也是可以申请的,后面会讲
		}
	}

	static inline size_t _Index(size_t size, size_t align_shift) {
   
		// 其实就是size/2^(align_shift)上取整然后-1
		return ((size + (1 << align_shift) - 1) >> align_shift) - 1;
	}

	// 计算自由链表所在哈希桶中的位置
	static inline size_t Index(size_t size)
	{
   
		// 每一个区间中有多少的自由链表
		static int group_array[4] = {
    16, 56, 56, 56 };
		if (size <= 128) 
		{
   
			return _Index(size, 3);
		}
		else if (size <= 1024)
		{
   
			return _Index(size - 128, 4) + group_array[0];
		}
		else if (size <= 8 * 1024)
		{
   
			return _Index(size - 1024, 7) + group_array[0] + group_array[1];
		}
		else if (size <= 64 * 1024)
		{
   
			return _Index(size - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
		}
		else if (size <= 256 * 1024)
		{
   
			return _Index(size - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
		}
		else 
		{
   
			assert(false);
			return -1;
		}
	}

};

// "ThreadCache.h"中
class ThreadCache
{
   
public:
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);
	// 从centralcache中获取内存
	void* FetchFromCentralCache(size_t index, size_t size);
private:
	// 用数组模拟哈希表,最多有NFREE_LISTS
	FreeList _freeLists[NFREE_LISTS];
};

// ”ThreahCache.cpp“中
void* ThreadCache::Allocate(size_t size)
{
   
	// threadcache最多只能分配256KB
	assert(size <= MAX_BYTES);
	// size对齐之后的字节数
	size_t alignSize = SizeClass::RoundUp(size);
	// size字节数对应的哈希桶的位置
	size_t index = SizeClass::Index(size);

	// 如果申请内存大小对应的哈希桶中的自由链表为空,就去centralcache中拿	
	// 否则直接从自由链表中获取即可
	if (_freeLists[index].Empty()) 
	{
   
		return FetchFromCentralCache(index, alignSize);
	}
	else 
	{
   
		return _freeLists[index].Pop();
	}
}

void ThreadCache::Deallocate(void* ptr, size_t size)
{
   
	// ...
}

7. TLS – thread local storage

问题0:什么是TLS?

TLS(线程局部存储),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保证了数据的线程独立性。

问题1:为什么需要TLS?

为了保证每一个线程都可以有自己专属的thread cache,所以可以使用TLS,来保证每一个线程都可以无锁地获得自己的thread cache对象。TLS分为静态和动态的,使用静态的LTS最简单,只需要声明一个_declspec(thread)的变量就会给每一个线程单独的一个拷贝。

问题2:"ConcurrentAlloc.h"是什么作用?

这里需要专门准备两个函数给每一个线程调用分配内存。

问题3:.h文件中很多的static修饰的变量和函数是为什么?

static修饰函数,改变链接属性,一个.h文件中可以被多个.cpp文件包含,所以这里使用static保证其中的static的变量或者函数只保存一份,这样就不会再生成.obj文件的时候相互冲突了,static保证了变量或者函数只在当前文件可见 。

/
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

hyzhang_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值