那些位元組究竟從哪裡來
前面幾篇悄悄迴避了一個問題:當你呼叫 malloc() 時,究竟是誰手上有那塊記憶體?核心擁有所有的實體記憶體,而一般程式碼不能就這樣拿走一些——一塊堆積區是真實的位址空間,必須由作業系統授予。但核心只以整頁(通常 4 KiB)為單位、且只透過系統呼叫發放記憶體,這對每一個小小的 `malloc(16)` 來說太粗糙、也太慢了。所以 malloc() 不是一個系統呼叫。它是住在 libc 裡的一個普通函式庫函式,而它所管理的堆積,是它一開始就向核心一次性取得、再由自己零售分發的一大片位址空間。
它怎麼取得那片記憶體?傳統上的經典做法,是用 sbrk() 系統呼叫去推高程式中斷點(program break)——也就是堆積區的頂緣。呼叫 `sbrk(amount)` 把中斷點往上推 `amount` 個位元組,而那塊新暴露出來的位址空間就歸配置器去再細分。現代的配置器則更常改用 mmap() 一次抓大塊,但概念完全相同:難得才向核心要一大片區域,然後從裡面滿足成千上萬次 `malloc()` 呼叫、不必再去打擾核心。好處極大——一次系統呼叫的代價是陷入核心那幾百個週期,而從一塊你已持有的區域裡切出一段位元組範圍,只是寥寥幾道普通指令。
隱藏的帳目:你指標之前的祕密位元組
現在來談那個從第 1 篇起就藏在眾目睽睽之下的問題:`free(p)` 究竟怎麼可能運作?你只交給它一個指標、不附大小,它卻能恰好歸還正確數量的位元組。訣竅在於:配置器從不給你一塊光禿禿的記憶體。當你請求 `n` 個可用位元組時,它其實切出一塊略大的區塊、在它最前面寫上一小段帳目資料標頭(header)——通常是這塊的大小加上一兩個旗標——然後回傳一個指向那個標頭之後那個位元組的指標。你收到的位址是你資料的起點;而那筆帳目就隱形地坐在它前方幾個位元組處。
malloc(24) returns p ----+
v
+-----------+------------------------------+
| header | 24 usable bytes for you |
| size=24.. | (this is what p points at) |
+-----------+------------------------------+
^
+-- a few hidden bytes the allocator keeps
free(p) reads the header at p - (header size)
to learn the chunk is 24 bytes, then
marks it free. No size argument needed.於是 `free(p)` 做了一件悄悄聰明的事:它算出 `p` 減去標頭大小、讀取坐在那裡的大小欄位,於是便知道整塊該回收的範圍。這一個事實就解釋了一串你被要求遵守的規則。你必須把 malloc() 回傳的那個確切指標傳給 free()——傳 `p + 1`,配置器就會在它預期是標頭的地方讀到垃圾,後果未定義。一次重複釋放之所以危險,是因為第二次 free() 讀到的是配置器已經動過手腳的標頭。而一次往你區塊外多寫一個位元組的緩衝區溢位之所以如此惡毒,恰恰因為緊接在後的那些位元組往往就是下一塊的標頭——把它弄壞,配置器自己的資料結構就崩了,有時還是在那次壞寫入之後很久才發作。
空閒串列:一本記著什麼可用的帳簿
在它那片記憶體之內,配置器需要追蹤哪些區塊正在使用、哪些是空閒的,而它用一份空閒串列來做這件事——一條穿過那些空閒區塊本身的鏈結串列。優雅之處在此:一塊空閒區塊,按定義就是沒人在用的記憶體,所以配置器把它的「下一個」指標存在那塊空閒區塊自己的身體裡。空閒串列幾乎不額外花記憶體,因為它就住在那些洞裡。要滿足一次 `malloc()`,配置器走訪這份串列、找一塊夠大的;要處理一次 `free()`,它翻轉那塊的「使用中」旗標、再把它接回串列上。
當好幾塊都裝得下時,一個請求該拿哪一塊空閒區塊?這是一個帶著真實取捨的政策選擇,而非定論。首次適配(first-fit)抓串列上第一塊夠大的——快,但傾向把串列前段啃光。最佳適配(best-fit)掃描出大小最接近請求的那一塊,浪費的剩料最少,卻要搜尋更久、並留下一地細小到不堪用的碎屑。真實的配置器比單一串列精巧得多:它們依大小級別把記憶體分成許多桶(bin),好讓一個請求幾乎瞬間跳到大小差不多的區塊那裡、完全避開線性掃描。但這每一種,骨子裡仍然是一份附帶搜尋政策的空閒串列。
切分、合併,以及那個叫碎裂的代價
兩個操作讓空閒串列在程式翻攪之際保持健康。當一個請求需要 24 個位元組、但最好的空閒區塊有 200 個位元組時,配置器執行區塊切分:它切下你所要的 24 個、把剩下的 176 個當成一塊全新的、較小的空閒區塊回收,好讓剩料不致浪費。釋放時則發生相反的事。當你釋放一塊區塊時,配置器會檢查緊鄰它前後的區塊是否也空閒,若是,便把它們合併(coalesce)——把相鄰的空閒區塊重新併回一塊更大的。兩者合在一起,切分與合併就是堆積的呼吸:依需求細分、隨釋放重組,奮力保住大塊連續的空洞可用。
即便如此,堆積有時仍會打輸這場仗,結果就是碎裂。想像你並排配置了十塊 1 KiB 的記憶體、然後把隔一塊釋放掉。你現在握有 5 KiB 的空閒空間——但那是五個各自獨立的 1 KiB 空洞、被存活中的區塊楔在中間,於是單單一個 2 KiB 的請求就會失敗,儘管總共有大把記憶體是空的。這就是外部碎裂:空閒記憶體確實存在,卻散成一塊塊小到不堪用的碎片。合併在這裡幫不上忙,因為沒有任何兩個空洞是相鄰的。這是親手配置記憶體誠實而艱難的真相——碎裂是這套模型固有的代價,而非一個你能單靠寫程式就消滅的臭蟲。
還有第二種值得一提。內部碎裂是浪費在你確實持有的一塊區塊內部的空間——因為配置器把你的請求往上取整。請求 17 個位元組,你極可能被交給一塊 24 或 32 位元組的區塊,因為配置器為了對齊、以及為了套進它的大小桶而往上取整。那些多出來的位元組記在你帳上、卻不堪使用。這兩種碎裂都不是你做錯了什麼的徵兆;它們都是堆積靈活性的常設稅金。一座讓你能隨時、按位元組、為任意生命週期請求記憶體的受管理資源池,根本不可能同時保證零浪費。
把整幅圖拼起來
既然每個部分如今都有了名字,讓我們把單單一行 `int *p = malloc(24)` 追蹤著穿過這台機器。配置器在它的空閒串列(其實是它依大小分桶的那些串列)裡搜尋一塊至少有 24 個位元組外加標頭空間的區塊。如果它找到的那塊大得多,它就切下剩料。它把大小與一個「使用中」旗標寫進那塊的標頭,然後回傳那個標頭之後那個位元組的位址——那就是你的 `p`。稍後,`free(p)` 往回退去讀那個標頭、把那塊標記為空閒、接到串列上、並與任何空閒的鄰居合併。常見情況下沒有任何核心呼叫發生;這一切都是 libc 老早取得的那片記憶體裡的帳目作業。
- 取得一片記憶體:配置器一開始就用 sbrk() 或 mmap() 把堆積長大一次,好讓它有原始的位址空間可管理。
- 服務 malloc():走訪空閒串列找一塊夠大的、切下剩料、把大小寫進一個隱藏標頭,然後回傳它之後那個位元組。
- 服務 free(p):讀取 p 減去標頭大小處的標頭、把那塊標記為空閒、接回串列、並與相鄰的空閒區塊合併。
- 繳那筆常設稅:把請求往上取整(內部)以及存活區塊四散(外部)會留下碎裂,再小心的寫程式也無法完全消除它。
退一步,看看這個階段給了你什麼。你在前幾篇學到的契約,從來不是武斷的儀式:你釋放 malloc() 回傳的確切指標,是因為 free() 會去讀它前方固定偏移處的一個標頭;你絕不往區塊外寫,是因為下一塊的標頭就坐在那裡;你只釋放一次,是因為第二次 free() 會弄壞一份它已經動過的串列;而你在一支長命程式裡漏記憶體不可能不受懲罰,是因為堆積只能往上長。配置器不是魔法——它是一台由記憶體片、標頭與串列構成、可知可解的小機器。而如果親手管理這一切聽起來累人,那份誠實的疲憊正是後續內容的動機所在:一個垃圾收集器以執行期的代價替你回收記憶體,而 Rust 的所有權系統則在編譯期就強制執行這些同樣的規則、不需要任何收集器——它們是同一道你如今已從內部理解的艱難取捨上的不同落點。