高并发内存池
- [1. 项目介绍](#1. 项目介绍)
-
- [1.1 这个项目做的是什么](#1.1 这个项目做的是什么)
- [1.2 为什么做这个项目](#1.2 为什么做这个项目)
- [1.3 这个项目要求的知识储备](#1.3 这个项目要求的知识储备)
- [2. 什么是内存池](#2. 什么是内存池)
-
- [2.1 池化技术](#2.1 池化技术)
- [2.2 内存池](#2.2 内存池)
- [2.3 内存池主要解决的问题](#2.3 内存池主要解决的问题)
- [2.4 malloc](#2.4 malloc)
- [3. 开胃菜 -- 先设计一个定长的内存池](#3. 开胃菜 -- 先设计一个定长的内存池)
- [4. 高并发内存池整体框架设计](#4. 高并发内存池整体框架设计)

点赞 👍👍收藏 🌟🌟关注 💖💖
你的支持是对我最大的鼓励,我们一起努力吧!😃😃
1. 项目介绍
1.1 这个项目做的是什么
当前项目是实现一个高并发的内存池,他的原型是google的⼀个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)。
我们这个项目是把tcmalloc最核新的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华。
1.2 为什么做这个项目
malloc通常是C标准库中的内存分配函数,用于基本的内存分配需求。什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。并且它没有特别针对多线程进行优化,因此在多线程环境中可能会遇到性能瓶颈和锁竞争问题。而tcmalloc在多线程环境中表现出色,由于其优化的内存管理和内部缓存机制,在相同条件下比malloc显著提高了性能,减少了总耗时。特别是在大量内存分配和释放的操作中,tcmalloc能够更快地完成任务。
tcmalloc适用于需要高并发和大规模内存操作的应用。而malloc适用于简单的单线程应用或对性能要求不高的场景。
1.3 这个项目要求的知识储备
这个项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等等方面的知识。
2. 什么是内存池
2.1 池化技术
所谓"池化技术",就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
在计算机中,有很多使用"池"这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程有进入睡眠状态。
2.2 内存池
内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
2.3 内存池主要解决的问题
内存池 主要解决的当然是效率的问题 (频繁申请小块内存,一次申请大快内存),其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。那么什么是内存碎片呢?
需要补充说明的是内存碎片分为外碎片和内碎片,。外部碎片 是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用(比如说你要5字节,但是由于内存对齐,给你一个8字节的内存)。

2.4 malloc
C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,
而malloc实际就是一个内存池。malloc() 相当于向操作系统"批发"了一块较大的内存空间,然后"零售"给程序用。当全部"售完"或程序有大量的内存需求时,再根据实际需求向操作系统"进货"。malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。下面有几篇关于这块的文章,大概可以去简单看看了解一下。
一文了解,Linux内存管理,malloc、free 实现原理

3. 开胃菜 -- 先设计一个定长的内存池
作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是⼀个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,下面我们就先来设计一个定长内存池做个开胃菜,当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以学习它目的有两层,先熟悉一下简单内存池是如何控制的,第二它会作为我们后面内存池的一个基础组件。
定长内存池一次向堆要一大块连续的内存,当要内存的找我,释放内存不是直接还给堆,而是给我,我给你管理起来,等下次你在继续找我要。因此它需要有两个指针,_memory指向申请的一大块内存,_freelist指向释放的内存并且用链表方式连接起来,这个链表叫做自由链表。

这里有一个问题。自由链表中当前内存块如何知道下一个内存块的地址?
肯定是要保存下一块内存的地址,那如何保存呢?
可以这样做,取出当前内存块前4字节的地址,保存下一块的地址。如何取出当前内存块前4个字节地址呢?
我们可以把指针强制类型转化为int*,然后在解引用, 例如这样*(int*)ptr,就可以取到前4个字节了。
但是要注意的是,32位下地址4个字节,64位下地址8个字节,因此不能就直接把地址转成int*,而是转成void**,在解引用,例如*(void**)ptr,这样在32位下取4字节,64位下取8字节。
未来这个项目是可移值得,即可能放在windows也可能放在linux,但是平台不同,它们找堆申请内存是不一样的,所以我们可以来个条件编译。
windows和Linux下如何直接向堆申请页为单位的大块内存:
VirtualAlloc
brk和mmap
cpp
#pragma once
#ifdef _WIN32
#include<windows.h>
#else
//linux
#endif
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
//申请kpage页,每页8kb
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
template<class T>
class ObjectPool
{
public:
ObjectPool()
:_memory(nullptr), _remainBytes(0),_freelist(nullptr)
{}
T* New()
{
T* obj = nullptr;
//如果链表中有空闲的内存块,就优先使用
if (_freelist)
{
//头删
void* next = *(void**)_freelist;
obj = (T*)_freelist;
_freelist = next;
}
else
{
//if (_memory == nullptr)//不能直接判断释放为空,因为内存是连续的
if(_remainBytes < sizeof(T))//如果当前内存块不够一个T类型,就重新申请一大快内存快
{
_remainBytes = 128 * 1024;
//_memory = (char*)malloc(_remain);
_memory = (char*)SystemAlloc(_remainBytes >> 13);//找对申请,按页为单位,想要每页8kb,申请多少页
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
//保证内存块至少能放下一个地址
size_t Size = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += Size;
_remainBytes -= Size;
}
// 定位new,显示调用T的构造函数初始化
new(obj)T;
return obj;
}
void Delete(T* obj)
{
// 显示调用析构函数清理对象
obj->~T();
//直接头插
* (void**)obj = _freelist;
_freelist = obj;
}
private:
char* _memory;//指向申请的一大块内存
size_t _remainBytes;//记录申请一大块内存还剩下多少内存
void* _freelist;//指向释放后的内存,以链表形式管理起来
};
4. 高并发内存池整体框架设计
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题。
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎片问题。
concurrent memory pool主要由以下3个部分构成:
-
thread cache :线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个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分配出一个span它管理一定数量的page,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

总结一下,每个线程都有自己的ThreadCach,内存小于256KB的时候都找自己的ThreadCache要(大于256KB后面在说),这样多线程要内存就先不要加锁了。如果ThreadCache中没有了,就再去找CentralCache要,CentralCache是所有线程共享的,因此需要加锁,但是它加的是桶锁,所以锁的竞争没有那么激烈。当ThreadCache中空闲内存太多了CentralCache还会进行回收,避免一个线程栈用太多内存,导致其他线程内存吃紧,让内存分配在多下次中更加均衡了。如果CentralCache中没有合适的内存,就去找PageCache要,PageCache是以页为单位对内存进行存储和分配的。当一个span中几个跨度页的对象都回收以后,PageCache会回收CentralCache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。当PageCache不够就去找堆要。
关于这个高并发内存池,有申请内存也有释放内存,为了更简单一些,我们先说申请,在说释放,最后在针对它进行细节处理以及优化。