高并发内存池6 —— Page Cache

【投稿赢 iPhone 17】「我的第一个开源项目」故事征集:用代码换C位出道! 10w+人浏览 1.4k人参与

Page Cache 整体设计

与 Central Cache 的不同之处

首先,Page Cache 与 Central Cache 的底层结构,也是一个哈希桶结构,但是对于 Page Cache 来说,在哈希桶的结构里面,存放的就不再是一个一个的字节对应的空间,取而代之则是一个页一个页。

为什么要用页呢?

从系统层面来看,内存管理的基本单位是页

为什么这里最大挂 128 页的 Span 呢?因为线程申请单个对象最大是 256KB,而 128 页可以被切成 4 个 256KB 的对象,因此是足够的。当然,如果你想在 Page Cache 中挂更大的 Span 也是可以的,根据具体的需求进行设置就行了。

我们设置一页的大小是 8KB,那从此之后每一个 Span 里面,现在又多了很多属性,
例如页的起始地址、页的数量

在 Page Cache 中获取一个 n 页的 Span

如果 Central Cache要获取一个 n 页的 Span,那我们就可以在 Page Cache 的第 n 号桶中取出一个 Span 返回给 Central Cache 即可,但如果第 n 号桶中没有 Span了,这时我们**并不是直接转而向堆申请一个 n 页的 Span,而是要继续在后面的桶当中寻找 Span。**

直接向堆申请以页为单位的内存时,我们应该尽量申请大块一点的内存块,因为此时申请到的内存是连续的,当线程需要内存时我们可以将其切小后分配给线程,而当线程将内存释放后我们又可以将其合并成大块的连续内存。如果我们向堆申请内存时是小块小块的申请的,那么我们申请到的内存就不一定是连续的了。

因此,当第 n 号桶中没有 Span 时,我们可以继续找第 n + 1 号桶,因为我们可以将 n + 1 页的 Span 切分成一个 n 页的 Span 和一个 1 页的 Span,这时我们就可以将 n 页的 Span 返回,而将切分后 1 页的 Span 挂到 1 号桶中。但如果后面的桶当中都没有 span,这时我们就只能向堆申请一个 128 页的内存块,并将其用一个 span 结构管理起来,然后将 128 页的 span 切分成 n 页的 Span 和 128 - n 页的 span,其中 n 页的 Span 返回给 Central Cache,而 128 - n 页的 Span 就挂到第 128 - n 号桶中。

也就是说,我们每次向堆申请的都是 128 页大小的内存块,Central Cache 要的这些 Span 实际都是由 128 页的 Span 切分出来的。

不用桶锁

当每一个线程的 Thread Cache 没有对象时,就会前往 Central Cache 中寻找对象,而当 Central Cache 也没有对象时,就又会前往 Page Cache 中获取对象。

正因如此,所以每一次访问 Page Cache 必定会存在线程安全的问题,所以必须使用锁来管理。

但是,刚刚也讲了,在 Page Cache 中,存在多次对各个桶的访问,不仅仅是申请的访问,也是在合并拆分的访问。所以会涉及到大量的桶的访问,所以**大量的_加桶锁和解锁_,必是会降低程序的效率**

基于这一点,为了解决锁的多次申请和释放,我们不妨减少锁的出现,所以这次采取选择一把 Page Cache 的大锁,就能完美的解决这次问题。

整体设计

单例模式

class PageCache
{
public:
    // 提供一个全局访问点
    static PageCache* GetInstance()
    {
        return &_sInst;
    }
private:
    SpanList _spanLists[NPAGES];
    std::mutex _pageMtx; // 大锁
private:
    PageCache() // 构造函数私有
    {}
    PageCache(const PageCache&) = delete; // 防拷贝

    static PageCache _sInst;
};

首先,毫无疑问的就是单例模式。

Central Cache 获取非空的 Span

在实现 Central Cache 的获取批量对象的函数 FetchRangeObj 中,有一个函数 GetOneSpan,这个函数是用来在 Central Cache 指定的桶中,获取一个非空的 Span,然后返回。

非空的 Span,具体指的就是 Span 里的 _freeList 不为空。

所以第一步一定是遍历,在这里我们没有必要像 list 和 map 那用封装一个迭代器,我们直接加就好了!

// 带头双向循环链表
class SpanList
{
public:
	Span* Begin()
	{
		return _head->_next;
	}
	Span* End()
	{
		return _head;
	}
private:
	Span* _head;
public:
	std::mutex _mtx; // 桶锁
};

遍历

// 在对应哈希桶中获取一个非空的 span
Span* CentralCache::GetOneSpan(SpanList &list, size_t byte_size)
{
    // 遍历一遍
    Span* it = list.Begin();
    while (it != list.End())
    {
        if (it->_freeList != nullptr)
            return it;

        it = it->_next;
    }

    // 没有找到非空的 Span / 没有 Span
}

申请页数

那现在是这样的,在指定的桶里面遍历一遍之后,万一发现了没有 Span 或时没有非空的 Span 时,那这个时候就不得不求助于 Page Cahce 了,可是 Page Cache 里面的单位可是页啊,你首先得将你需要的内容大小转换成页吧,这样 Page Cache 才能明白!

为了省事,能够不要让 Central Cache 多次向 Page Cache 中申请,所以最好以上来就先申请到申请对象个数的上限

获得对象的个数了,那我也知道每一个对象的大小,那我也就知道了一共需要多少的空间了!
因为我们假设一页是 8k,所以我直接除 8k,就获取页数了,但假如你申请的不够一页,我也给你一页。

class SizeClass
{
public:
    // Central Cache 一次向 Page Cache 获取多少页
    static size_t NumMovePage(size_t size)
    {
        size_t num = NumMoveSize(size); // 计算出 Thread Cache 一次向 Central Cache 申请对象的个数上限
        size_t nPage = num*size; // Num 个 Size 大小的对象所需的字节数

        nPage >>= PAGE_SHIFT; // 将字节数转换为页数
        if (nPage == 0) // 至少给一页
            nPage = 1;

        return nPage;
    }
};

这里的PAGE_SHIFT其实是 13,因为位运算的高效,所以使用位运算,而 13 则是 213 正好是 8K。

切割页,分配对象空间

现在经过遍历后,你没有一个合适的 Span,所以你去 Page Cache 申请,这个等会实现。
到这里你现在拿到了一个全新的 Span,既然是全新的那里面的页都是“粘”在一起的,所以你还需要将这些切割成一块一块的空间。

// 在对应哈希桶中获取一个非空的 Span
Span *CentralCache::GetOneSpan(SpanList &list, size_t byte_size)
{
    // 遍历一遍....
    
    // 没有找到非空的 Span,只能向 Page Cache 申请
    // todo: NewSpan
    Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(byte_size));
        
    // 现在我获得了一个 Span
    // 我得去对内部进行切割,使得里面全是 大小为 byte_size 的对象
    char* start = (char*)(span->_pageId << PAGE_SHIFT); // 通过 页号 找到 页的起始地址
    size_t size = span->_n << PAGE_SHIFT; // 算出所有页有多大
    char* end = start + size; // 找到最后一个位置
        
    // 切割
    span->_freeList = start;
    start += size;
    void* tail = span->_freeList;
    while (start < end)
    {
        NextObj(tail) = start;
        tail = NextObj(tail);
        start += size;
    }
    NextObj(tail) = nullptr; // 尾的指向置空
    
    // 将切好的 span 头插到 spanList
    list.PushFront(span);

    return span;
}
//带头双向循环链表
class SpanList
{
public:
	void PushFront(Span* span)
	{
		Insert(Begin(), span);
	}
private:
	Span* _head;
public:
	std::mutex _mtx; //桶锁
};

对照,方便理解

Page Cache 获取 Span

NewSpan

k 号桶不为空返回第一个 Span

在实现 GetOneSpan 函数这一部份中,有一个向 Page Cache 申请空间的函数 NewSpan,对 NewSpan 函数来说,我传递了页数进去,那我肯定需要在 Page Cache 中对应的桶里找到一个 Span。

找到之后,我还需要将头部的第一个 Span 返回,所以还需要自己实现一个 PopFront 函数

Span* PopFront()
{
    Span* span = _head->_next;
    Erase(span);
    return span;
}
// 获取一个 k 页的 Span
Span* PageCache::NewSpan(size_t k)
{
    assert(k > 0 && k < NPAGES);

    // 1. 若 k 位置的桶,不为空
    if (!_spanLists[k].IsEmpty())
        return _spanLists->PopFront(); // 直接返回
}

寻找后面的桶

如果当前的 k 号桶没有 Span 时,则需要往后寻找,找到一个有 Span 的桶,因为前面说过 Span 里面的页是可以合并拆分的,所以往后找到一个就拆分。

上面是讲解一个大概,但其实真实的细节也打差不差。

首先我们要明确 Span 的内部结构是如何管理页的,如果这部分没理解,将会非常吃力,
其次这里说的切分,不是对对象空间进行切分,而是对页进行切分的!

那么这里既然是 3 页,那就会在哈希桶里的第 3 个位置的桶里面。

然后现在假设你想要 1 页的 Span,然后你后面正好有这个 3 页的 Span,那么你就会去切割这些页。
就会变成 1 + 2 页的组合。

切页,更新页数

  1. 你需要创建一个 bigSpan,用来保存切割下来,最大的那一页,比如当前例子的 2 页的那个组合。
  2. _pageId 就是记录第几页,既然你需要 1 页的 Span,所以剩下的就是原来的 Span 的 _pageId + 1
  3. 接下来就是更新页数就好。
  4. 最后,再将 bigSpan 头差到 2 号桶里面。
// 获取一个 k 页的 Span
Span* PageCache::NewSpan(size_t k)
{
    assert(k > 0 && k < NPAGES);

    // 1. 若 k 位置的桶,不为空....

    // 2. 为空
    // 检查一下后面的桶里面有没有 Span,如果有可以将其进行切分
    for (int i = k; i < NPAGES; ++i)
    {
        if (!_spanLists[i].IsEmpty())
        {
            // 找到了个不为空的桶
            Span* span = _spanLists[i].PopFront(); // 获取第一个 Span
            assert(span);

            // 开始切分
            Span* bigSpan = new Span;

            bigSpan->_pageId = span->_pageId + k; // 获得新页号
            bigSpan->_n = span->_n - k; // 获得新页数

            span->_n = k; // 更新页数

            // 将切下来的 bigSpan,放在新的桶上
            _spanLists[bigSpan->_n].PushFront(bigSpan);
            return span;
        }
    }
}
后面没桶了!

比如在一开始的情况下,每个桶里面什么都没有,那这里就必须需要做处理了,那我们不妨直接在第 128 个位置的桶里申请一个 128 页的大页,因为反正后面也要不断的切割。
所以直接找堆拿 128 页大小的页出来。

这里直接使用调用系统申请空间出来,就不用 malloc 和 new 了

//直接去堆上申请按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	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;
}
// macOS 的内存管理和 Linux 一样是 Unix 系内核 (Darwin / BSD)
inline static void* SystemAlloc(size_t kpage)
{
    size_t bytes = kpage * (1 << PAGE_SHIFT);

    // mmap 参数:
    // addr = NULL -> 让内核自动分配地址
    // length = bytes -> 分配字节数
    // prot = PROT_READ | PROT_WRITE -> 读写权限
    // flags = MAP_PRIVATE | MAP_ANONYMOUS
    // fd = -1, offset = 0 -> 匿名映射(不和文件关联)

    void *ptr = mmap(
        NULL,
        bytes,
        PROT_READ | PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS,
        -1,
        0);

    if (ptr == MAP_FAILED)
        throw std::bad_alloc();

    return ptr;
}

然后你就获得了这个大页的地址!

地址转页号

通过上述的方法,可以直接写出代码:

你通过 SystemAlloc 获得的是一个地址啊,你要转换成页号啊!

这里的 ptr 就是获取一块连续的内存空间,然后返回当前连续的内存空间的首地址给 ptr

但是在系统内存中,其实内部是用一块一块的页来统计的。

你所申请的这一连续的空间,其实就是由多个页组合在一起的

在处理之前还需要理解页号和地址区别

  • 想象内存是一条大马路,每隔8KB画一条白线 → 一页。
  • ptr就像告诉你「某个车停在了公路上的0x60020000米处」。
  • pageId就是「这个车停在了第196624个停车格]。

操作系统/内存分配器更喜欢用“第几个格子”来管理,而不是具体的米数。

递归

程序到了这里,你别忘记你是要获得一个 Span 的,除非你再切一次页,那这不就非常冗余了吗。

所以在这里直接递归调用自己就好

// 尽量避免代码重复,递归调用自己
return NewSpan(k);

锁的调整

// 在对应哈希桶中获取一个非空的 span
Span* CentralCache::GetOneSpan(SpanList &list, size_t byte_size)
{
    // 1. 遍历一遍
    
    // 2. 没有找到非空的 Span,只能向 page cache 申请
    
    // ...................
    
    list._mtx.unlock(); // 桶锁:解锁 🔓
    PageCache::GetInstance()->Get_pageMtx().lock(); // 大锁:加锁 🔒

    Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(byte_size));

    PageCache::GetInstance()->Get_pageMtx().unlock(); // 大锁:解锁 🔓

    // 现在我获得了一个 Span
    // 我得去对内部进行切割,使得里面全是 大小为 byte_size 的对象
    // 切割
    
    // ...................

    list._mtx.lock(); // 桶锁:加锁 🔒

    // 将切好的 span 头插到 spanList
    list.PushFront(span);
    return span;
}

完整代码

// 获取一个 k 页的 Span
Span* PageCache::NewSpan(size_t k)
{
    assert(k > 0 && k < NPAGES);

    // 1. 若 k 位置的桶,不为空
    if (!_spanLists[k].IsEmpty())
        return _spanLists->PopFront(); // 直接返回

    // 2. 为空
    // 检查一下后面的桶里面有没有 Span,如果有可以将其进行切分
    for (int i = k; i < NPAGES; ++i)
    {
        if (!_spanLists[i].IsEmpty())
        {
            // 找到了个不为空的桶
            Span* span = _spanLists[i].PopFront(); // 获取第一个 Span
            assert(span);

            // 开始切分
            Span* bigSpan = new Span;

            bigSpan->_pageId = span->_pageId + k; // 获得新页号
            bigSpan->_n = span->_n - k; // 获得新页数

            span->_n = k; // 更新页数

            // 将切下来的 bigSpan,放在新的桶上
            _spanLists[bigSpan->_n].PushFront(bigSpan);
            return span;
        }
    }

    // 后面的桶,都没有 Span 了, 这时就向堆申请一个 128 页的 Span
    Span* largeSpan = new Span;
    void* ptr = SystemAlloc(NPAGES - 1); // 获得一个大页

    // ptr 为大页的起始地址,>>= PAGE_SHIFT 就可以获得页号
    largeSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
    largeSpan->_n = NPAGES - 1;
    _spanLists[largeSpan->_n].PushFront(largeSpan);

    // 尽量避免代码重复,递归调用自己
    return NewSpan(k);
}

总结

到这里,我们就完成了整个项目的核心部分,即申请空间的部分,接下来就是讲解释放的过程。

总结这几篇,函数与函数之间的关系十分紧凑,流程逻辑和结构也十分模糊,所以在写的过程中,一定要多多画图,最好能够像我这样梳理一遍:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

无双@

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

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

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

打赏作者

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

抵扣说明:

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

余额充值