用了這麼多年 Rust 終於搞明白了內存分佈!

阿里妹導讀

Rust 作爲一門學習曲線十分陡峭的語言,掌握其核心基礎數據結構的內存分佈對學習 Rust 會有很大的幫助,本文由淺入深仔細介紹了 Rust 的各個數據結構在內存中的分佈情況。

Rust 作爲一門學習曲線十分陡峭的語言,掌握其核心基礎數據結構的內存分佈對學習 Rust 會有很大的幫助,即使對於已經熟悉 Rust 的同學,深入數據結構分佈也能幫助到調優 Rust 程序。

接下來,我會由淺入深仔細介紹 Rust 的各個數據結構在內存中的分佈情況,幫助大家學習 Rust。

先決條件 Prerequisite

在開始介紹之前,我們先做這個幾個假設,來更好地幫助後續文章的展開。

  1. 我們本文的機器預設是 32 位的(主要爲了畫圖可以精簡一點),所有和位相關的數據結構均會用上標標記(即這些數據結構佔用的是 1 machine word)。例如:

  2. 數據結構基本單位示圖:

藍色的框框代表 1 個 byte,綠色的框框 pointer 下的(1|2|3|4)代表 pointer 在 32 位機器上 rust 是 4 個 byte,他們整體被框在綠色框框中代表一個 pointer。

一、基本類型

不用害怕,讓我們把一隻小腳試探性地邁入 Rust 的大門,先看看基礎類型的內存分佈吧。

這些數據結構 Rust 分配的時候都是在棧上的。

1.1 Stack 棧 vs Heap 堆

因爲本文會涉及到 Rust 中棧和堆分配,本小章先來簡單講一下棧和堆。
我們只提煉一些最基本的區別概要,更多的細節可以看這篇文章 [1] 有比較好的解釋。

棧特點:

  1. 分配快

  2. 大小受限

堆特點:

  1. 分配慢

  2. 大小不受限

二、元組 Tuple

讓我們先從比較基礎的 Rust 數據結構 Tuple 看起。

let a:(char, u8, i32) = ('a', 7, 354);
size_of::<(char, u8, i32)>(); // 打印結果 12
align_of::<(char, u8, i32)>(); // 打印結果 4

該元組由三個元素構成——char、u8 和 i32,由 1 基本類型中可知 char 佔 4 bytes,u8 佔 1 byte, i32 佔 4bytes,那麼初步計算出來這個 tuple 佔用的總內存應爲 4+1+4 = 9 bytes。接着,Rust 會選擇 Tuple 中對齊值最大的元素爲 a 該元組的對齊值,由此上例 alignment 是 4。有了整體對齊值,Rust 會在內存中加入一段填充 (padding) 來讓整體內存佔用是 alignment 的整數倍,本例中加在 u8 與 i32 中間是爲了保障 i32 自身的內存對齊。

由於 Rust 有多種數據排布風格(默認的 Rust 風格,還有 C 語言風格,primitive 和 transparent 風格),在 Rust 風格中,Rust 可以對元組中的元素做任意重排,也包括 padding 的位置,因而圖中的排列只是一種可能,也許 i32 和 char 的位置在 Rust 中會進行互換,Rust 是根據其優化算法做出其認爲最優的排序,對最終排序結果並沒有統一規則。

上圖爲該 tuple 的內存分佈圖。

三、引用 Reference

引用 reference 是 Rust 中的一個重要概念,相關規則也是支撐了 Rust 內存安全的重要支柱。我們來看下面的例子。

let a: i8 = 6;
let b : &i8 = &a;

a 是一個 i8,b 是一個指向 a 的 reference,我們可以看下他倆的內存分佈。

首先,Rust 會在棧上分配一個大小爲 1byte 的 i8 存儲 a,接着會在內存另外一個空間(不一定和 a 連續)分配 b,b 中存儲的內存空間會指向 a 所在的內存空間,同時 b 的內存佔用大小即 pointer 的大小。

需要注意的是,&T 和 &mut T 在內存分佈上規則一致,他們的區別是在使用方式和編譯器處理方式上。

四、Array 數租 和 Vector 動態數組

接下來我們來看看 Rust 的數組 Array 和動態數組 Vector 的內存分佈,以下面的數組和動態數組爲例。

let a: [i8; 3] = [1, 2, 3];
let b: Vec<i8> = vec![1, 2, 3];

數組 Array 是固定大小的,所以在創建的時候都指定好了長度;動態數組 Vector,由其名字就可以知道他是可以自由伸縮的,那麼我們來看看 Rust 是怎麼在內存上存儲這兩位數據結構的。

對於 Array a,由於他固定大小爲 3 個 i8,Rust 即在棧上爲其分配了 3 * 1 byte 個內存。
對於 Vector b 就有點特殊啦,他會由如下三個部分組成:

  1. pointer : pointer b 會指向 vector b 在堆上的實際數據(目前是 1, 2, 3 共 3 * 1 byte),

  2. cap(圖中上標 32 代表這個值和機器位數有關,最後複習一次哦): cap 代表最多多少個 T(本例中 T 是 i8)的內存可以在堆上讓這個動態數組使用,默認大小爲創建時的 T 個數,可根據使用需求自動擴容,但每次擴容時會帶來 reallocate 影響到性能。

  3. len (1 machine word),代表目前有多少個 T(本例中 T 是 i8)的內存真實被該動態數組使用。

以上即可看到數組和動態數組由於在 “動態” 這個特點上的不同,出現的內存分佈差異啦。

4.1 Slice 數組切片

接下來,我們通過 Array 和 Vector 來看下 Rust 中切片的內存分佈實現。
假設我們想獲取到上面例子中 a 和 b 兩個 Array 和 Vector 的前兩個元素。

let slice_1: [i32] = a[0..2];
let slice_2: [i32] = b[0..2];

然而,對於 [i32],Rust 沒法在編譯時明確這個變量需要多少內存,因而也沒法在棧上分配內存,因而上例中的 slice_1 和 slice_2 實際上會編譯失敗。這樣的變量稱之爲 dynamically sized type,後續會講到 string slice 和 trait object 也屬於這個範疇。

因而,通常我們使用一個 reference 來指向一個 Slice 切片,讓我們看下例

let slice_1: &[i32] = &a[0..2]
let slice_2: &[i32] = &b[0..2]

當 reference 指向 dynamically sized type 時,Rust 實際會使用到一個胖指針(fat pointer),其中包含:

  1. pointer (1 machine word):指向實際被切片的數據。

  2. length (1 machine word): 切片長度,即有多少個 T(本例中 T 爲 i32)。

我們可以看下上述例子的內存分佈圖。

五、String, str, &str

接下來讓我們來看下 String, str 和 & str 的內存分佈。以一個例子開始吧。

let s1: String = String::from(HELLO);
let s2: &str = “ЗдP;  // д -> Russian Language
let s3: &str = &s1[1..3];

首先,s1 是一個 String,String 實質上就是 Vec 的一個包裝,其中也是在棧上有一個指針 + cap(1 machine word) + len ( 1 machine word ),指針指向了該 String 實際在堆上的值。String 是保證 UTF-8 兼容的。

如果我們直接在變量中存了一個字符串字面值 (string literal),例如 s2,那麼這個變量會是一個指向 string slice 的指針。這個 string 數據不會存儲在堆 heap 上,而是會直接存在編譯後的二進制中,同時他們具有 static 生命週期,即直到程序結束前都不會被釋放。如同前面講的 slice 以後,&str 也同樣是個胖指針,同時包含了實際數據的內存地址和數據長度 (一共 2 machine words)。這裏的例子裏用了一個特殊字符д,由於 UTF-8 是一種可變長的編碼方式,這裏可以看到д就用了 2 個 byte 來表達。

s3 的情況與 4.1 中類似,使用到一個胖指針(fat pointer),其中包含:

  1. pointer (1 machine word):指向實際被切片的字符串。

  2. length (1 machine word): 切片長度。

六、Struct

Rust 有三種結構體類型定義方式:

6.1 unit-like Struct

struct Data

由於並沒有定義 Data 結構體的細節,Rust 也不會爲其分配任何內存。

6.2 Struct with named fields && tuple-like struct

這兩種結構體的內存分配方式是類似的,我們來看一個例子就好。

struct Data {
   nums: Vec<usize>,
   dimension: (usize, usize),
}

首先,nums 是 Vec,佔用 3 個 machine word(pointer + cap + len),pointer 指向 heap 上實際動態數組的值;dimension 是兩個 usize 組成的 tuple,佔用 2 個 machine word。由於之前談到,Rust 風格的數據排布是可以做任意重排的,所以具體的 padding 在圖中就並沒有畫出了。

七、Enum

enum HTTPStatus {
   Ok,
   NotFound,
}

對於 C-style enum,在內存中,rust 會根據該 enum 中最大的數來選擇內存佔用最小的 int 來存儲,此例中沒有指定就會默認 Ok 爲 0,NotFound 爲 1,Rust 選擇佔用 1 byte 的 i8 來存儲 enum。

同時,每個 Enum 的整數值是可以指定的,例如:

enum HttpStatus {
   Ok = 200,
  NotFound = 404,
}

本例中,Rust 會選擇佔用 2 byte 的 i16 來存儲 enum(以滿足存儲 404)。

接着我們來看更復雜一些的 Enum:

 Empty,
  Number(i32),
  Array(Vec<i32>),

對於這類有具體數據結構的 Enum,每一個 Enum 中的元素都有一個 1 byte 的 tag,tag 用於標識屬於 Enum 中具體哪個變量。此例中,Empty 的話 tag 爲 0,而 Empty 後的內存空間都是爲了滿足對齊要求而構造的 padding,後續的 i32 和 Vec 均和之前介紹的分佈一樣,在 enum 中它們有不同的幾點:加入了 1 byte tag 以及 padding,因而也可以看到每一個 Enum 所佔的空間由其中佔用空間最大的變量所決定,如果要優化 Enum 的空間佔用,可以從削減其中最大元素做起。

(padding 的位置是不固定的,Rust 會根據具體數據結構的內存分佈調整 padding 位置以做優化)

7.1 Option

Rust 中的 Option 實質上是便是一種 Enum,我們可以看下 Option 的定義:

pub enum Option<T> {
  None,
  Some(T),
}

Rust 通過 None 和 Some 的區分,避免了其他語言中可能發生的空指針訪問問題。我們可以看下 Option<Box> 這個例子,稍後我們會仔細介紹 Box,在這裏你可以先理解 Box 會將原來的 i32 從棧放到堆,然後 Box 會是一個指針指向原來的 i32 新的堆的地址。

由於 pointer 本身只佔 1 machine word,而 tag 的存在多了 1 byte,導致 Rust 需要根據對齊值加入 paddign 使其對齊,使得整體內存佔用提升,很明顯這裏有可以優化的空間。於是,Rust 對於類似 Box 這樣的不允許爲 null 的 SmartPointer,Rust 進行了如下優化:

如此,整體內存佔用降到了 1 machine word。如果該 Option 值爲 0,那麼 Rust 就知道他是 None,如果非 0,那麼 Rust 就知道他是 Some,通過這樣省去了 tag 的作用並且節省了內存空間消耗。

八、Box

對於通常默認分配在棧上的變量,使用 Box 可以將其分配到堆上,棧空間上只用分配指向堆數據的指針。

我們以一個 tuple 爲例 let t: (i32, String) = (5, “Hello”.to_string); ,在沒有經過 Box 處理前,它的內存分佈如下圖:

(圖中省去了 padding)

如果我們將該數據結構放到 Box b 中,即

let t: (i32, String) = (5, Hello.to_string);
let mut b = Box::new(t);

內存分佈則如下圖:

可以看到,原本在棧上的內容都被轉移到 Heap 上,減少了我們在棧上的內存空間消耗。

基礎篇在這裏就完結啦,後續會繼續展開進階篇,對 Rust 的 Copy&Move, 智能指針, Arc 等特性做進一步展開。

參考鏈接:

[1]https://web.mit.edu/rust-lang_v1.25/arch/amd64_ubuntu1404/share/doc/rust/html/book/first-edition/the-stack-and-the-heap.html?spm=ata.21736010.0.0.48ae3d52Tpg5zy

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/OnLox1rh9UOxaBTMJcurcw