内存池
为什么要手动创建内存池?
因为使用系统的内存,在使用new/delete/malloc/free操作时,为了防止内存泄露问题,我们需要new/delete、malloc/free配套使用,而我们不断进行内存申请与释放会增大开销,同时还会导致内存碎片化的问题。关于内存碎片化就好比,虽然剩余内存有这么大,但是没有一个连续的大内存空间,当用户需要申请一块连续的大内存时,导致申请失败。
new(关键字)失败会抛出异常返回std::bad_alloc。malloc(标准库函数)不会抛出异常返回nullptr。
创建内存池的作用
首先创建内存池,我们需要管理内存池。简单来说,我们不延续之前的RALL思想,我们通过实现申请一篇内存,打造为内存池,当用户使用时,只需向内存池申请内存,而不需要向内核申请。因为,内存池已经向内核申请一大块连续的内存,用户只需使用内存池的内存就行。而在使用完内存后,用户需要释放内存,但不是真的释放,将内存还回内存池,达到复用的结果。我们设计内存池后,可以减少频繁的向内核申请内存,减少开销,并减少内存碎片化。但值得注意的是,我们设计的内存池,只面向小内存申请,当需要申请过大内存仍然使用原有操作,因为需要申请过大内存,我们内存池的优势就没有了,内存池优化建立于对象尺寸远小于内存池。
性能优化的角度:
- 减少动态内存分配的开销
- 避免内存碎片
- 降低系统调用频率
确定性:稳定的分配时间。使用内存池可以使分配和释放操作的耗时更加可控稳定,适合实时性有严格要求的系统。
使用场景
服务器开发、高性能计算、实时系统、游戏开发、网络编程、内存管理器等。
缺点
复杂度:比使用常规操作复杂
初始化内存:需要事先分配内存
不适合大型对象
内存池框架
我们是基于开源项目TCMalloc(Thread-Caching Malloc)实现的,该项目是Goole开发的内存分配器。实现了高效的内存池,旨在优化内存分配和释放的性能,特别是在多线程环境下。内存池通过分层缓存架构来管理内存,有三层:
- ThreadCache 线程本地缓存。每个线程独立的内存缓存,无锁操作,快速分配和释放内存。减少线程间的竞争,提高并发性
- CentralCache 中心缓存。管理多个线程共享的内存块;通过自旋锁保护,确保线程安全;批量从PageCache获取内存,分配给ThreadCache。
- PageCache 页内存。从操作系统获取大块内存;将大块内存分成小块,供CentralCache使用;负责内存的回收和再利用。
线程本地化缓存
我们通过array链表,为每个线程创建自由链表,将利用index作为主键分配(根据大小分配链表索引,利用链表指针来分配内存和回收)。当内存不足时向CentralCache申请内存,并根据自定义设计决定是否需要将内存归还给中心缓存。
简单代码如下:
// 线程本地缓存
class ThreadCache
{
public:
static ThreadCache* getInstance()
{
static thread_local ThreadCache instance;
return &instance;
}
void* allocate(size_t size);
void deallocate(void* ptr, size_t size);
private:
ThreadCache()
{
// 初始化自由链表和大小统计
freeList_.fill(nullptr);
freeListSize_.fill(0);
}
// 从中心缓存获取内存
void* fetchFromCentralCache(size_t index);
// 归还内存到中心缓存
void returnToCentralCache(void* start, size_t size);
bool shouldReturnToCentralCache(size_t index);
private:
// 每个线程的自由链表数组
std::array<void*, FREE_LIST_SIZE> freeList_;
std::array<size_t, FREE_LIST_SIZE> freeListSize_; // 自由链表大小统计
};
中心缓存
struct SpanTracker
{
std::atomic<void*> spanAddr{nullptr};
std::atomic<size_t> numPages{0};
std::atomic<size_t> blockCount{0};
std::atomic<size_t> freeCount{0}; //用于追踪span还有多少块空闲,都空闲,归还span到PageCache
};
class CentralCache
{
public:
static CentralCache& getInstance()
{
static CentralCache instance;
return instance;
}
void* fetchRange(size_t index);
void returnRange(void* start, size_t size, size_t index);
private:
//相互是还所有原子指针为 nullptr
CentralCache();
//从页缓存获取内存
void* fetchFromPageCache(size_t size);
//获取span信息
SpanTracker* getSpanTracker(void* blockAddr);
//更新span空闲计数并检查是否可以归还
void updateSpanFreeCount(SpanTracker* tracker, size_t newFreeBlocks, size_t index);
private:
//中心化缓存的自由链表
std::array<std::atomic<void*>, FREE_LIST_SIZE> centralFreeList_;
//用于同步自旋锁
std::array<std::atomic_flag, FREE_LIST_SIZE> lock_;
//使用数组存储span信息,避免map开销
std::array<SpanTracker, 1024> spanTrackers_;
std::atomic<size_t> spanCount_{0};
//延迟归还相关的成员变量
static const size_t MAX_DELAY_COUNT = 48; //最大延迟计数
std::array<std::atomic<size_t>, FREE_LIST_SIZE> delayCounts_; //每个大小类的延迟计数
std::array<std::chrono::steady_clock::time_point, FREE_LIST_SIZE> lastReturnTimes_;
static const std::chrono::milliseconds DELAY_INTERVAL; //延迟间隔
bool shouldPerformDelayedReturn(size_t index, size_t currentCount, std::chrono::steady_clock::time_point currentTime);
void performDelayedReturn(size_t index);
};
我们向中心化缓存申请空间是按照传入参数index申请的,因为通过index我们能确定空间大小的范围,分配出比较合适的内存,方便对其颗粒度,减少不必要的内存浪费。不会出现我只需要8b的内存,而分配512的内存。而申请超过限定值的需要直接向系统申请内存。
页缓存
public:
static const size_t PAGE_SIZE = 4096; //一页4k
//单例模式
static PageCache& getInstance()
{
static PageCache instance;
return instance;
}
//分配指定页面的Span
void* allocateSpan(size_t numPages);
//释放span
void deallocateSpan(void* ptr, size_t numPages);
private:
PageCache() = default;
//向系统申请空间
void* systemAlloc(size_t numPages);
private:
struct Span
{
void* pageAddr; //页起始地址
size_t numPages; //页数量
Span* next; //链表指针
};
//按页数管理Spans,不同页面对应不同的Spans
std::map<size_t, Span*> freeSpans_;
//页号到Span的映射,用于回收
std::map<void*, Span*> spanMap_;
std::mutex mutex_;
};
我们页面缓存是以页为单位的4K,可以减少内存碎片,提高内存管理效率,硬件层次优化(操作系统虚拟内存也是以页为单位的),适应不同内存大小的内存需求。
我们回收内存尝试将连续的空闲页面进行合并,防止多次使用后导致碎片化的问题。
项目所设计的工作有:
优化内存管理。
结合定时器实现批量内存管理
高效同步机制。
所涉及的八股文知识有:多线程,原子操作(atomic)、设计模式、自旋锁、互斥锁、高并发、RALL等
八股文补充
死锁是指在多线程或多进程程序中,多个进程或线程在执行过程中,因争夺资源而造成一种相互等待的状态,从而导致程序无法继续执行。
死锁的四个必要条件
死锁的发生必须满足以下四个条件,这四个条件通常被称为“死锁的必要条件”:
- 互斥条件:至少有一个资源是处于“非共享”状态的,即每次只能有一个线程或进程使用这个资源。如果有其他线程请求该资源,必须等待。
- 占有并等待:至少有一个线程已经持有了一个资源,并且正在等待其他线程所持有的资源。
- 不剥夺条件:资源不能被强制从持有者的手中剥夺,只有在持有者自行释放资源时,其他线程才能获得该资源。
- 循环等待:存在一种线程或进程的循环等待关系,即线程 A 等待线程 B 持有的资源,线程 B 等待线程 C 持有的资源,最终线程 C 又等待线程 A 持有的资源,形成一个环形等待链。
死锁的危害
- 系统崩溃或性能下降:死锁会导致程序停滞不前,使得受影响的线程无法继续执行。严重时,整个应用程序的性能会受到影响,甚至导致系统崩溃。
- 资源浪费:死锁会造成资源的长时间占用,而无法被其他线程或进程使用,导致资源的浪费。
- 用户体验差:死锁的存在可能导致应用程序的响应延迟或无响应,从而影响用户体验。
死锁的预防策略
- 避免互斥条件:如果可能的话,尽量避免将资源设置为“互斥”资源,即允许多个线程或进程同时访问某些资源。这在一些资源的设计上可能实现,如共享内存、并发数据结构等。
- 避免占有并等待:一次性请求:要求线程在开始时请求所有所需的资源。如果不能同时获得所有资源,则不分配任何资源,从而避免线程持有部分资源时再等待其他资源。
- 避免不剥夺条件:如果一个线程请求资源时发现部分资源被其他线程占用,它可以释放自己已经持有的资源并重新尝试请求,避免形成不剥夺条件。
- 避免循环等待:资源排序法:按固定顺序申请资源,如果一个线程请求的资源没有满足要求,它会按照顺序排队,直到所有资源都可以满足为止。这样可以避免循环等待。例如,a,b两个进程都需要资源1,资源2,如果a进程获得了资源1,然后b进程获得了资源2就会导致死锁,如果强制要求必须先获得资源1再获得资源2,如果某个进程先获得了资源1,那么另一个进程就会等待,直到资源1的释放,就打破死锁了。
死锁的检测和恢复
尽管死锁预防非常重要,但在一些复杂的并发环境中,无法保证完全避免死锁。因此,死锁的检测和恢复也很重要。
死锁检测
死锁检测是指通过某些算法在程序运行时检测是否发生了死锁。常见的死锁检测方法有:
- 资源分配图法:资源分配图是一种图结构,其中节点表示资源和进程,边表示资源与进程之间的关系。如果图中存在一个环,则说明发生了死锁。
- 等待图法:通过分析线程之间的等待关系,构建一个等待图。如果该图中有环,则表明程序发生了死锁。
死锁恢复
一旦检测到死锁,系统需要采取措施来恢复正常状态。常见的死锁恢复策略有:
- 撤销进程:通过终止一个或多个进程来破坏死锁的循环等待关系,进程终止后释放它所占用的资源,从而让其他进程继续执行。
- 资源抢占:系统可以通过抢占某个进程的资源并分配给其他进程,直到资源分配满足并避免死锁。
- 回滚:将进程回滚到之前的某个安全状态,释放资源并重新尝试获取资源。
项目中存在内存对其的问题:内存对齐主要是为了提高数据访问效率。
875

被折叠的 条评论
为什么被折叠?



