作業系統思考 第六章 記憶體管理

飛龍發表於2019-05-09

第六章 記憶體管理

作者:Allen B. Downey

原文:Chapter 6 Memory management

譯者:飛龍

協議:CC BY-NC-SA 4.0

C提供了4種用於動態記憶體分配的函式:

  • malloc,它接受表示位元組單位的大小的整數,返回指向新分配的、(至少)為指定大小的記憶體塊的指標。如果不能滿足要求,它會返回特殊的值為NULL的指標。

  • calloc,它和malloc一樣,除了它會清空新分配的空間。也就是說,它會設定塊中所有位元組為0。

  • free,它接受指向之前分配的記憶體塊的指標,並會釋放它。也就是說,使這塊空間可用於未來的分配。

  • realloc,它接受指向之前分配的記憶體塊的指標,和一個新的大小。它使用新的大小來分配記憶體塊,將舊記憶體塊中的資料複製到新記憶體塊中,釋放舊記憶體塊,並返回指向新記憶體塊的指標。

這套API是出了名的易錯和苛刻。記憶體管理是設計大型系統中,最具有挑戰性的一部分,它正是許多現代語言提供高階記憶體管理特性,例如垃圾回收的原因。

6.1 記憶體錯誤

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

下面是一些應受到懲罰的程式行為:

  • 如果你訪問任何沒有分配的記憶體塊,就應受到懲罰。

  • 如果你釋放了某個記憶體塊之後再訪問它,就應受到懲罰。

  • 如果你嘗試釋放一個沒有分配的記憶體塊,就應受到懲罰。

  • 如果你釋放多次相同的記憶體塊,就應受到懲罰。

  • 如果你使用沒有分配或者已經釋放的記憶體塊呼叫realloc,就應受到懲罰。

這些規則聽起來好像不難遵循,但是在一個大型程式中,一塊記憶體可能由程式一部分分配,在另一個部分中使用,之後在其他部分中釋放。所以一部分中的變化也需要其它部分跟著變化。

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

理論上,每個分配記憶體的函式都應包含記憶體如何釋放的資訊,作為介面文件的一部分。成熟的庫通常做得很好,但是實際上,軟體工程的實踐通常不是這樣理想化的。

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

  • 如果從未分配的記憶體塊中讀取值,系統可能會檢測到錯誤,觸發叫做“段錯誤”的執行時錯誤,並且中止程式。這個結果非常合理,因為它表示程式所讀取的位置會導致錯誤。但是,遺憾的是,這種結果非常少見。更通常的是,程式讀取了未分配的記憶體塊,而沒有檢測到錯誤,程式所讀取的未分配記憶體正好儲存在一塊特定區域中。如果這個值沒有解釋為正確的型別,結果可能會難以解釋。例如,如果你讀取字串中的位元組,將它們解釋為浮點數,你可能會得到一個無效的數值,非常大或非常小的數值。如果你向函式傳遞它無法處理的值,結果會非常怪異。

  • 如果你向未分配的記憶體塊中寫入值,會更加糟糕。因為在值被寫入之後,需要很長時間值才能被讀取並且發生錯誤。此時尋找問題來源就會非常困難。事情還可能更加糟糕!C風格記憶體管理的一個最普遍的問題是,用於實現mallocfree的資料結構(我們將會看到)通常和分配的記憶體塊儲存在一起。所以如果你無意中越過動態分配塊的末尾寫入值,你就可能破壞了這些資料結構。系統通常直到最後才會檢測到這種問題,當你呼叫mallocfree時,這些函式會由於一些謎之原因呼叫失敗。

你應該從中總結出一條規律,就是安全的記憶體管理需要設計和規範。如果你編寫了一個分配記憶體的庫或模組,你應該同時提供釋放它的介面,並且記憶體管理從開始就應該作為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);
}

perrorstdio.h中宣告,它會列印出關於最後發生的錯誤的錯誤資訊和額外的資訊。

exitstdlib.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的pstop工具來檢視系統上的哪個程式佔用了最多的記憶體。

6.3 實現

當程式啟動時,系統為text段、靜態分配的資料、棧和堆分配空間,堆中含有動態分配的資料。

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

malloc呼叫時,它會檢查這個空閒塊是否足夠大。如果不是,它會向系統請求更多記憶體。做這件事的函式叫做sbrk,它設定“程式中斷點”(program break),你可以將其看做一個指向堆底部的指標。

譯者注:sbrk是Linux上的系統API,Windows上使用HeapAllocHeapFree來管理堆區。

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通常會很快。

相關文章