Memory Management in Rust

Mu001999發表於2022-02-17

程式在執行時需要請求作業系統分配記憶體以及釋放記憶體,因此,程式設計師在編寫程式時,需要顯式(手動)地編寫分配和釋放記憶體的程式碼,或者隱式(自動,由語言保證)地進行記憶體管理。對於前者,C/C++ 是代表語言,程式設計師需要手動管理記憶體;對於後者,垃圾回收器(Garbage collector, GC)是一種常見的選擇,諸如 Go/Java 等都提供了 GC。

事實上,C++ 標準庫中提供了智慧指標等工具,能夠解決一部分的記憶體管理問題。但是由於 C++ 的高自由度,即使在使用智慧指標時,仍十分容易編寫出會導致記憶體錯誤的程式碼。

樸素的手動記憶體管理對程式設計師的要求更高,也意味著更容易出錯,導致一些記憶體錯誤的產生,比如:

  • 解引用儲存已釋放空間的地址的指標(Use after free)
  • 沒有釋放空間導致的記憶體洩漏(Memory leak)
  • 重複釋放已經被重用的空間(Double free)

一個自動的記憶體管理機制,可以消除這些常見問題。GC 通過在執行時記錄分配的空間和空間的使用資訊來實現記憶體的自動管理,程式設計師就可以從記憶體管理中解放。但是,儘管這件事聽起來還不錯,但它意味著我們編寫的程式在執行時,還附帶著執行一個 GC,這當然會帶來一些執行時的損耗。

如果你不想在執行時帶著一個 GC,又不想像 C/C++ 一樣手動檢查違反了記憶體安全的程式碼的存在,那麼可以看看 Rust 的解決方案。Rust 提供了一種不借助 GC,又能夠保證記憶體安全的高效記憶體管理方式。

所有權 —— 大廈的基石

所有權Ownership)是 Rust 最重要的特性之一,也是 Rust 能夠高效地保證記憶體安全的同時避免引入 GC 的核心機制。

所有權是一組規則,描述了 Rust 程式如何管理記憶體。在 Rust 中,記憶體通過所有權系統來管理,該系統有一套由編譯器進行檢查的規則(即所有權規則),如果違反了任何一條規則,程式就不能被成功編譯。因此,所有權是一組靜態的、在編譯時檢查的規則。這意味著所有權系統不會帶來執行時的損耗。

所有權規則有且只有如下三條規則:

  • Rust 中的每個值都有一個被稱為其所有者Owner)的變數。
  • 一個值在任一時刻有且只有一個所有者。
  • 當所有者離開作用域時,這個值將被丟棄。

或許你認為這和大部分(OOP)語言中棧上物件在函式結束時進行析構,同時釋放資源(RAII)的程式設計習慣似乎區別不大,事實上也確實如此,區別在於 Rust 將這種規則擴大到了所有值上,包括儲存在堆上的值,並且由編譯器保證。換言之,Rust enforces RAII。因此,在 Rust 中任何一個值(無論在棧上還是堆上),在不使用時(其所有者離開作用域)都將被丟棄(佔用的空間被釋放)。你可能已經發現了,沒有記憶體洩漏的世界完成了。

然而,目前的世界十分簡陋,如果只有這三條規則,一個簡單的實現是在賦值時複製資料並建立一個新值,儘管這樣的實現不違背所有權規則,但卻是低效的。因此,本文的剩餘部分介紹 Rust 中和所有權相關的其它組成部分,這些部分在不違背所有權規則的基礎上,和所有權系統共同組成了 Rust 高效的記憶體安全世界。

移動

通常來說,複製堆上的資料被認為是緩慢的,因為堆上的資料通常較大,並且在複製時需要申請新的空間;而複製棧上的資料被認為是快速的,因為棧上的資料通常較小,並且型別的大小在編譯時已知。

以 Rust 中的 String 型別為例,與其它語言中的字串型別相似,String 型別由三部分組成:一個指向存放字串內容(佔用堆上空間)的指標,一個長度和一個容量。而這些資料儲存在棧上。當一個 String 型別的值被丟棄時,就需要根據棧上儲存的資料釋放佔用的堆上空間。

因此,為了避免複製堆上的資料,一種解決方案是允許在賦值時僅複製 String 在棧上的資料,但是這意味著堆上的空間此時分別被兩個變數所有,在失效時會重複釋放同一處堆上空間導致記憶體錯誤,同時也違背了所有權規則。對此,Rust 的解決方案是在複製了棧上資料之後,將該值的所有權轉移給新變數,在當前作用域結束後,之前的所有者便不會再嘗試釋放堆上的空間。值的所有權的轉移在 Rust 中被稱為值的移動Move)。同時,移動是 Rust 中賦值的預設行為。

Rust 中,變數的宣告通過 let 語句完成,作用域與其它程式語言類似:

{ // s 在這裡無效,它尚未宣告
    let s = String::from("hello"); // 從這裡開始 s 是有效的
    // 使用 s
} // 在此之後,s 離開當前作用域,不再有效

在賦值後,變數 s 繫結到了 String 型別的一個值,此時 s 是該值的所有者。而當 s 離開當前作用域後便不再有效,此時其繫結的值會被丟棄。那麼當我們允許值的移動時,將另一個擁有 String 型別的值的變數賦值給新變數會發生什麼?

let s1 = String::from("hello");
let s2 = s1;
// 此時使用 s1 會發生什麼?

答案是 s1 的值會被移動給 s2,Rust 會認為 s1 不再有效,如果你此時使用 s1,編譯會失敗並得到一個錯誤:s1 的值已經被移動。而在所有權發生轉移時,如前文所說,s2 其實複製了 s1 棧上的資料,並在此時令 s1 失效,避免兩者在離開作用域時重複釋放同一處堆上空間引發記憶體錯誤。

克隆和拷貝

有時我們確實需要複製 String 中的所有資料(包括堆上的資料),在 Rust 中被稱為克隆Clone),可以通過顯式呼叫通用方法 clone 實現這樣的功能。克隆會產生一個資料相同的新值,此時 s1s2 分別是兩個 String 的所有者:

let s1 = String::from("hello");
let s2 = s1.clone();
// s1 和 s2 都有效

前面提到了,移動時複製的是棧上的資料,而使之前的所有者失效,是為了避免重複釋放空間。但是,除了像 String 一樣在佔用了堆上空間的型別,還有像整型一樣所有資料都儲存在棧上的型別。這些型別不需要釋放堆上空間,意味著不需要令之前的所有者失效。因此,Rust 提供了一個叫做 Copy trait 的型別註解用於這些型別,對於滿足 Copy trait 的型別,賦值時會產生一個新值,行為與克隆相似,但不需要顯式指定,在 Rust 中被稱為拷貝Copy):

let x = 42;
let y = x;
// x 和 y 都有效

因此,在 Rust 中,移動是賦值時值的一般行為,而拷貝是當資料僅儲存在棧上時值的特殊行為,值的克隆則不會自動發生,需要程式設計師的顯式呼叫。這隱含了 Rust 在設計時的一個選擇:不自動進行資料的“深拷貝”。可以認為任何自動的複製對執行時效能的影響較小。

擴充套件到函式

事實上,幾乎所有的程式語言中都有函式(或者類似的概念),而函式的呼叫涉及到值的傳遞和返回,因此我們還需要考慮在涉及到函式時,值和所有權的行為。由於向函式傳遞值,類似於將值賦值給函式的引數,而函式返回值類似於將返回值賦值給呼叫者宣告的一個變數(或者不可見的臨時變數)。因此,一個將所有權擴充套件到函式的簡單實現是,在向函式傳遞值以及函式返回值時,採用和賦值一樣的行為。在 Rust 中,向函式傳遞值時值的行為和賦值時相同,可能會移動或者拷貝,同樣的,函式的返回值也可以移動:

fn main() {
    let s1 = String::from("hello");
    let s2 = foo(s1); // s1 經由 foo 被移動給 s2
    // s1 不再有效
}

fn foo(s: String) -> String { // s 進入作用域
    s // 返回 s 並移動給呼叫者
}

至此,通過所有權系統檢查的程式碼中,值都能被正確地丟棄,空間都能被正確地回收,並且被移動過的值可以保證不會被再次使用。同時,這一切又都是高效的。

引用和借用

世界在擁有了所有權和移動語義後變得更好了,但是或許還不夠好。比如,當我們實現的一個函式只希望使用一個引數的值,又不想獲取所有權,並且呼叫者也希望在呼叫完成後繼續使用它。在目前的世界裡,函式需要在獲取引數的所有權之後,在返回的時候再將該引數移動給呼叫者:

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = length(s1);
    println!("The length of '{}' is {}.", s2, len);
}

fn length(s: String) -> (String, usize) {
    let len = s.len(); // len() 返回字串的長度
    (s, len) // 可以使用元組返回多個值
}

儘管這樣確實可行,而且對執行時的影響似乎也可以接受(只需要複製一些棧上的資料就行了),但是問題在於,沒有人想要這樣囉嗦地寫程式碼。Rust 對此的解決方案是,提供了一個不獲取值的所有權,但可以暫時獲取值的使用權的功能,叫做引用Reference)。

在 Rust 中,引用儲存值的地址,我們可以根據該地址訪問屬於其它變數的值。在 Rust 中,指向型別為 String 的值的引用的型別為 &String。對於型別為 String 的值 s,我們可以通過 &s 建立一個指向 s 的引用。這些 & 符號表示引用,而 Rust 將建立一個引用的行為稱為借用Borrowing)。同時,引用無需返回值來歸還所有權,因為它不具有值的所有權,這與本節開始提出的需求是一致的。因此,我們可以改寫本節開始的程式碼:

fn main() {
    let s1 = String::from("hello");
    let len = length(&s1);
    println!("The length of '{}' is {}.", s1, len);
}

fn length(s: &String) -> usize {
    s.len() // len() 返回字串的長度
}

生命週期

事實上,許多程式語言中都有和引用類似的概念,但是在沒有 GC 的語言中,引用(或者類似的功能)的使用可能會導致一些錯誤,比如[懸垂引用](Dangling Pointer),即引用沒有指向有效物件。而在 Rust 中,引用確保指向某個特定型別的有效值。引用有效性的保證依賴於生命週期Lifetime)機制,它是與所有權機制同等重要的記憶體管理機制。

生命週期是引用必須有效的程式碼區域(與作用域的概念接近)。與所有權機制相似,生命週期機制也要求編譯器保證程式碼滿足一些關於生命週期的規則,事實上,這由編譯器中的借用檢查器Borrow checker)來保證。同樣的,這些的規則的檢查也發生在編譯時,因此不會影響執行時效率。

考慮下面的程式,它有一個外部作用域和一個內部作用域:

{
    let r;                // ---------+-- 'a
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
    println!("r: {}", r); //          |
}                         // ---------+

在註釋中,我們將 r 的生命週期標記為 'a,將 x 的生命週期標記為 'b。顯然,'b 塊要比 'a 塊小。在編譯時,Rust 比較這兩個生命週期的大小,並發現生命週期為 'ar 引用了一個生命週期為 'b 的物件 x。由於生命週期 'b 比生命週期 'a 小,這意味著被引用的物件比它的引用者存在的時間更短,因此程式被拒絕編譯。如果成功編譯,這將導致懸垂引用。這是借用檢查器需要檢查的規則之一:一個引用的生命週期不超過其引用的物件的生命週期。

大部分時候,生命週期是隱含並可以推斷的(比如在函式體內,區域性引用的生命週期通常與作用域保持一致)。但在一些情況下,比如當引用跨越了函式邊界(作為引數)時,這種時候,引用的生命週期和呼叫者有關,編譯器此時缺乏足夠的資訊進行判斷。為此,Rust 提供了一種可以描述多個引用生命週期相互的關係,而不影響其生命週期的功能,叫做生命週期註解Lifetime annotation)。程式設計師可以通過生命週期註解,提供給編譯器足夠的資訊,以便借用檢查器可以進行分析。

總結

或許你作為一名有經驗的程式設計師,在程式設計中可能已經遵守了上述這些準則,但是 Rust 的貢獻在於,它將這些準則和語言的設計良好地結合在一起,通過提高了編譯器的能力,減輕了程式設計師程式設計時的負擔,同時儘可能地避免效能上的損耗。事實上,除了記憶體安全方面,Rust 也通過許多設計嘗試避免包括執行緒安全和型別安全在內的安全問題。而本文介紹的內容,也只是 Rust 中的冰山一角。

相關文章