高并发内存池(四):内存释放原理与实现

发布于:2025-04-17 ⋅ 阅读:(28) ⋅ 点赞:(0)

        前言:经过前3期的攻坚,我们已完整实现了内存动态申请的核心模块。接下来将进入关键阶段——内存释放机制的理解与实现,这是构建完整 高并发内存池 的最后一块技术拼图。该模块完成后,项目主体架构将基本成型(达90%),后续主要聚焦于边界测试和性能调优。欢迎关注本专栏的开发者们持续追踪代码演进,共同探讨高并发内存池的工程实践优化方案。

项目专栏:高并发内存池_敲上瘾的博客-CSDN博客

个人主页:敲上瘾-CSDN博客

目录

​编辑

一、ThreadCache内存释放

二、CentralCache内存回收

三、PageCache内存回收

四、源码


一、ThreadCache内存释放

        在ThreadCache层我们探讨过这样一个问题,某一时段某个线程可能需要大量内存,不断向span桶中申请。而用完后这些内存就被放回它自己的自由链表桶了,导致后续线程再需要向span桶申请内存时资源不足,大部分内存都堆积在这个线程自由链表桶

        为了解决这样的问题,我们需要将部分内存归还给CentralCache。那么,自由链表达到多长时需要放回span桶呢?

        之前在设计应该从span桶取多少内存块比较合适时我们也有过类似的困扰,当时设计了一个慢增长算法,每个自由链表中存了一个_maxSize,这里我们就做简单一点,用_maxSize来判断,即当一个自由链表长度超过一个批量(_maxSize)应该申请的内存时,我们把自由链表的_maxSize个节点还回span桶

首先在class ThreadCache中声明一个函数,用来把要释放的内存截下来送入CentralCache层。

        作用是把list中自由链表的_maxSize个节点截下来,然后送入CentralCache层,因为需要找到对应的哈希桶,所以传入内存块大小。

然后在Deallocate函数(释放内存)中检查是否满足归还span桶的条件,如下:

void ThreadCache::Deallocate(void* obj, size_t bytes)
{
    int index = SizeClass::Index(bytes);
    _freelists[index].Push(obj);
    if (_freelists[index].Size() >= _freelists[index].MaxSize())
    {
        size_t size = SizeClass::RoundUp(bytes);
        ListTooLong(_freelists[index], size);
    }
}

注:Size用来获取链表长度,这里就不展示了,可以用这两种方法之一:

  • 直接遍历链表并计数。
  • 添加并维护_size成员函数,Size()中直接返回_size。 

因为每次使用Size函数都遍历链表会影响效率,所以这里选用添加_size成员的方法。

接下来实现ListTooLong函数:

void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
    void* start = nullptr;
    void* end = nullptr;
    list.PopRange(start, end, list.MaxSize());
    //交给CentralCache
    CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}

        同样的使用start和end指针去取内存,在class FreeList里封装一个函数PopRange,如果你看懂了之前内存申请的代码,这里的实现就显得很简单,不在一一讲解。代码示例:

//取一段内存
void PopRange(void* start, void* end, int num)
{
    assert(num <= _size);
    start = end = _list_head;
    for (int i = 0; i < num - 1; i++)
    {
        end = Nextobj(end);
    }
    _list_head = Nextobj(end);
    Nextobj(end) = nullptr;
    _size -= num;
}

二、CentralCache内存回收

小节目标:ReleaseListToSpans(start, size)的实现

        CentralCache内存回收的主要任务就是把ThreadCache送来的内存连接到span桶中,其次,同样的问题,什么时候送到下一层呢?这个问题我们探讨过,就是span中的所有内存块都回收回来时

        记住:理解为什么要span中的所有内存块回收回来后送入下一层这一点很重要,在下文会讲到。

回收的内存插入到Span的自由链表中

        现在有一个问题:内存块要插入到哪个span?我们知道的size仅能找到它所在的哈希桶,也就是一个span链,要具体到某个span节点是一个很让人头疼的问题。

注意:不能粗鲁地把内存块连接到一个随便找的span,这样的话,属于不同页的内存块就混乱在一起,后面的PageCache就没法玩了!

        追溯到本质(思考内存块从“出生”到现在的这个过程):内存块连接到某个span,实际上是要找到内存块属于那个页号,然后连接到这个页号对应的span中。

        页号成为了它们之间的脐带,内存块地址除以8KB就是它所在的页,而一个span的信息中也能找到它管理着哪些页,只需要做一个页号到span的映射(即哈希表)就能解决问题。页级别的内存管理是在PageCache,我们把哈希映射在PageCache层创建和维护。

在PageCache中添加成员:

  • unordered_map<PAGE_ID, Span*> _idSpanMap;  

        _idSpanMap在哪里填写呢?答:在页分配时。在向系统申请内存和给span分配页前是不能确定页号和span的对应关系的,所以哈希映射是随程序运行的动态填写过程。

        将要被连接到CentralCache层的span,需要把它管理的所有页都映射到span中,因为拆开的内存块能获取到的是一个确定的页号。放回PageCache的span只需要它管理的开始页号和尾页号映射到span,用于span合并时对相邻页的查找。

如下:

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);
	if (!_spanLists[k].Empty())
	{
		return _spanLists[k].PopFront();
	}
	for (int i = k + 1; i < NPAGES; i++)
	{
        //切割页(即页分配)
		if (!_spanLists[i].Empty())
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;
			kSpan->_n = k;//页数
            nSpan->_n -= k;
            //填写页号
            kSpan->_pageId = nSpan->_pageId;
			nSpan->_pageId += k;
            //建立映射
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
			for (PAGE_ID i = 0; i < kSpan->_n; i++)
			{
				_idSpanMap[kSpan->_pageId + i] = kSpan;
			}
			_spanLists[nSpan->_n].PushFront(nSpan);
			kSpan->_isUse = true;//表示span被CentralCache使用,在下文会讲到。
			return kSpan;
		}
	}
	//向系统申请内存
	Span* span = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);
	span->_pageId = (PAGE_ID)ptr>>PAGE_SHIFT;
	span->_n = NPAGES - 1;
	_spanLists[span->_n].PushFront(span);
	return NewSpan(k);
}

对于使用内存块查找span我们封装一个函数,如:

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID index = (PAGE_ID)obj >> PAGE_SHIFT;
	auto ret = _idSpanMap.find(index);
	if (ret == _idSpanMap.end())
	{
		assert(false);
		return nullptr;
	}
	else
	{
		return ret->second;
	}
}

接下来我们来实现ReleaseListToSpans(start, size),它的核心工作:

  1. 找到每个内存块对应的span。
  2. 把内存块连接到span的自由链表中。
  3. 判断内存块是否全部回收,如果是,就将span送入PageCache中。

注意:找到哈希桶后需要加桶锁。

        对于第3点,在把span送入PageCache之前,需要把span从CentralCache移除,并把span前后指针指向空,自由链表指向空。其次走到这一步意味着程序将离开CentralCache层进入PageCache层,在此之前把桶锁解了,方便让其他线程使用,然后加锁执行PageCache的内存回收函数,解锁,然后再次申请桶锁

代码示例:

void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	void* p  = start;
	//找到每个p所在Span并连接到自由链表
	//加桶锁
	int index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();
	while (p)
	{
		void* next = Nextobj(p);
		Span* span = PageCache::GetInstance()->MapObjectToSpan(p);
		Nextobj(p) = span->_freeList;
		span->_freeList = p;
		span->_useCount--;
		if (span->_useCount == 0)
		{
			//移出Span链表
			_spanLists->Erase(span);
			span->_freeList = nullptr;
			span->_next = span->_prev = nullptr;
			//交给PageCache回收
			_spanLists[index]._mtx.unlock();

			PageCache::GetInstance()->_pageMtx.lock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->_pageMtx.unlock();
			
			_spanLists[index]._mtx.lock();
		}
		p = next;
	}
	_spanLists[index]._mtx.unlock();
}

内存块全部回收后送入PageCache的作用:

        内存块回收到span后,它在自由链表中的排布已经不像初次从PageCache取出那样是有序的,而是混乱的。等span全部内存块回收后,就可以把它们看做一个整体,尽管内存块的排布是混乱的也没关系,这只是逻辑上的混乱,回到PageCache层后它就是一个大块页。等PageCache再次分页时,内存块次序恢复和虚拟地址一样,增加高速缓存命中率。

三、PageCache内存回收

小节目标:ReleaseSpanToPageCache(span)的实现。

        当PageCache拿到span后不要直接连接到哈希桶里,而把它前后能合并的页都合并在一起组成一个大块的页,然后再连接到哈希桶,这样可以缓解内存外碎片

        注意 是把它前后的页span合并在一起,只有连续页的span才能合并,而不是前后桶的span,前后页的span也无法依据桶来找,要找到管理它前后页的span需要用哈希映射。

        但又会带来一些问题,哈希映射得到的span可能是在CentralCache中,也有可能是在PageCache中,因为我们用的是同一个哈希映射,而合并span,指的是PageCache中的span,要如何解决?

        这里我们选择在span中添加成员变量_isUse来判断span是否分配给了CentralCache,这样就可以把它们区分开。先暂时这样写,后期再做优化。比如一个span的_isUse初始状态为false,分配给CentralCache后设为true,PageCache的回收处理后设为false。

合并条件:

  • 它相邻的页在哈希映射中。
  • 该页不被_CentralCache使用。
  • 两个span的页数相加不超过128。

合并过程:

  • 用来储存合并后结果的span:页号更新为两个span中较小的页号,页数更新为两者的页数和。
  • 另一个span:从PageCache的哈希桶中移除,delete删除,释放空间。

合并结果处理:

  • 按上面逻辑把前后页合并得到的span,_isUse设为false,将其连接到PageCache的哈希桶,然后做页到span的哈希映射。

代码示例:

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	PAGE_ID n = span->_pageId;
	//向前合并
	while (true)
	{
		PAGE_ID prev = span->_pageId - 1;
		auto ret = _idSpanMap.find(prev);
		if (ret != _idSpanMap.end()&&!ret->second->_isUse)
		{
			if (span->_n + ret->second->_n >= NPAGES)
				break;
			span->_pageId = ret->second->_pageId;
			span->_n += ret->second->_n;
			_spanLists->Erase(ret->second);
			delete ret->second;
		}
		else break;
	}
	//向后合并
	while (true)
	{
		PAGE_ID prev = span->_pageId + 1;
		auto ret = _idSpanMap.find(prev);
		if (ret != _idSpanMap.end() && !ret->second->_isUse)
		{
			if (span->_n + ret->second->_n >= NPAGES)
				break;
			span->_n += ret->second->_n;
			_spanLists->Erase(ret->second);
			delete ret->second;
		}
		else break;
	}
	span->_isUse = false;
	_spanLists->PushFront(span);
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
}

四、源码

需要源码的小伙伴到我的gitee上取:

内存释放/PageCache · 敲上瘾/ConcurrentMemoryPool - 码云 - 开源中国

非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!74c0781738354c71be3d62e05688fecc.png


网站公告

今日签到

点亮在社区的每一天
去签到