1. 程式人生 > 實用技巧 >《0bug-C/C++商用工程之道》節選01--記憶體棧-1

《0bug-C/C++商用工程之道》節選01--記憶體棧-1

7.2 記憶體池的核心邏輯記憶體棧

在記憶體池中,首先要有一個記憶體塊管理的核心模組,來負責所有記憶體塊的申請、分發、回收和釋放工作,經過設計,筆者是使用“棧”來完成的這個模組,因此,筆者將其定名為“記憶體棧”(Memory Stack)。下面我們將詳細討論其設計細節。

7.2.1 記憶體管理的數學模型

記憶體塊如果要提升可重用性,必須對記憶體塊尺寸進行取模,否則的話,很容易因為幾個Bytes的偏差,導致記憶體塊無法重用,被迫向系統頻繁申請新的記憶體空間,那意義就不大了。

取模的主要目的,是減少記憶體塊的種類,以有限幾個尺寸的記憶體塊,應對絕大多數記憶體使用要求。

筆者看過STL的記憶體管理模組原始碼,對其記憶體塊的取模機制深表欽佩,因此,在筆者自己的記憶體池中,也是按照這種方式取模。

提示:32位系統,有個位元組對齊問題,即一個程式變數單元,如一個結構體,一個記憶體塊,如果其尺寸不是4Bytes的整倍數,作業系統會按照比它大的整倍數分配記憶體,這其實也是作業系統在取模。比如我們的一個結構體為7Bytes,作業系統分配時會分配8Bytes,一個14Bytes的記憶體塊,作業系統會分配16Bytes,這主要是簡化記憶體地址運算,以一定的記憶體消耗,來提升程式的執行速度。

我們對記憶體的取模也是這個原理,當然,我們不可能像作業系統那樣,機械地以4Bytes為模數,那樣,記憶體塊種類還是太多,管理起來壓力很大,記憶體池的效率也不高。

筆者仿造STL的取模方式,在記憶體池中按照如下邏輯取模,簡單說來,就是從

16Bytes開始,以兩倍方式遞增模數。直到4G記憶體為止。當然,實際使用時,超過1M的記憶體塊,一般應用很少,即使有,基本上也屬於應用程式永久緩衝區,很少會中途頻繁釋放,因此,筆者的記憶體池管理,一般模數為16Bytes~1M即可。

序號

記憶體塊大小模數(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即可。

提示:此處可能出於業務考慮,有點違背CC++無錯化程式設計方法中,關於指標不得參與四則運算的原則,不過,沒辦法,需求如此,只有這條路走了。因此,違背就違背一點了。

唯一需要我們注意的細節,是我們在分析應用程式的記憶體申請需求時,不能以申請的記憶體塊的真實尺寸進行比對,而應該比對減去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