操作系統思考

第六章 內存管理

協議:CC BY-NC-SA 4.0
C提供了4種用於動態內存分配的函數:

malloc,它接受表示字節單位的大小的整數,返回指向新分配的、(至少)爲指定大小的內存塊的指針。如果不能滿足要求,它會返回特殊的值爲NULL的指針。
calloc,它和malloc一樣,除了它會清空新分配的空間。也就是說,它會設置塊中所有字節爲0。
free,它接受指向之前分配的內存塊的指針,並會釋放它。也就是說,使這塊空間可用於未來的分配。
realloc,它接受指向之前分配的內存塊的指針,和一個新的大小。它使用新的大小來分配內存塊,將舊內存塊中的數據複製到新內存塊中,釋放舊內存塊,並返回指向新內存塊的指針。
這套API是出了名的易錯和苛刻。內存管理是設計大型系統中,最具有挑戰性的一部分,它正是許多現代語言提供高階內存管理特性,例如垃圾回收的原因。

6.1 內存錯誤
C的內存管理API有點像Jasper Beardly,動畫片《辛普森一家》中的一個配角,他是一個嚴厲的代課老師,喜歡體罰別人,並使用戒尺懲罰任何違規行爲。

下面是一些應受到懲罰的程序行爲:

如果你訪問任何沒有分配的內存塊,就應受到懲罰。
如果你釋放了某個內存塊之後再訪問它,就應受到懲罰。
如果你嘗試釋放一個沒有分配的內存塊,就應受到懲罰。
如果你釋放多次相同的內存塊,就應受到懲罰。
如果你使用沒有分配或者已經釋放的內存塊調用realloc,就應受到懲罰。
這些規則聽起來好像不難遵循,但是在一個大型程序中,一塊內存可能由程序一部分分配,在另一個部分中使用,之後在其他部分中釋放。所以一部分中的變化也需要其它部分跟着變化。

同時,同一個內存塊在程序的不同部分中,也可能有許多別名或者引用。這些內存塊在所有引用不再使用時,才應該被釋放。正確處理這件事情通常需要細心的分析程序的所有部分,這非常困難,並且與良好的軟件工程的基本原則相違背。

理論上,每個分配內存的函數都應包含內存如何釋放的信息,作爲接口文檔的一部分。成熟的庫通常做得很好,但是實際上,軟件工程的實踐通常不是這樣理想化的。

內存錯誤非常難以發現,因爲這些症狀是不可預測的,這使得事情更加糟糕,例如:

如果從未分配的內存塊中讀取值,系統可能會檢測到錯誤,觸發叫做“段錯誤”的運行時錯誤,並且中止程序。這個結果非常合理,因爲它表示程序所讀取的位置會導致錯誤。但是,遺憾的是,這種結果非常少見。更通常的是,程序讀取了未分配的內存塊,而沒有檢測到錯誤,程序所讀取的未分配內存正好儲存在一塊特定區域中。如果這個值沒有解釋爲正確的類型,結果可能會難以解釋。例如,如果你讀取字符串中的字節,將它們解釋爲浮點數,你可能會得到一個無效的數值,非常大或非常小的數值。如果你向函數傳遞它無法處理的值,結果會非常怪異。
如果你向未分配的內存塊中寫入值,會更加糟糕。因爲在值被寫入之後,需要很長時間值才能被讀取並且發生錯誤。此時尋找問題來源就會非常困難。事情還可能更加糟糕!C風格內存管理的一個最普遍的問題是,用於實現malloc和free的數據結構(我們將會看到)通常和分配的內存塊儲存在一起。所以如果你無意中越過動態分配塊的末尾寫入值,你就可能破壞了這些數據結構。系統通常直到最後纔會檢測到這種問題,當你調用malloc或free時,這些函數會由於一些謎之原因調用失敗。
你應該從中總結出一條規律,就是安全的內存管理需要設計和規範。如果你編寫了一個分配內存的庫或模塊,你應該同時提供釋放它的接口,並且內存管理從開始就應該作爲API設計的一部分。

如果你使用了分配內存的庫,你應該按照規範使用API。例如,如果庫提供了分配和釋放儲存空間的函數,你應該一起使用或都不使用它們。例如,不要在不是malloc分配的內存塊上調用free。你應該避免在程序的不同部分中持有相同內存塊的多個引用。

通常在安全的內存管理和性能之間有個權衡。例如,內存錯誤的的最普遍來源是數組的越界寫入。這一問題的最顯然的解決方法就是邊界檢查。也就是說,每次對數組的訪問都應該檢查下標是否越界。提供數組結構的高階庫通常會進行邊界檢查。但是C風格數據和大多數底層庫不會這樣做。

6.2 內存泄漏
有一種可能會也可能不會受到懲罰的內存錯誤。如果你分配了一塊內存,並且沒有釋放它,就會產生“內存泄漏”。

對於一些程序,內存泄露是OK的。如果你的程序分配內存,對其執行計算,之後退出,這可能就不需要釋放內存。當程序退出時,所有分配的內存都會由操作系統釋放。在退出前立即釋放內存似乎很負責任,但是通常很浪費時間。

但是如果一個程序運行了很長時間,並且泄露內存的話,它的內存總量會無限增長。此時會發生一些事情:

某個時候,系統會耗完所有物理內存。在沒有虛擬內存的系統上,下一次的malloc調用會失敗,返回NULL。
在帶有虛擬內存的系統上,操作系統可以將其它進程的頁面從內存移動到磁盤上,之後分配更多空間給泄露的進程。我會在7.8節解釋這一機制。
單個進程可能有內存總量的限制,超過它的話,malloc會返回NULL。
最後,進程可能會用完它的虛擬地址空間(或者可用的部分)。之後,沒有更多的地址可分配,malloc會返回NULL。
如果malloc返回了NULL,但是你仍舊把它當成分配的內存塊進行訪問,你會得到段錯誤。因此,在使用之前檢查malloc的結果是個很好的習慣。一種選擇是在每個malloc調用之後添加一個條件判斷,就像這樣:

void *p = malloc(size);
if (p == NULL) {
perror(“malloc failed”);
exit(-1);
}
perror在stdio.h中聲明,它會打印出關於最後發生的錯誤的錯誤信息和額外的信息。

exit在stdlib.h中聲明,會使進程終止。它的參數是一個表示進程如何終止的狀態碼。按照慣例,狀態碼0表示通常終止,-1表示錯誤情況。有時其它狀態碼用於表示不同的錯誤情況。

錯誤檢查的代碼十分討厭,並且使程序難以閱讀。但是你可以通過將庫函數的調用和錯誤檢查包裝在你自己的函數中,來解決這個問題。例如,下面是檢查返回值的malloc包裝:

void *check_malloc(int size)
{
void *p = malloc (size);
if (p == NULL) {
perror(“malloc failed”);
exit(-1);
}
return p;
}
由於內存管理非常困難,多數大型程序,例如Web瀏覽器都會泄露內存。你可以使用Unix的ps和top工具來查看系統上的哪個程序佔用了最多的內存。

6.3 實現
當進程啓動時,系統爲text段、靜態分配的數據、棧和堆分配空間,堆中含有動態分配的數據。

並不是所有程序都動態分配數據,所以堆的大小可能很小,或者爲0。最開始堆只含有一個空閒塊。

malloc調用時,它會檢查這個空閒塊是否足夠大。如果不是,它會向系統請求更多內存。做這件事的函數叫做sbrk,它設置“程序中斷點”(program break),你可以將其看做一個指向堆底部的指針。

譯者注:sbrk是Linux上的系統API,Windows上使用HeapAlloc和HeapFree來管理堆區。
sbrk調用時,它分配的新的物理內存頁,更新進程的頁表,並設置程序中斷點。

理論上,程序應該直接調用sbrk(而不是通過malloc),並且自己管理堆區。但是malloc易於使用,並且對於大多數內存使用模式,它運行速度快並且高效利用內存。

爲了實現內存管理API,多數Linux系統都使用ptmalloc,它基於dlmalloc,由Doug Lea編寫。一篇描述這個實現要素的論文可在http://gee.cs.oswego.edu/dl/html/malloc.html訪問。

對於程序員來說,需要注意的最重要的要素是:

malloc在運行時通常不依賴塊的大小,但是可能取決於空閒塊的數量。free通常很快,和空閒塊的數量無關。因爲calloc會清空塊中的每個字節,執行時間取決於塊的大小(以及空閒塊的數量)。realloc有時很快,如果新的大小比之前更小,或者空間可用於擴展現有的內存塊。否則,它需要從舊內存塊中複製數據到新內存塊,這種情況下,執行時間取決於舊內存塊的大小。
邊界標籤:當malloc分配一個快時,它在頭部和尾部添加空間來儲存塊的信息,包括它的大小和狀態(分配還是釋放)。這些數據位叫做“邊界標籤”。使用這些標籤,malloc就可以從任何塊移動到內存中上一個或下一個塊。此外,空閒塊會鏈接到一個雙向鏈表中,所以每個空閒塊也包含指向“空閒鏈表”中下一個塊和上一個塊的指針。邊界標籤和空閒鏈表指針構成了malloc的內部數據結構。這些數據結構穿插在程序的數據中,所以程序錯誤很容易破壞它們。
空間開銷:邊界標籤和空閒鏈表指針也佔據空間。最小的內存塊大小在大多數系統上是16字節。所以對於非常小的內存塊,malloc在空間上並不高效。如果你的程序需要大量的小型數據結構,將它們分配在數組中可能更高效一些。
碎片:如果你以多種大小分配和釋放塊,堆區就會變得碎片化。也就是說,空閒空間會打碎成許多小型片段。碎片非常浪費空間,它也會通過使緩存效率低下來降低程序的速度。
裝箱和緩存:空閒鏈表在箱子中以大小排序,所以當malloc搜索特定大小的內存塊時,它知道應該在哪個箱子中尋找。所以如果你釋放了一塊內存,之後立即以相同大小分配一塊內存,malloc通常會很快。

作者:Allen B. Downey

原文:Chapter 6 Memory management

譯者:飛龍

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章