高并发内存池

个人主页:Lei宝啊 

愿所有美好如期而遇


一、项目介绍

这个项目做的是什么?我们要实现一个高并发的内存池,当然,我们这个项目是参考google的一个开源项目tcmalloc而实现的一个迷你版的高并发内存池,实现高效的多线程内存管理,用于代替系统提供的malloc和free。



 二、内存池

1. 池化技术

池化技术就是一次性向系统申请过量的资源,然后自己管理。为什么要一次性申请过量的资源?其他人会告诉你是因为每一次申请资源都有较大开销,但是这些开销体现在哪里呢?

  1. 系统调用开销:在实际应用过程中,无论是申请内存,创建进程,线程等,都需要系统调用,而系统调用需要程序从用户态切换到内核态,调用完成后还要切换回去,这个过程是非常耗时的。每次资源的申请和释放都伴随着这样的过程,所以频繁的系统调用会大大增加系统的开销。
  2. 资源初始化和清理开销:对于数据库连接和线程等,他们的创建不仅仅是内存的申请和释放。在创建时,对数据库连接来说需要建立网络连接,对线程来说,需要分配一系列资源。这些操作对于系统来说也有一定的消耗。
  3. 资源竞争与同步开销:在多线程和多进程场景中,资源的分配和释放可能会引起竞争,需要采用同步机制来确保数据的一致性和线程安全,这些同步机制(信号量,锁)本身也会带来一定的开销,包括等待时间,硬件上下文切换等。
  4. 内存碎片及效率:频繁地申请和释放小块内存会导致内存碎片化,降低内存的利用率和访问效率(稍后我们后面会细说)。

在计算机中,有很多地方都采用了池化技术,除了内存池,还有对象池,线程池,连接池,进程池等。以线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。 

2. 内存池

内存池是指预先向系统申请一块较大的内存,此后,当程序需要申请内存时,不直接向系统申请,而是从内存池中获取;同理,释放内存时,也不交还给操作系统,而是交还给内存池,当程序退出时,内存池才会真正释放之前申请的内存。

3. 内存池解决的问题

内存池主要解决的是效率问题,以及内存碎片问题,什么是内存碎片?内存碎片分为内碎片和外碎片,这里我们先介绍外碎片,内碎片我们后面结合代码讲解。

此时如果我们再想申请超过256字节的内存,就申请不下来了,因为内存已经碎片化了,不连续。

官方点讲就是:外部碎片是⼀些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。

4. malloc

C/C++都是通过malloc去动态申请内存的(C++调用的new底层还会调用operator new, 它里面会调用malloc),但是我们要知道,实际上我们不是直接去堆申请内存的。

事实上malloc就是一个内存池。我们平时向系统申请20字节,他真的只申请20字节吗?不是的,他会申请更多,下次申请时如果足够就直接用,如果不够再继续向OS申请。

malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的⼀套,linux gcc用的glibc中的ptmalloc。

三、定长内存池

1. 解释和实现

作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是⼀个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很⾼的性能。我们要实现的高并发内存池在多线程内存管理上要高效很多,接下来我们要实现的定长内存池将会作为他的一个基础组件。

_memory指向内存池开辟的空间,_freeList指向释放归还给内存池的空间,_remainCapacity指向_memory剩余的空间。

现在我们假设程序要调用New()向内存池申请空间,此时内存池是没有空间的,所以要开辟一段空间:_memory = (char*)malloc(128 * 1024); 那么_memory为什么是char*类型的呢?因为这个定长内存池我们想要设计成模版类,能够适应各种对象,如果_memory不设计成char*,那么对象申请空间后,_memory向后走时,一次移动的大小就不是一个字节,也就无法适应类似于char这样的对象或者自定义类型对象。那么开辟大小为什么是128 * 1024呢?这个我们后面会讲述。

_memory申请空间后,我们要判断他是否申请空间成功,如果申请失败,我们就抛异常:throw std::bad_alloc(); 申请成功后,就可以设置_remainCapacity的大小了。

于是我们顺利成章,定义一个T* obj作为返回值,obj = _memory, _memory += sizeof(T), _remainCapacity -= sizeof(T), 最后将obj返回。

    			
			_memory = (char*)malloc(128 * 1024);
			if (_memory == nullptr)
			{
				throw std::bad_alloc();
			}
			_remainCapacity = 128 * 1024;

			obj = (T*)_memory;
			_memory += sizeof(T);
			_remainCapacity -= sizeof(T);

那么_memory什么时候会去申请空间呢?_remainCapcity小于sizeof(T)时,申请空间,假如说,分配空间到最后几个字节,这几个字节不足以分配对象,那么这几个字节就丢弃掉不去使用,_memory再去申请。

			if (_remainCapacity < sizeof(T)) 
			{
				_memory = (char*)malloc(128 * 1024);
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
				_remainCapacity = 128 * 1024;
			}

			obj = (T*)_memory;
			_memory += sizeof(T);
			_remainCapacity -= sizeof(T);

那么有New也就有Delete,我们如何释放对象呢?

obj指向的空间现在要释放,我们就可以利用他头上的几个字节去存放地址,指向被释放的对象空间,但是对象类型是什么我们不知道,如果对象大小为char或者short等,存不下一个指针的大小怎么办?这就要修改我们的New方法。

		obj = (T*)_memory;
		int objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
		_memory += objSize;
		_remainCapacity -= objSize;

接下来我们要将释放的对象空间挂在_freeList链表上,并且我们采用头插,效率比尾插更高。

现在问题来了,头插代码怎么写?*(int*) = nullptr ?是这样吗,取他的头四个字节?可是你怎么知道这是32位平台还是64位平台,指针大小是不一样的,所以我们if判断指针大小去决定使用int*或者longlong*吗?可以,但是我们还有更好的方法。

*(void**)obj = nullptr; 将obj强转为void**,意思就是obj现在是一个指向void*的指针,解引用obj就能够操控void*大小的空间,对他进行修改,而不同平台指针大小的问题就不需要我们去担心了。 

现在我们来谈Delete也就简单多了。

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

现在,不仅仅是_memory可以分配对象空间,_freeList也可以,所以,我们之前的New逻辑可以进行更新了。

当_freeList有空间时,从头上去取空间,其实类似于头删。 

		if (_freeList)
		{
			void* next = *(void**)_freeList;
			obj = (T*)_freeList;
			_freeList = next;
		}

所以,我们也就可以写出整体代码了,至此,定长内存池基本完成:

#pragma once

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

template<class T>
class ObjectPool
{
private:
	char* _memory;       // 内存池大小,char*为分配内存时,加减合理
	void* _freeList;     // 还回来的内存,构建成链表
	int _remainCapacity; // 内存池的剩余大小

public:
	ObjectPool()
		:_memory(nullptr)
		,_freeList(nullptr)
		,_remainCapacity(0)
	{}

	T* New()
	{
		T* obj = nullptr;

		if (_freeList)
		{
			void* next = *(void**)_freeList;
			obj = (T*)_freeList;
			_freeList = next;
		}
		else
		{
            //剩余空间不足以分配一个对象,且链表没有空间
			if (_remainCapacity < sizeof(T)) 
			{
				_memory = (char*)malloc(128 * 1024);
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
				_remainCapacity = 128 * 1024;
			}

			obj = (T*)_memory;
			int objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objSize;
			_remainCapacity -= objSize;
		}

		new(obj)T;
		return obj;
	}

	void Delete(T* obj)
	{
		obj->~T();

		//将要归还的空间
		*(void**)obj = _freeList;
		_freeList = obj;
	}
};



对于new(obj)T,叫做定位new,是在已申请的原始内存空间中调用对象的构造函数初始化对象。关于obj->~T(); 显式调用析构函数,并不是释放内存池的空间,而是去释放对象内部逻辑可能去自己申请的空间。

另外要修改的是,我们这里申请空间不使用malloc,因为他也是一个内存池,用于处理各种复杂场景,而我们这里只是需要单纯的申请空间,于是我们可以使用系统调用去申请空间,在Windows操作系统上,这个系统调用叫:VirtualAlloc

LPVOID VirtualAlloc(  
  LPVOID lpAddress,    // 要分配的内存区域的地址  
  SIZE_T dwSize,       // 分配的大小(以字节为单位)  
  DWORD flAllocationType, // 分配的类型  
  DWORD flProtect        // 该内存的初始保护属性  
);

inline static void* SystemAlloc(size_t kpage)
{

#ifdef _WIN32
		void* ptr = VirtualAlloc(0, kpage * (1 << 10), \
                            MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
		// linux下brk mmap等
#endif
		if (ptr == nullptr)  throw std::bad_alloc();
	    return ptr;
}

_WIN32是一个在Windows编程中经常遇到的宏定义,它主要用于判断当前的编译环境是否为Windows 32位环境。_WIN32是VC(Visual C++)编译器在编译Windows程序时自动定义的一个宏。这意味着,只要在使用VC编译器编写Windows程序,无论目标平台是32位还是64位_WIN32宏通常都会被定义。但是,需要注意的是,在64位Windows系统上编译64位程序时,虽然_WIN32会被定义,但通常还会定义_WIN64宏来明确指示64位环境。

_memory = (char*)SystemAlloc(128);

2. 性能测试

#include "FixedLenMemPool.h"

#include <vector>
#include <time.h>

struct TreeNode
{
    int _val;
    TreeNode* _left;
    TreeNode* _right;
    TreeNode()
        :_val(0)
        ,_left(nullptr)
        ,_right(nullptr)
    {}
};

void TestObjectPool()
{
    // 申请释放的轮次
    const size_t Rounds = 3;
    // 每轮申请释放多少次
    const size_t N = 1000000;

    std::vector<TreeNode*> v1;
    v1.reserve(N);

    size_t begin1 = clock();
    for (size_t j = 0; j < Rounds; ++j)
    {
        for (int i = 0; i < N; ++i)
        {
            v1.push_back(new TreeNode);
        }

        for (int i = 0; i < N; ++i)
        {
            delete v1[i];
        }
        v1.clear();
    }
    size_t end1 = clock();

    std::vector<TreeNode*> v2;
    v2.reserve(N);
    ObjectPool<TreeNode> TNPool;

    size_t begin2 = clock();
    for (size_t j = 0; j < Rounds; ++j)
    {
        for (int i = 0; i < N; ++i)
        {
            v2.push_back(TNPool.New());
        }

        for (int i = 0; i < N; ++i)
        {
            TNPool.Delete(v2[i]);
        }

        v2.clear();
    }
    size_t end2 = clock();

    cout << "new cost time:" << end1 - begin1 << endl;
    cout << "object pool cost time:" << end2 - begin2 << endl;
}

int main()
{
    TestObjectPool(); 
    return 0;
}

四、高并发内存池整体框架设计

现代开发环境大多都是多线程多进程,在申请内存的场景下,因为内存时临界资源,必然存在激烈的锁竞争问题,malloc本身其实已经足够优秀,但是我们的项目圆形tcmalloc就是在多线程高并发的场景更胜一筹,所以我们设计的内存池需要考虑下面几个问题:

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

高并发内存池主要由以下三个部分组成:

thread cache:线程缓存,每个线程独有一个thread cache,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,因为每个线程独有一个cache,这也是这个并发线程池高明的地方。

central cache:中⼼缓存是所有线程所共享,thread cache是按需从central cache中获取对象。当thread cache空间不足,就会从central cache中获取对象,central cache会在合适的时机回收thread cache中的对象,避免⼀个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的,central cache是存在竞争的,所以从这里取内存创建对象是需要加锁的,但是这里用的是桶锁,再一个只有thread cache没有内存创建对象时才会找central cache,所以这里竞争不会很激烈。

page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分 配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。page cache如果要再申请内存,就是从系统申请了。

当⼀个span {span通常指的是一段连续的内存区域, 可以被划分为多个更小的单元(如页或对象)以供分配} 的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。 

五、thread cache

thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的的内存块对象的自由链表,不同桶的自由链表所指向的空间大小是不一样的(定长内存池办不到),也就是说,不同的对象可以向thread cache申请空间,并且,每个线程都有自己的thread cache,因此,在线程向各自的thread cache申请内存对象以及释放时,不会有锁的竞争。

我们首先创建ThreadCache类:

class ThreadCache
{
private:

public:
	// 申请和释放内存对象
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);

	//从中心缓存获取内存对象
	void* FetchFromCentralCache(size_t index, size_t size);

};

类属性我们怎么设计呢?根据我们上面的分析,我们需要一个哈希桶,并且每个桶的位置挂上一个自由链表,那么现在,我们可以先设计出自由链表类(有了上面定长内存池的铺垫,这个应该很好理解):

class FreeList
{
private:
	void* _freelist;

	void*& nextobj(void* cur)
	{
		return *(void**)cur;
	}

public:

	FreeList() :_freelist(nullptr)
	{}

	void push(void* obj)
	{
		nextobj(obj) = _freelist;
		_freelist = obj;
	}

	void* pop()
	{
		void* obj = _freelist; 
		_freelist = nextobj(_freelist);;

		return obj;
	}

	bool Empty()
	{
		return _freelist == nullptr;
	}
};

我们哈希桶的设计打算使用数组,使用数组下标来映射桶的位置:

FreeList _freelist[MaxFreeListSize]; //MaxFreeListSize后面解释

到这里,我们其实可以发现一个问题,我们的自由链表中没有malloc空间之类的代码,这和我们的逻辑有关,我们设计的逻辑是,如果线程向thread cache申请内存对象,如果thread cache对应的桶的自由链表不为空,可以为他分配对象,那么就分配,否则,向central cache去申请内存对象,申请的内存对象释放时,

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Lei宝啊

觉得博主写的有用就鼓励一下吧

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

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

打赏作者

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

抵扣说明:

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

余额充值