【閒談 rust 語言】內存安全與垃圾回收器
這篇文章通俗地談談內存安全的概念、手動內存管理的問題、垃圾回收器的優劣,再講講 rust 解決內存安全問題時獨特的做法。
手動內存管理
現在流行的操作系統和數據庫等基礎軟件,多是使用 C 或 C++ 編寫的。在 C 和 C++ 中,每當使用動態內存分配(如使用變長數組、變長字符串)時,需要手動分配和釋放內存。以 C 語言爲例:
// 爲變量 a 分配內存區域
char * a = malloc(64);
// 使用變量 a
// ...
// 釋放 a 的內存區域
free(a);
每次使用動態內存分配的變量之前,總是要在一開始用 malloc 分配內存區域,在末尾用 free 釋放掉它。這樣很麻煩,而且經常會遺漏 free ,所以後來 “垃圾回收器” 被髮明出來了。
垃圾回收器
如果使用包含有垃圾回收器的編程語言,就可以省掉 free 的煩惱。以 JavaScript 爲例:
// 創建一個新的數組
var arr = new Array(64);
// 使用變量 arr
// ...
在一開始時,通過 new 分配的內存區域會由垃圾回收器來統一管理,並不需要在代碼的末尾釋放。每隔一段時間,垃圾回收器會做一個檢測,自動識別出來到底哪些內存區域可以被釋放了。這讓寫代碼方便了很多。
當然,垃圾回收器的問題也是顯而易見的。
一是垃圾回收器需要間歇性地檢測識別可釋放的內存區域,這會產生不受代碼控制的 “垃圾回收中斷”,像是代碼暫停執行了一樣。
二是垃圾回收器並不是即時釋放內存區域、很多內存區域並不會在不再使用時立即釋放,導致總體佔用的內存偏大。
三是垃圾回收器需要完全管理編程語言中所有分配出來的內存區域,這導致同時使用多門帶有垃圾回收器的編程語言時,總是需要更多內存拷貝操作,編程語言之間交互的代碼更加繁瑣。
因而,對性能要求高的基礎軟件、常被其他編程語言引用的底層庫,一般不使用帶有垃圾回收器的編程語言來實現。
儘管如此,現在很多編程語言仍然帶有垃圾回收器。因爲除了代碼編寫方便之外,垃圾回收器還帶來了一個重要的好處,稱爲 “內存安全”。
內存安全問題
垃圾回收器總是在一片內存區域不被使用的時候纔去釋放它。而如果手工編寫 free ,有時很難做到正確使用 free 。
最常見的錯誤用法是 use-after-free :free 調用得過早,導致內存錯亂。例如:
void use_after_free_example_1() {
// 爲變量 a 分配內存區域
char * a = malloc(64);
// 釋放 a 的內存區域
free(a);
// 爲變量 b 分配內存區域
char * b = malloc(64);
// 向 b 寫入一段數據
strcpy(b, "data of b");
printf("%s\n", b); // 輸出 data of b
// 此時 a 仍可以使用,但訪問到了 b 的數據!
printf("%s\n", a); // 輸出 data of b
// ...
}
在上面這個例子中,由於變量 a 釋放得過早,使得後續分配變量 b 時, b 重新使用了 a 剛剛釋放掉的這片內存區域,最終導致 a 與 b 相等。
在實踐中,往往情況更加複雜。例如下面這種變形:
void use_after_free_example_2() {
// 爲變量 a 分配內存區域
char * a = malloc(64);
// 讓變量 c 和變量 a 指向同一片區域
char * c = a;
// 釋放 a 的內存區域
free(a);
// 爲變量 b 分配內存區域
char * b = malloc(64);
// 向 b 寫入一段數據
strcpy(b, "data of b");
printf("%s\n", b); // 輸出 data of b
// 此時 c 仍可以使用,但訪問到了 b 的數據!
printf("%s\n", c); // 輸出 data of b
// ...
}
在上面這個例子中,有變量 c 複用了變量 a 的內存區域,而 a 釋放時,對變量 c 的使用就會導致內存錯亂。
在實踐中,這些複雜的變形往往很難僅憑少數幾個開發者就看出問題,也不一定能通過測試就表現出來;即使測試表現出來了問題, debug 往往也很麻煩。何況,還有更多其他類似的內存安全問題,比如多次 free 同一塊內存區域、使用未初始化的內存區域、使用數組時下標越界等等。
一經發布,這些問題會給不懷好意的攻擊者留下攻擊面。歷史上最有名的案例之一是 OpenSSL 的 heartbleed 漏洞。這個漏洞說來也並不複雜:就是將某個已經內存錯亂了的變量內容通過網絡發送了出去。如果錯亂了的內容中剛好包含了需要保密的內容(如證書密鑰),就使得攻擊者拿到這些保密內容了。當時,衆多網站被迫更新了證書;據報道,一些來不及應對的網站受到了攻擊。
另據統計, Google Chrome 中 70% 的安全問題都屬於內存安全問題,其中的一半是最容易犯下的 use-after-free 錯誤。所以,不要以爲編碼足夠小心就能避免這種問題了。
rust 的解決方式
出於性能考慮, rust 是沒有垃圾回收器的語言,但 rust 有一套完整的體系來保證內存安全。
首先, rust 沒有顯式的 free 調用,而是在花括號塊的末尾自動釋放。例如:
fn example_1() {
{
// 爲變量 a 分配內存區域
let a: Vec<u8> = Vec::new();
// 花括號末尾自動釋放 a 的內存區域
}
let b: Vec<u8> = Vec::new();
// b 雖然重新利用了 a 剛釋放的內存區域
// 但 a 僅在上面花括號內有效,這裏不能再使用
// 這樣就避免了 use-after-free 問題
}
如果作爲花括號的計算結果拋到花括號外,則釋放時機自動延遲到外層花括號末尾。例如:
fn example_1() {
let a = {
// 爲變量 a 分配內存區域
let a: Vec<u8> = Vec::new();
a
// 這裏不會釋放 a
};
let b: Vec<u8> = Vec::new();
// a 還未釋放
// b 和 a 的內存區域不同
// a 和 b 在這個花括號末尾釋放
}
rust 的所有權規則規定,一個內存區域只能有一個所有者。例如:
fn example_2() {
// 爲變量 a 分配內存區域
let a: Vec<u8> = Vec::new();
// 將 Vec 所有權從 a 轉移到 c
let c = a;
// 此時 a 不能再使用,也不會在末尾被釋放
let b: Vec<u8> = Vec::new();
// c 還未釋放
// b 和 c 的內存區域不同
// b 和 c 在這個花括號末尾釋放
}
rust 的借用規則規定,如果變量 c 持有變量 a 的引用,則 c 不能在 a 所在的花括號末尾被拋出。例如:
fn example_2() {
let c = {
// 爲變量 a 分配內存區域
let a: Vec<u8> = Vec::new();
// c 是 a 的引用
let c = &a;
c
// 編譯失敗!
};
let b: Vec<u8> = Vec::new();
}
不符合所有權和借用規則的寫法都將直接導致編譯失敗。(詳細的規則比較複雜,這裏不再列舉了。)
rust 的一整套所有權和借用機制可以完整保證內存安全,而且沒有額外的運行時開銷。這種無垃圾回收器的內存安全機制就是 rust 最重要的設計之一,也是 rust 在編程語言領域理論價值的體現。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/QNqx4KJvm9YLUGZVD_pkag