《0bug-C/C++商用工程之道》節選01--記憶體棧-1
7.2 記憶體池的核心邏輯—記憶體棧
在記憶體池中,首先要有一個記憶體塊管理的核心模組,來負責所有記憶體塊的申請、分發、回收和釋放工作,經過設計,筆者是使用“棧”來完成的這個模組,因此,筆者將其定名為“記憶體棧”(Memory Stack)。下面我們將詳細討論其設計細節。
7.2.1 記憶體管理的數學模型
記憶體塊如果要提升可重用性,必須對記憶體塊尺寸進行取模,否則的話,很容易因為幾個Bytes的偏差,導致記憶體塊無法重用,被迫向系統頻繁申請新的記憶體空間,那意義就不大了。
取模的主要目的,是減少記憶體塊的種類,以有限幾個尺寸的記憶體塊,應對絕大多數記憶體使用要求。
筆者看過STL的記憶體管理模組原始碼,對其記憶體塊的取模機制深表欽佩,因此,在筆者自己的記憶體池中,也是按照這種方式取模。
提示:32位系統,有個位元組對齊問題,即一個程式變數單元,如一個結構體,一個記憶體塊,如果其尺寸不是4Bytes的整倍數,作業系統會按照比它大的整倍數分配記憶體,這其實也是作業系統在取模。比如我們的一個結構體為7Bytes,作業系統分配時會分配8Bytes,一個14Bytes的記憶體塊,作業系統會分配16Bytes,這主要是簡化記憶體地址運算,以一定的記憶體消耗,來提升程式的執行速度。
我們對記憶體的取模也是這個原理,當然,我們不可能像作業系統那樣,機械地以4Bytes為模數,那樣,記憶體塊種類還是太多,管理起來壓力很大,記憶體池的效率也不高。
筆者仿造STL的取模方式,在記憶體池中按照如下邏輯取模,簡單說來,就是從
序號 |
記憶體塊大小模數(Bytes) |
應對申請需求(Bytes) |
1 |
16 |
1~16 |
2 |
32 |
17~32 |
3 |
64 |
33~64 |
4 |
128 |
65~128 |
5 |
256 |
129~256 |
6 |
512 |
257~512 |
7 |
1k |
513~1k |
8 |
2k |
1k+1~2k |
9 |
4k |
2k+1~4k |
10 |
8k |
4k+1~8k |
11 |
16k |
8k+1~16k |
12 |
32k |
16k+1~32k |
13 |
64k |
32k+1~64k |
14 |
128k |
64k+1~128k |
15 |
256k |
128k+1~256k |
16 |
512k |
256k+1~512k |
17 |
1M |
512k+1~1M |
圖7.1:記憶體模數表 |
如表7.1所示,記憶體池實際上是一個樹型資料結構在管理,每一種型別的記憶體塊,構成一個連結串列,形成樹的“右枝”,而所有右枝的鏈頭,又是以一個連結串列在管理,形成樹的“左枝”。請注意,這並不是二叉樹,還是一顆普通的樹結構。如下圖所示:
圖7.2:記憶體管理樹模型 |
我們舉個例子,當應用程式申請一塊57Bytes的記憶體塊,程式邏輯會沿著樹的左枝,從頭到尾比對,首先是16Bytes的的管理鏈,由於57>16,因此,無法在這根右枝進行管理,因此繼續往下,32Bytes也不行,64Bytes,比57要大,可以使用,因此,就在64Bytes這根右枝實現記憶體塊管理。 |
管理原則:分配時,首先在合適的右枝尋找可用的記憶體塊,如果有,則直接分配給應用程式重用該塊,如果沒有,則向系統申請一塊64Bytes的記憶體塊,分配給應用程式使用。而當應用程式釋放時,記憶體塊本身是64Bytes的,因此,可以直接掛回到64Bytes這根右枝,等待下次重用。
提示:這裡面有一個隱含的推論,如果一個應用程式,需要一塊57Bytes的記憶體塊,那麼,我們分配一塊比它大的記憶體塊,比如64Bytes的記憶體塊,是完全可以的,應用程式不關心自己實際獲得的記憶體塊大小,同時,這種稍稍超大的分配機制,也不回引發任何的記憶體溢位bug,反而更安全,因此,這種記憶體取模分配的思路,是完全可行的。
7.2.2 管理模型的優化
雖然上文我們討論的是以連結串列方式管理,不過,在實做中,筆者發現一個問題,即連結串列效率不高,原因很簡單,筆者的連結串列是以佇列方式管理,每次從右枝取出記憶體塊,是從連結串列頭取出,但釋放時,將記憶體塊推回右枝,需要迴圈遍歷到連結串列尾部進行掛鏈操作。這在高速的記憶體申請和釋放時,會嚴重影響連結串列的效率。
筆者經過思考,發現一個問題,當一個記憶體塊被推回一個右枝,其實已經是無屬性的,比如,64Bytes這個右枝上,掛的都是64Bytes大小的記憶體塊,應用程式申請時,使用任何一塊都是可以的,無需考慮這塊是在鏈頭還是鏈尾,同時,申請的記憶體塊,都是需要初始化的,應用程式也不關心這塊記憶體塊是否剛剛被使用完,還是已經空閒很久了。筆者理解這個記憶體樹的右枝,其實已經是前文所說的“被動池”邏輯了。
我們知道,在“推”入和“提取”這個邏輯上,“棧”的效率遠高於“佇列”,通常我們不使用棧的唯一原因,主要是棧是“後進先出”邏輯,而佇列是“先進先出”邏輯,而我們常見的應用模型,一般都有資料順序要求,因此,佇列的使用場合,遠多於棧結構。
但此處既然我們已經明確論證了,記憶體塊無順序需求,那麼,我們完全可以使用棧模型來管理記憶體樹的右枝,以提高效率。
這在實做時非常簡單,當應用程式釋放一塊記憶體,我們需要推回右枝時,直接將其掛接到鏈頭即可,取消了無意義的迴圈遍歷鏈尾的操作,雖然,下次申請時,最後釋放的一塊記憶體會被最先分配使用,但這又有什麼關係呢?
這個優化看似很小,但實做時威力驚人,經筆者測試,記憶體塊的申請和釋放吞吐量,在“佇列”管理方式下,每秒僅5萬次左右,一旦使用“棧”方式管理,迅速提升到40~50萬次,提升了整整一個數量級。
正因為如此,筆者才將記憶體池最核心的記憶體管理模組,定名為記憶體棧(Memory Stack)。
提示:在進行程式開發時,很多時候,需要針對業務需求進行分析,實現針對性優化,很多時候,很小的一點優化,都可以大幅度提升程式的效能。反過來說,通用的優化其實不存在,只有深刻理解了業務需求之後,才有可能實施有效的優化方案。
提示:原則上,程式開發應該遵循“先實現,後優化”的原則,筆者常說的“先解決有無問題,再解決好壞問題”,也是這個意思,本章在此先討論優化,是因為筆者這個記憶體池在實踐中已經經過了多次優化,有條件討論此事,並不意味著可以再程式實現前實施優化,請各位讀者關注這個細節。事實上,本書展示的記憶體池,已經是筆者第19個版本,中間經過了十幾次優化的結果。
7.2.3 關於連結串列管理的思考
討論完上面的問題,我們再來討論一下基本資料結構管理的問題。我們知道,雖然我們的記憶體池是樹結構管理,但具體到每個右枝上,還是連結串列,而連結串列元素是動態申請的,依賴內部指向下一元素的指標,實現連結關係。
具體到我們記憶體塊管理上,我們發現一個基本的連結串列元素,至少需要定義成如下形式:
typedef struct _CHAIN_TOKEN_ { struct _CHAIN_TOKEN_* m_pNext; //指向下一連結串列元素的指標 char* m_pBuffer; //指向真實記憶體塊的指標 }SChainToken; |
這就帶來一個問題,連結串列的元素,應該分為兩部分,一部分是實現連結串列管理的邏輯資料,如:m_pNext,另一部分,是業務相關的資料,如m_pBuffer,這樣的資料結構,造成程式開發非常麻煩。
比如一個簡單的記憶體申請和釋放動作,以上述資料結構管理,其基本邏輯如下:
記憶體申請: 1、檢查連結串列有無空閒記憶體單元 2、如果有,提取其中的m_pBuffer,準備返回給應用程式使用 3、從連結串列中解除安裝已經為空的連結串列管理元素,直接釋放給系統(注意,無管控的記憶體塊釋放,記憶體碎片的隱患) 記憶體釋放: 1、尋找合適的鏈,準備做掛鏈操作 2、申請一個連結串列管理單元,將記憶體塊的指標放入其中的m_pBuffer 3、執行掛鏈操作,填充m_pBext指標 |
大家注意到沒有,我們本意是記憶體管理,減小記憶體碎片,但是,為了實現連結串列的管理,反而中間引入了一個多餘的連結串列單元申請和釋放邏輯,反而增加了記憶體碎片的產生可能,這種方法當然不可取。
另外,這裡還有一個隱患,我們分配給應用程式的記憶體塊,其中所有的記憶體單元,都是對應用程式透明的,應用程式可以任意使用,這說明,這塊記憶體塊中,沒有儲存任何關於記憶體塊尺寸的資訊,當應用程式釋放指標時,我們面臨一個問題,就是怎麼確定這根指標指向的記憶體塊,究竟有多大,應該掛在哪個右枝上,等待下次使用。
這個問題不解決,上述的記憶體釋放邏輯的第一步,尋找合適的鏈,根本無法完成。
因此,為了記錄記憶體塊的尺寸資訊,我們必須內部再建立一個對映表,將我們管理的每根記憶體塊指標,在申請時的具體尺寸,都記錄下來,等釋放時,需要根據指標,逆查其對應的長度資料,才能完成功能。
筆者做開發有個原則:“簡單的程式才是好程式”,上述邏輯雖然最終也能完成功能,但無論怎麼看,都太複雜了,不是好的解決方案。
為此,筆者經過了較長時間的思考,發現所有問題的核心焦點,無非只有兩條:
1、如何使連結串列的管理資料,不要發生新的動態記憶體分配。 2、如何使分配出去的指標,能夠攜帶相關的緩衝區尺寸資訊,避免額外的儲存和查詢壓力。 |
經過分析,筆者突發奇想,既然我們記憶體池管理的就是記憶體塊,就有儲存能力,為什麼我們不能利用記憶體塊做一點自己的管理資料儲存呢?大不了這個記憶體塊實際可用記憶體,比我們從作業系統申請的,要小一點,但這又有什麼關係呢?應用程式需要的只是自己需要的記憶體塊,這個記憶體塊原來有多大?能給應用程式使用的又有多大?應用程式並不關心。
經過考慮,筆者做了如下一個結構體:
typedef struct _TONY_MEM_BLOCK_HEAD_ { ULONG m_ulBlockSize; //記憶體塊的尺寸 struct _TONY_MEM_BLOCK_HEAD_* m_pNext; //指向下一連結串列元素的指標 }STonyMemoryBlockHead; //本結構體的長度,經過計算,恆定為8Bytes const ULONG STonyMemoryBlockHeadSize=sizeof(STonyMemoryBlockHead); |
上述結構體,包含了記憶體塊尺寸資訊,來滿足釋放時查詢的需求,同時,包含了指向下一元素的指標,這個指標,在分配給應用程式使用時,是無效的,只有當這個記憶體塊掛接在連結串列中時,才有意義。
筆者這麼思考,當我們向系統申請一個記憶體塊,比如說64Bytes,我們記憶體池佔用其中最開始的8Bytes來儲存上述資訊,也就是說,實際能給應用程式使用的,只有56Bytes。如圖7.3:
圖7.3:記憶體塊組織結構 |
我們假定這個記憶體塊的真實尺寸為N Bytes,我們從系統申請的首指標為p0,那麼,我們佔用8Bytes作為管理使用,當應用程式申請時,我們真實分配給應用程式的指標為p1=(p0+8)。這樣,當應用程式釋放時,我們只需要執行p0=(p1-8),即可求出原始首地址,並以此獲得所有的管理資訊。當最後向系統釋放記憶體時,我們只要記得釋放p0即可。
提示:此處可能出於業務考慮,有點違背C和C++無錯化程式設計方法中,關於指標不得參與四則運算的原則,不過,沒辦法,需求如此,只有這條路走了。因此,違背就違背一點了。
唯一需要我們注意的細節,是我們在分析應用程式的記憶體申請需求時,不能以申請的記憶體塊的真實尺寸進行比對,而應該比對減去8Bytes之後的資料。即64Bytes這個右枝上提供的記憶體,只有56Bytes大小,如果超過這個值,請找下一鏈,即到128 Bytes這個右枝處理,當然,此時的128 Byte的右枝,也僅能提供120 Bytes的記憶體塊,以此類推。
有鑑於此,筆者做了如下的巨集定義,來界定所有的計算行為:
//根據一個應用程式資料塊的長度,計算一個記憶體塊的真實大小,即n+8 #define TONY_MEM_BLOCK_SIZE(nDataLength) \ (nDataLength+STonyMemoryBlockHeadSize) //根據向系統申請的記憶體塊,計算其應用程式資料記憶體的真實大小,即n-8 #define TONY_MEM_BLOCK_DATA_SIZE(nBlockSize) \ (nBlockSize-STonyMemoryBlockHeadSize) //根據應用程式釋放的指標,逆求真實的記憶體塊指標,即p0=p1-8 #define TONY_MEM_BLOCK_HEAD(pData) \ ((STonyMemoryBlockHead*)(((char*)pData)-STonyMemoryBlockHeadSize)) //根據一個記憶體塊的真實指標,求資料記憶體塊的指標,即p1=p0+8 #define TONY_MEM_BLOCK_DATA(pHead) \ (((char*)pHead)+STonyMemoryBlockHeadSize) //最小記憶體塊長度,16 Bytes,由於我們管理佔用8 Bytes,這個最小長度不能再小了, //否則無意義,即使這樣,我們最小的記憶體塊,能分配給應用程式使用的,僅有8 Bytes。 #define TONY_XIAO_MEMORY_STACK_BLOCK_MIN 16 //這是管理的最大記憶體塊長度,1M,如前文表中所示,超過此限制,記憶體池停止服務 //改為直接向系統申請和釋放。 #define TONY_XIAO_MEMORY_STACK_MAX_SAVE_BLOCK_SIZE (1*1024*1024) |
轉載於:https://blog.51cto.com/tonyxiaohome/597720