消除垃圾收集器:RAII 方式

已發表: 2022-03-11

最開始有 C。在 C 中,內存分配分為三種類型:靜態、自動和動態。 靜態變量是嵌入在源文件中的常量,由於它們的大小是已知的並且永遠不會改變,所以它們並不是那麼有趣。 自動分配可以被認為是堆棧分配 - 當進入一個詞法塊時分配空間,並在退出該塊時釋放。 它最重要的特點與此直接相關。 在 C99 之前,自動分配的變量需要在編譯時知道它們的大小。 這意味著任何字符串、列表、映射以及從它們派生的任何結構都必須存在於堆上,在動態內存中。

消除垃圾收集器:RAII 方式

動態內存由程序員使用四個基本操作顯式分配和釋放:malloc、realloc、calloc 和 free。 其中前兩個不執行任何初始化,內存可能包含垃圾。 除了免費之外,所有這些都可能失敗。 在這種情況下,它們返回一個空指針,其訪問是未定義的行為; 在最好的情況下,你的程序會爆炸。 在最壞的情況下,您的程序似乎可以工作一段時間,在爆炸之前處理垃圾數據。

以這種方式做事有點痛苦,因為你,程序員,有責任維護一堆不變量,這些不變量會導致你的程序在違反時爆炸。 在訪問變量之前必須有一個 malloc 調用。 在使用變量之前,您必須檢查 malloc 是否成功返回。 執行路徑中的每個 malloc 必須恰好存在一個免費調用。 如果為零,則內存洩漏。 如果超過一個,您的程序就會爆炸。 釋放變量後,可能不會嘗試訪問該變量。 讓我們看一個實際情況的示例:

 int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); }
 $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576

該代碼雖然很簡單,但已經包含一個反模式和一個有問題的決定。 在現實生活中,您永遠不應該將字節數寫為文字,而應使用 sizeof 函數。 類似地,我們將 char * 數組分配給我們需要兩倍的字符串大小(比字符串的長度多一倍,以考慮空終止),這是一個相當昂貴的操作。 更複雜的程序可能會構建更大的字符串緩衝區,從而允許字符串大小增長。

RAII的發明:新的希望

至少可以說,所有這些手動管理都令人不快。 在 80 年代中期,Bjarne Stroustrup 為他的全新語言 C++ 發明了一種新範式。 他將其稱為資源獲取即初始化,其基本見解如下:可以指定對象具有編譯器在適當時間自動調用的構造函數和析構函數,這提供了一種更方便的方式來管理給定對象的內存需要,並且該技術對於不是內存的資源也很有用。

這意味著上面的 C++ 示例更簡潔:

 int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); }
 $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0

看不到手動內存管理! 構造字符串對象,調用重載方法,並在函數退出時自動銷毀。 不幸的是,同樣的簡單性可能會導致其他並發症。 讓我們更詳細地看一個例子:

 vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

這一切似乎都相當簡單。 矢量線被填充、返回和調用。 然而,作為關心性能的高效程序員,這讓我們感到困擾:在 return 語句中,由於值語義,向量在其銷毀前不久被複製到一個新向量中。

這在現代 C++ 中不再嚴格正確。 C++11 引入了移動語義的概念,其中原點處於有效狀態(以便仍然可以正確銷毀)但未指定狀態。 返回調用是編譯器優化移動語義的一個非常容易的情況,因為它知道在任何進一步訪問之前不久就會銷毀源。 但是,該示例的目的是說明為什麼人們在 80 年代末和 90 年代初發明了一大堆垃圾收集語言,而在那個時候 C++ 移動語義還不可用。

對於大數據,這可能會變得昂貴。 讓我們優化一下,只返回一個指針。 有一些語法更改,但除此之外它是相同的代碼:

實際上,vector 是一個值句柄:一個相對較小的結構,其中包含指向堆上項目的指針。 嚴格來說,簡單地返迴向量是沒有問題的。 如果返回的是一個大數組,該示例會更好。 由於試圖將文件讀入預先分配的數組是無意義的,我們使用向量來代替。 請假裝它是一個不切實際的大數據結構。

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)

哎喲! 現在行是一個指針,我們可以看到自動變量正在像宣傳的那樣工作:向量在其範圍離開時被銷毀,指針指向堆棧中的前向位置。 分段錯誤只是試圖訪問非法內存,所以我們真的應該預料到這一點。 儘管如此,我們還是想以某種方式從我們的函數中取回文件的行,很自然的事情就是簡單地將我們的變量移出堆棧並進入堆。 這是通過 new 關鍵字完成的。 我們可以簡單地編輯文件的一行,我們在其中定義行:

 vector<string> * lines = new vector<string>;
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

不幸的是,儘管這看起來很完美,但它仍然有一個缺陷:它會洩漏內存。 在 C++ 中,指向堆的指針在不再需要後必須手動刪除; 如果不是,則一旦最後一個指針超出範圍,該內存將變得不可用,並且在進程結束時操作系統對其進行管理之前不會恢復。 慣用的現代 C++ 將在此處使用 unique_ptr,它實現了所需的行為。 當指針超出範圍時,它會刪除指向的對象。 然而,這種行為直到 C++11 才成為該語言的一部分。

在這個例子中,這可以很容易地解決:

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }

不幸的是,隨著程序擴展超出玩具規模,很快就很難推斷出應該在何時何地刪除指針。 當一個函數返回一個指針時,你現在擁有它嗎? 你應該在完成後自己刪除它,還是它屬於某個稍後將立即釋放的數據結構? 以一種方式出錯並導致內存洩漏,在另一種方式中出錯並且您已經破壞了有問題的數據結構以及可能的其他數據結構,因為它們試圖取消引用現在不再有效的指針。

相關:調試 Node.js 應用程序中的內存洩漏

“進入垃圾收集器,飛行小子!”

垃圾收集器不是一項新技術。 它們是 John McCarthy 於 1959 年為 Lisp 發明的。 隨著 1980 年的 Smalltalk-80,垃圾收集開始成為主流。 然而,1990 年代代表了該技術的真正開花:在 1990 到 2000 年間,發布了大量語言,所有這些語言都使用了一種或另一種垃圾收集:Haskell、Python、Lua、Java、JavaScript、Ruby、OCaml , 和 C# 是最著名的。

什麼是垃圾收集? 簡而言之,它是一組用於自動化手動內存管理的技術。 它通常作為具有手動內存管理的語言(例如 C 和 C++)的庫提供,但它更常用於需要它的語言中。 最大的好處是程序員根本不需要考慮內存; 這一切都被抽像出來了。 例如,與我們上面的文件讀取代碼等效的 Python 代碼如下:

 def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count))
 $ python3 python3.py makefile File makefile contains 38 lines.

行數組在第一次分配時產生,並且在不復製到調用範圍的情況下返回。 它在超出該範圍後的某個時間被垃圾收集器清理,因為時間不確定。 有趣的一點是,在 Python 中,非內存資源的 RAII 不是慣用的。 這是允許的——我們可以簡單地編寫fp = open(file_name)而不是使用with塊,然後讓 GC 清理。 但推薦的模式是盡可能使用上下文管理器,以便可以在確定的時間釋放它們。

儘管抽像出內存管理是一件好事,但它是有代價的。 在引用計數垃圾回收中,所有變量分配和範圍退出都會獲得少量更新引用的成本。 在標記和清除系統中,在不可預知的時間間隔內,所有程序執行都會停止,而 GC 會清理內存。 這通常被稱為停止世界事件。 使用這兩種系統的 Python 等實現會受到兩​​種懲罰。 這些問題降低了垃圾收集語言在性能至關重要或需要實時應用程序的情況下的適用性。 即使在這些玩具程序上,也可以看到性能損失:

 $ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015s

Python 版本的實時時間幾乎是 C++ 版本的三倍。 雖然並非所有這些差異都可以歸因於垃圾收集,但它仍然相當可觀。

所有權:RAII Awakens

那麼就這樣結束了嗎? 所有編程語言都必須在性能和編程易用性之間做出選擇嗎? 不! 編程語言研究仍在繼續,我們開始看到下一代語言範式的第一個實現。 特別令人感興趣的是名為 Rust 的語言,它承諾類似於 Python 的人體工程學和類似 C 的速度,同時使懸空指針、空指針等不可能——它們無法編譯。 它怎麼能提出這些要求?

允許這些令人印象深刻的聲明的核心技術稱為藉用檢查器,這是一種在編譯時運行的靜態檢查器,拒絕可能導致這些問題的代碼。 但是,在深入探討其含義之前,我們需要先討論一下先決條件。

所有權

回想一下我們在討論 C++ 中的指針時,我們談到了所有權的概念,粗略地說,它的意思是“誰負責刪除這個變量”。 Rust 形式化並強化了這個概念。 每個變量綁定都擁有它綁定的資源的所有權,借用檢查器確保只有一個綁定擁有該資源的全部所有權。 也就是說,Rust Book 中的以下代碼段無法編譯:

 let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]);
 error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^

Rust 中的賦值默認具有移動語義——它們轉移所有權。 可以將復制語義賦予一個類型,這已經用於數字基元,但這並不常見。 因此,從第三行代碼開始,v2 擁有相關向量,並且不能再作為 v 訪問它。為什麼這很有用? 當每個資源只有一個所有者時,它也會有一個時刻超出範圍,這可以在編譯時確定。 這反過來意味著 Rust 可以兌現 RAII 的承諾,根據其範圍確定性地初始化和銷毀資源,而無需使用垃圾收集器或要求程序員手動釋放任何東西。

將此與引用計數垃圾收集進行比較。 在 RC 實現中,所有指針至少有兩條信息:指向的對象和對該對象的引用數。 當該計數達到 0 時,該對像被銷毀。這使指針的內存需求增加了一倍,並為其使用增加了一點成本,因為計數會自動遞增、遞減和檢查。 Rust 的所有權系統提供了相同的保證,即對像在引用用完時會自動銷毀,但這樣做不會產生任何運行時成本。 分析每個對象的所有權,並在編譯時插入銷毀調用。

借款

如果移動語義是傳遞數據的唯一方法,那麼函數返回類型將變得非常複雜、非常快。 如果你想編寫一個函數,它使用兩個向量來生成一個整數,之後不會破壞向量,你必須將它們包含在返回值中。 雖然這在技術上是可行的,但使用起來很糟糕:

 fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);

相反,Rust 有借用的概念。 您可以像這樣編寫相同的函數,它會藉用對向量的引用,並在函數結束時將其歸還給所有者:

 fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);

在 fn foo 返回後,v1 和 v2 將其所有權歸還給原始作用域,超出作用域並在包含作用域退出時自動銷毀。

這裡值得一提的是,借用檢查器在編譯時強制執行借用限制,Rust Book 非常簡潔地說明了這一點:

任何借用的持續範圍不得大於所有者的範圍。 其次,你可能有這兩種借用中的一種,但不能同時有兩種:

對資源的一個或多個引用 (&T)

恰好一個可變引用 (&mut T)

這是值得注意的,因為它構成了 Rust 防止數據競爭的關鍵方面。 通過在編譯時防止對給定資源的多次可變訪問,它保證了不能編寫結果不確定的代碼,因為它取決於哪個線程首先到達資源。 這可以防止迭代器失效和釋放後使用等問題。

實用的借用檢查器

現在我們了解了 Rust 的一些特性,讓我們看看我們如何實現我們之前見過的相同的文件行計數器:

 fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } }

除了源代碼中已經註釋的項目之外,還值得仔細閱讀並跟踪各種變量的生命週期。 file_namefile_lines一直持續到 main() 結束; 那時調用它們的析構函數,無需額外費用,使用與 C++ 的自動變量相同的機制。 調用read_lines_from_file時, file_name在其持續時間內不可變地借給該函數。 在read_lines_from_file中, buffer的行為方式相同,在超出範圍時被銷毀。 另一方面, lines持續存在並成功返回到main 。 為什麼?

首先要注意的是,由於 Rust 是一種基於表達式的語言,return call 一開始可能看起來不像一個。 如果函數的最後一行省略了尾隨分號,則該表達式就是返回值。 第二件事是返回值得到特殊處理。 假設他們希望至少與函數的調用者一樣長。 最後一點是,由於涉及到移動語義,將Ok(lines)轉換為Ok(file_lines)不需要復制,編譯器只需將變量指向適當的內存位。

“只有到了最後,你才會意識到 RAII 的真正力量。”

自編譯器發明以來,手動內存管理是程序員一直在發明方法來避免的噩夢。 RAII 是一種很有前途的模式,但在 C++ 中被削弱了,因為如果沒有一些奇怪的解決方法,它根本不適用於堆分配的對象。 因此,垃圾收集語言在 90 年代出現爆炸式增長,旨在讓程序員的生活更愉快,甚至犧牲性能。

然而,這並不是語言設計中的硬道理。 通過使用新的、強大的所有權和借用概念,Rust 設法將 RAII 模式的基於範圍的模式與垃圾收集的內存安全性相結合; 所有這些都不需要垃圾收集器來阻止世界,同時提供任何其他語言都沒有的安全保證。 這是系統編程的未來。 畢竟,“犯錯是人之常情,但編譯器永遠不會忘記。”


進一步閱讀 Toptal 工程博客:

  • WebAssembly/Rust 教程:音高完美的音頻處理
  • 在 Java 中尋找內存洩漏