實現一個高併發的內存池
1. 什麼是內存池
1.1 池化技術
- 池是在計算技術中經常使用的一種設計模式,其內涵在於:將程序中需要經常使用的核心資源先申請出來,放到一個池內,有程序自管理,這樣可以提高資源的利用率,也可以保證本程序佔有的資源數量,經常使用的池化技術包括內存池,線程池,和連接池等,其中尤以內存池和線程池使用最多。
1.2 內存池 - 內存池(Memory Pool)是一種動態內存分配與管理技術,通常情況下,程序員習慣直接使用new,delete,malloc,free等API申請和釋放內存,這樣導致的後果就是:當程序運行的時間很長的時候,由於所申請的內存塊的大小不定,頻繁使用時會造成大量的內存碎片從而降低程序和操作系統的性能。
- 內存池則是在真正使用內存之前,先申請分配一大塊內存(內存池)留作備用。當程序員申請內存時,從池中取出一塊動態分配,當程序員釋放時,將釋放的內存放回到池內,再次申請,就可以從池裏取出來使用,並儘量與周邊的空閒內存塊合併。若內存池不夠時,則自動擴大內存池,從操作系統中申請更大的內存池。
2. 爲什麼需要內存池
2.1 內存碎片問題
造成堆利用率很低的一個主要原因就是內存碎片化。如果有未使用的存儲器,但是這塊存儲器不能用來滿足分配的請求,這時候就會產生內存碎片化問題。內存碎片化分爲內部碎片和外部碎片。
- 內碎片
內部碎片是指一個已分配的塊比有效載荷大時發生的。(假設以前分配了10個大小的字節,現在只用了5個字節,則剩下的5個字節就會內碎片)。內部碎片的大小就是已經分配的塊的大小和他們的有效載荷之差的和。因此內部碎片取決於以前請求內存的模式和分配器實現(對齊的規則)的模式。 - 外碎片
假設系統依次分配了16byte、8byte、16byte、4byte,還剩餘8byte未分配。這時要分配一個24byte的空間,操作系統回收了一個上面的兩個16byte,總的剩餘空間有40byte,但是卻不能分配出一個連續24byte的空間,這就是外碎片問題。
2.2 申請效率問題
例如:我們上學家裏給生活費一樣,假設一學期的生活費是6000塊。
方式1:開學時6000塊直接給你,自己保管,自己分配如何花。
方式2:每次要花錢時,聯繫父母,父母轉錢。
同樣是6000塊錢,第一種方式的效率肯定更高,因爲第二種方式跟父母的溝通交互成本太高了。
同樣的道理,程序就像是上學的我們,操作系統就像父母,頻繁申請內存的場景下,每次需要內存,都像系統申請效率必然有影響。
3.內存池設計
3.1 爲什麼要使用內存池
- 解決內碎片問題
- 由於向內存申請的內存塊都是比較大的,所以能夠降低外碎片問題
- 一次性向內存申請一塊大的內存慢慢使用,避免了頻繁的向內存請求內存操作,提高內存分配的效率
- 但是內碎片問題無法避免,只能儘可能的降低
3.2 內存池的演變
- 最簡單的內存分配器
做一個鏈表指向空閒內存,分配就是取出一塊來,改寫鏈表,返回,釋放就是放回到鏈表裏面,並做好歸併。注意做好標記和保護,避免二次釋放,還可以花點力氣在如何查找最適合大小的內存快的搜索上,減少內存碎片,有空你了還可以把鏈表換成夥伴算法。
優點: 實現簡單
缺點: 分配時搜索合適的內存塊效率低,釋放回歸內存後歸併消耗大,實際中不實用。 - 定長內存分配器
即實現一個 FreeList,每個 FreeList 用於分配固定大小的內存塊,比如用於分配 32字節對象的固定內存分配器,之類的。每個固定內存分配器裏面有兩個鏈表,OpenList 用於存儲未分配的空閒對象,CloseList用於存儲已分配的內存對象,那麼所謂的分配就是從 OpenList 中取出一個對象放到 CloseList 裏並且返回給用戶,釋放又是從 CloseList 移回到 OpenList。分配時如果不夠,那麼就需要增長 OpenList:申請一個大一點的內存塊,切割成比如 64 個相同大小的對象添加到 OpenList中。這個固定內存分配器回收的時候,統一把先前向系統申請的內存塊全部還給系統。
優點: 簡單粗暴,分配和釋放的效率高,解決實際中特定場景下的問題有效。
缺點: 功能單一,只能解決定長的內存需求,另外佔着內存沒有釋放。
- 哈希映射的FreeList 池
在定長分配器的基礎上,按照不同對象大小(8,16,32,64,128,256,512,1k…64K),構造十多個固定內存分配器,分配內存時根據要申請內存大小進行對齊然後查H表,決定到底由哪個分配器負責,分配後要在內存頭部的 header 處寫上 cookie,表示由該塊內存哪一個分配器分配的,這樣釋放時候你才能正確歸還。如果大於64K,則直接用系統的 malloc作爲分配,如此以浪費內存爲代價你得到了一個分配時間近似O(1)的內存分配器。這種內存池的缺點是假設某個 FreeList 如果高峯期佔用了大量內存即使後面不用,也無法支援到其他內存不夠的 FreeList,達不到分配均衡的效果。
優點: 這個本質是定長內存池的改進,分配和釋放的效率高。可以解決一定長度內的問題。
缺點: 存在內碎片的問題,且將一塊大內存切小以後,申請大內存無法使用。多線程併發場景下,鎖競爭激烈,效率降低。
範例: sgi stl 六大組件中的空間配置器就是這種設計實現的。
關於STL空間配置器參考: https://blog.csdn.net/LF_2016/article/details/53511648 - 瞭解malloc底層原理
關於malloc底層: https://blog.csdn.net/hudazhe/article/details/79535220
malloc優點: 使用自由鏈表的數組,提高分配釋放效率;減少內存碎片,可以合併空閒的內存
**malloc缺點: ** 爲了維護隱式/顯示鏈表需要維護一些信息,空間利用率不高;在多線程的情況下,會出現線程安全的問題,如果以加鎖的方式解決,會大大降低效率。
4.併發內存池
4.1 項目介紹
我寫這個項目呢,主要是爲了學習,參考的是tc_malloc,項目設計分爲三層結構:
- 第一層是Thread Cache,線程緩存是每個線程獨有的,在這裏設計的是用於小於64k的內存分配,線程在這裏申請不需要加鎖,每一個線程都有自己獨立的cache,這也就是這個項目併發高效的地方。
- 第二層是Central Cache,在這裏是所有線程共享的,它起着承上啓下的作用,Thread Cache是按需要從Central Cache中獲取對象,它就要起着平衡多個線程按需調度的作用,既可以將內存對象分配給Thread Cache來的每個線程,又可以將線程歸還回來的內存進行管理。Central Cache是存在競爭的,所以在這裏取內存對象的時候是需要加鎖的,但是鎖的力度可以控制得很小。
- 第三層是Page Cache,存儲的是以頁爲單位存儲及分配的,Central Cache沒有內存對象(Span)時,從Page cache分配出一定數量的Page,並切割成定長大小的小塊內存,分配給Central Cache。Page Cache會回收Central Cache滿足條件的Span(使用計數爲0)對象,並且合併相鄰的頁,組成更大的頁,緩解內存碎片的問題。
注:怎麼實現每個線程都擁有自己唯一的線程緩存呢?
爲了避免加鎖帶來的效率,在Thread Cache中使用(tls)thread local storage保存每個線程本地的Thread Cache的指針,這樣Thread Cache在申請釋放內存是不需要鎖的。因爲每一個線程都擁有了自己唯一的一個全局變量。
TLS分爲靜態的和動態的:
靜態的TLS是:直接定義
動態的TLS是:調用系統的API去創建的,我們這個項目裏面用到的就是靜態的TLS
https://blog.csdn.net/evilswords/article/details/8191230
https://blog.csdn.net/yusiguyuan/article/details/22938671
4.2 設計Thread Cache
ThreadCache.h:
#pragma once
#include "Common.h"
class ThreadCache
{
private:
Freelist _freelist[NLISTS];//自由鏈表
public:
//申請和釋放內存對象
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
//從中心緩存獲取對象
void* FetchFromCentralCache(size_t index, size_t size);
//釋放對象時,鏈表過長時,回收內存回到中心堆
void ListTooLong(Freelist* list, size_t size);
};
//靜態的,不是所有可見
//每個線程有個自己的指針, 用(_declspec (thread)),我們在使用時,每次來都是自己的,就不用加鎖了
//每個線程都有自己的tlslist
_declspec (thread) static ThreadCache* tlslist = nullptr;
申請內存:
- 當內存申請size<=64k時在Thread Cache中申請內存,計算size在自由鏈表中的位置,如果自由鏈表中有內存對象時,直接從FistList[i]中Pop一下對象,時間複雜度是O(1),且沒有鎖競爭。
- 當FreeList[i]中沒有對象時,則批量從Central Cache中獲取一定數量的對象,插入到自由鏈表並返回一個對象。
釋放內存:
- 當釋放內存小於64k時將內存釋放回Thread Cache,計算size在自由鏈表中的位置,將對象Push到FreeList[i].
- 當鏈表的長度過長,也就是超過一次向中心緩存分配的內存塊數目時則回收一部分內存對象到Central Cache。
4.3 對齊大小的設計(對齊規則)
//專門用來計算大小位置的類
class SizeClass
{
public:
//獲取Freelist的位置
inline static size_t _Index(size_t size, size_t align)
{
size_t alignnum = 1 << align; //庫裏實現的方法
return ((size + alignnum - 1) >> align) - 1;
}
inline static size_t _Roundup(size_t size, size_t align)
{
size_t alignnum = 1 << align;
return (size + alignnum - 1)&~(alignnum - 1);
}
public:
// 控制在12%左右的內碎片浪費
// [1,128] 8byte對齊 freelist[0,16)
// [129,1024] 16byte對齊 freelist[16,72)
// [1025,8*1024] 128byte對齊 freelist[72,128)
// [8*1024+1,64*1024] 1024byte對齊 freelist[128,184)
inline static size_t Index(size_t size)
{
assert(size <= MAX_BYTES);
// 每個區間有多少個鏈
static int group_array[4] = { 16, 56, 56, 56 };
if (size <= 128)
{
return _Index(size, 3);
}
else if (size <= 1024)
{
return _Index(size - 128, 4) + group_array[0];
}
else if (size <= 8192)
{
return _Index(size - 1024, 7) + group_array[0] + group_array[1];
}
else//if (size <= 65536)
{
return _Index(size - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
}
}
// 對齊大小計算,向上取整
static inline size_t Roundup(size_t bytes)
{
assert(bytes <= MAX_BYTES);
if (bytes <= 128){
return _Roundup(bytes, 3);
}
else if (bytes <= 1024){
return _Roundup(bytes, 4);
}
else if (bytes <= 8192){
return _Roundup(bytes, 7);
}
else {//if (bytes <= 65536){
return _Roundup(bytes, 10);
}
}
//動態計算從中心緩存分配多少個內存對象到ThreadCache中
static size_t NumMoveSize(size_t size)
{
if (size == 0)
return 0;
int num = (int)(MAX_BYTES / size);
if (num < 2)
num = 2;
if (num > 512)
num = 512;
return num;
}
// 根據size計算中心緩存要從頁緩存獲取多大的span對象
static size_t NumMovePage(size_t size)
{
size_t num = NumMoveSize(size);
size_t npage = num*size;
npage >>= PAGE_SHIFT;
if (npage == 0)
npage = 1;
return npage;
}
};
4.4 設計Thread Cache
- Central Cache本質是由一個哈希映射的Span對象自由雙向鏈表構成
- 爲了保證全局只有唯一的Central Cache,這個類被可以設計成了單例模式
- 單例模式採用餓漢模式,避免高併發下資源的競爭
CentralCache.h:
#pragma once
#include "Common.h"
//上面的ThreadCache裏面沒有的話,要從中心獲取
/*
進行資源的均衡,對於ThreadCache的某個資源過剩的時候,可以回收ThreadCache內部的的內存
從而可以分配給其他的ThreadCache
只有一箇中心緩存,對於所有的線程來獲取內存的時候都應該是一箇中心緩存
所以對於中心緩存可以使用單例模式來進行創建中心緩存的類
對於中心緩存來說要加鎖
*/
//設計成單例模式
class CentralCache
{
public:
static CentralCache* Getinstence()
{
return &_inst;
}
//從page cache獲取一個span
Span* GetOneSpan(SpanList& spanlist, size_t byte_size);
//從中心緩存獲取一定數量的對象給threa cache
size_t FetchRangeObj(void*& start, void*& end, size_t n, size_t byte_size);
//將一定數量的對象釋放給span跨度
void ReleaseListToSpans(void* start, size_t size);
private:
SpanList _spanlist[NLISTS];
private:
CentralCache(){}//聲明不實現,防止默認構造,自己創建
CentralCache(CentralCache&) = delete;
static CentralCache _inst;
};
申請內存:
- 當Thread Cache中沒有內存時,就會批量向Central Cache申請一些內存對象,Central Cache也有一個哈希映射的freelist,freelist中掛着span,從span中取出對象給Thread Cache,這個過程是需要加鎖的。
- Central Cache中沒有非空的span時,則將空的span鏈在一起,向Page Cache申請一個span對象,span對象中是一些以頁爲單位的內存,切成需要的內存大小,並鏈接起來,掛到span中。
- Central Cache的span中有一個_usecount,分配一個對象給Thread Cache,就++_usecount。
釋放內存:
- 當Thread Cache過長或者線程銷燬,則會將內存釋放回Central Cache中的,釋放回來時- -_usecount。
- 當_usecount減到0時則表示所有對象都回到了span,則將Span釋放回Page Cache,Page Cache中會對前後相鄰的空閒頁進行合併。
特別關心:什麼是span?一個span是由多個頁組成的一個span對象。一頁大小是4k。
//Span是一個跨度,既可以分配內存出去,也是負責將內存回收回來到PageCache合併
//是一鏈式結構,定義爲結構體就行,避免需要很多的友元
struct Span
{
PageID _pageid = 0;//頁號
size_t _npage = 0;//頁數
Span* _prev = nullptr;
Span* _next = nullptr;
void* _list = nullptr;//鏈接對象的自由鏈表,後面有對象就不爲空,沒有對象就是空
size_t _objsize = 0;//對象的大小
size_t _usecount = 0;//對象使用計數,
};
特別關心:關於spanlist,設計爲一個雙向鏈表,插入刪除效率較高。
//和上面的Freelist一樣,各個接口自己實現,雙向帶頭循環的Span鏈表
class SpanList
{
public:
Span* _head;
std::mutex _mutex;
public:
SpanList()
{
_head = new Span;
_head->_next = _head;
_head->_prev = _head;
}
~SpanList()//釋放鏈表的每個節點
{
Span * cur = _head->_next;
while (cur != _head)
{
Span* next = cur->_next;
delete cur;
cur = next;
}
delete _head;
_head = nullptr;
}
//防止拷貝構造和賦值構造,將其封死,沒有拷貝的必要,不然就自己會實現淺拷貝
SpanList(const SpanList&) = delete;
SpanList& operator=(const SpanList&) = delete;
//左閉右開
Span* Begin()//返回的一個數據的指針
{
return _head->_next;
}
Span* End()//最後一個的下一個指針
{
return _head;
}
bool Empty()
{
return _head->_next == _head;
}
//在pos位置的前面插入一個newspan
void Insert(Span* cur, Span* newspan)
{
Span* prev = cur->_prev;
//prev newspan cur
prev->_next = newspan;
newspan->_next = cur;
newspan->_prev = prev;
cur->_prev = newspan;
}
//刪除pos位置的節點
void Erase(Span* cur)//此處只是單純的把pos拿出來,並沒有釋放掉,後面還有用處
{
Span* prev = cur->_prev;
Span* next = cur->_next;
prev->_next = next;
next->_prev = prev;
}
//尾插
void PushBack(Span* newspan)
{
Insert(End(), newspan);
}
//頭插
void PushFront(Span* newspan)
{
Insert(Begin(), newspan);
}
//尾刪
Span* PopBack()//實際是將尾部位置的節點拿出來
{
Span* span = _head->_prev;
Erase(span);
return span;
}
//頭刪
Span* PopFront()//實際是將頭部位置節點拿出來
{
Span* span = _head->_next;
Erase(span);
return span;
}
void Lock()
{
_mutex.lock();
}
void Unlock()
{
_mutex.unlock();
}
};
特別關心:怎麼才能將Thread Cache中的內存對象還給它原來的span呢?
答:可以在Page Cache中維護一個頁號到span的映射,當Span Cache給Central Cache分配一個span時,將這個映射更新到unordered_map中去,在Thread Cache還給Central Cache時,可以查這個unordered_map找到對應的span。
4.5 設計Page Cache
- Page cache是一個以頁爲單位的span自由鏈表。
- 爲了保證全局只有唯一的Page cache,這個類可以被設計成了單例模式。
- 本單例模式採用餓漢模式。
PageCache.h
#pragma once
#include "Common.h"
//對於Page Cache也要設置爲單例,對於Central Cache獲取span的時候
//每次都是從同一個page數組中獲取span
//單例模式
class PageCache
{
public:
static PageCache* GetInstence()
{
return &_inst;
}
Span* AllocBigPageObj(size_t size);
void FreeBigPageObj(void* ptr, Span* span);
Span* _NewSpan(size_t n);
Span* NewSpan(size_t n);//獲取的是以頁爲單位
//獲取從對象到span的映射
Span* MapObjectToSpan(void* obj);
//釋放空間span回到PageCache,併合並相鄰的span
void ReleaseSpanToPageCache(Span* span);
private:
SpanList _spanlist[NPAGES];
//std::map<PageID, Span*> _idspanmap;
std::unordered_map<PageID, Span*> _idspanmap;
std::mutex _mutex;
private:
PageCache(){}
PageCache(const PageCache&) = delete;
static PageCache _inst;
};
申請內存:
- 當Central Cache向page cache申請內存時,Page Cache先檢查對應位置有沒有span,如果沒有則向更大頁尋找一個span,如果找到則分裂成兩個。比如:申請的是4page,4page後面沒有掛span,則向後面尋找更大的span,假設在10page位置找到一個span,則將10page span分裂爲一個4page span和一個6page span。
- 如果找到128 page都沒有合適的span,則向系統使用mmap、brk或者是VirtualAlloc等方式申請128page span掛在自由鏈表中,再重複1中的過程。
釋放內存:
- 如果Central Cache釋放回一個span,則依次尋找span的前後_pageid的span,看是否可以合併,如果合併繼續向前尋找。這樣就可以將切小的內存合併收縮成大的span,減少內存碎片。
4.6 向系統申請內存
- VirtualAlloc https://baike.baidu.com/item/VirtualAlloc/1606859?fr=aladdin
- brk和mmap https://www.cnblogs.com/vinozly/p/5489138.html
5. 項目不足及擴展學習
- 項目的獨立性不足:
- 不足:當前實現的項目中我們並沒有完全脫離malloc,比如在內存池自身數據結構的管理中,如SpanList中的span等結構,我們還是使用的new Span這樣的操作,new的底層使用的是malloc,所以還不足以替換malloc,因爲們本身沒有完全脫離它。
- 解決方案:項目中增加一個定長的ObjectPool的對象池,對象池的內存直接使用brk、VirarulAlloc等向系統申請,new Span替換成對象池申請內存。這樣就完全脫離的malloc,就可以替換掉malloc。
- 平臺及兼容性:
- linux等系統下面,需要將VirtualAlloc替換爲brk等。
- x64系統下面,當前的實現支持不足。比如:id查找Span得到的映射,我們當前使用的是map<id,
Span*>。在64位系統下面,這個數據結構在性能和內存等方面都是撐不住。需要改進後基數樹。
具體參考:基數樹(radix tree) https://blog.csdn.net/weixin_36145588/article/details/78365480
6. 參考資料
幾個內存池庫的對比: https://blog.csdn.net/junlon2006/article/details/77854898
tcmalloc源碼學習: https://www.cnblogs.com/persistentsnail/p/3442185.html
TCMALLOC 源碼閱讀: https://blog.csdn.net/math715/article/details/80654167