关于rust:图解-Rust-内存布局

4次阅读

共计 13874 个字符,预计需要花费 35 分钟才能阅读完成。

疫情居家时看的一个英文视频(Rust Memory Layout),解开了心里的一些纳闷。从 5 号到 15 号做了残缺翻译(所有视频中的图片从新绘制花了些精力)。

侵删。


1. 二进制文件分段

fn main() {println!("Hello, world!");
}

当咱们编写 Rust 程序时,要么是间接调用 rustc

$ rustc main.rs

要么通过 cargo 生成一个可执行文件。

$ cargo build

而后便能够通过终端运行或间接双击(如 windows 中)运行它。

$ ./target/debug/demo
Hello, world!

生成的可执行二进制文件以特定格局存储数据。对于 linux 零碎来说,最常见的格局是 elf 64。每个操作系统(如 linux、mac 或 windows)应用不同的可执行格局,只管二进制文件在各个操作系统上的格局不同,然而它的执行形式却简直雷同。可执行格局大抵是:前 x 个字节示意一些头信息,接下来的 y 字节示意其它内容,依此类推。

当你运行二进制文件时,内核会为程序提供一个间断范畴的内存地址以供其应用。当然,这并不是 RAM 上的理论内存地址,内核和硬件零碎会在程序应用内存时再将它们映射到实在的物理内存地址。这段间断范畴的内存被称为程序的 虚拟地址空间

正在运行的程序称为过程(Process)。从过程的视角来看,它能看到的只是一段间断的从 0 到最大值的地址空间。像 elf 64 这类可执行文件格局,个别都指定了一些“段(segments)”,当二进制文件执行时,内核将它们映射到内存,segment 的数量因编译器而异,在此,仅展现其中一些重要的。

编译器可能将 Rust 这种高级语言编写的代码转换为 CPU 能够执行的机器指令。text segment 便蕴含这些指令。text segment 也被称为 code segment。这些指令因 CPU 架构而异,例如编译给 x86-64 平台应用的可执行文件便无奈在 ARM-64 平台 CPU 上运行。text segment 是只读的,运行中的程序无奈更改它。data segment 蕴含已初始化的动态变量(即全局变量)和一些已定义且能够被批改的部分动态变量。bss 的意思是 Block Started by Symbol,该段蕴含未初始化的全局变量。内核还会将一些 附加数据,例如环境变量、程序运行参数和参数的数量映射到高位地址。

之后,(stack)segment 被调配在内存地址高位的末端。咱们之前提到过,正在运行的程序称为过程。过程是操作系统用来存储过程名、过程 id 等所有细节的一个形象。过程至多蕴含一个执行线程,每个执行线程都蕴含各自的栈。在 64 位的 Linux 零碎中,Rust 程序会为主线程调配 8MB 的栈。而针对用户程序可能创立的任一线程,Rust 规范库则反对指定栈的大小,其默认值为 2MB。

只管主线程栈的大小是 8MB,但这 8MB 并不是立刻调配的。只有当程序真正应用它的时候,内核才会进行调配。栈向下增长,即向着低位内存增长。它只能增长到所属线程的最大栈容量,比方主线程只能到 8MB。如果程序线程试图应用更多的栈内存,内核则会将其终止,你便会失去一个 “stack overflow” 谬误。栈内存用于执行函数,我后续会具体讲。

这里要讲的最初一个分段是 (heap)内存。和栈不同的是,堆并非是被各个线程所领有。同一过程的所有线程共享一个通用的堆内存区域。堆内存向上增长。针对运行中程序的这部分内存映射,操作系统通常会提供一些办法来查看它们,如 Linux 中,这些堆内存能够在 /proc 目录的指定过程下的 “maps” 文件中查看。

$ cat /proc/10664/maps

你可能曾经意识到了,既然栈内存和堆内存向着彼此增长,那么这部分区域会否产生笼罩呢?通过检测堆内存的最高位地址和栈内存的最低位地址之间的间隔不难发现,其相距了近 47TB,因此极不可能会产生内存笼罩的景象。即使笼罩的景象真的行将产生,内核也提供了守卫机制用以及时中断程序。要晓得,这些都只是虚拟地址,纵使电脑只有 16GB 的 RAM,内核仍然可能应用如此微小范畴的内存地址。虚拟内存仅会在程序应用它们的时候映射为物理内存。

内存地址的范畴由 CPU 字长决定,CPU 字长大体上能够认为是 CPU 一次解决的数据量。64 位 CPU 的字长是 64 位(或 8 字节)。CPU 中大多数甚至所有的寄存器都具备雷同的字长。64 位 CPU 的寻址范畴是从 0 到 2^(64-1),也就是把各位都设为 1 的状况,这是一个十分大的值。过来,32 位 CPU 有一些限度,它最多只能寻址到 2^32 的内存地址,大概为 4 GB。现在,在 64 位 CPU 上,咱们只将其中 48 位用于内存寻址,这 48 位大概可寻址到 282 TB 的内存。除此之外,只有前 47 位属于用户空间,意味着大概有 141 TB 的虚拟内存被调配给用户程序,而位于高位的 141 TB 则保留给内核自用。47 位,就是说你能够应用的最大地址是 0x7ffffffffff,因而如果你检查程序的内存映射,应该能在 stack 左近看到这个值。

2. 深刻理解堆栈

接下来让咱们深刻理解栈内存空间的作用。

fn main() {
    let a = 22;
    let b = add_one(a);
}
fn add_one(i: i32) -> i32 {i + 1}

示例中 main 函数调用了 add_one 函数,咱们没有新建其它线程,因而示例中只有一个线程在执行。从上一节的解说中能够得悉,容许为主线程调配的栈的总大小为 8MB,接下来应用一个白框示意这 8MB 内存空间:

仅当程序须要时,内核才会为其分配内存。栈内存的一个次要作用是存储以后正在执行函数的数据,包含函数参数、局部变量以及返回值的地址。为执行一个函数在栈上调配的总内存被称为 栈帧 (stack frame)

此例中,main 函数是入口函数。首先 main 函数的栈帧被创立,

main 中有个局部变量 a,它的值是 22。还有另一个局部变量 bb 也是 i32 数据类型。i32 数据类型须要 4 个字节,main 的栈帧同样须要蕴含足够的空间来寄存它。另外,应用 栈指针(stack pointer) 指向以后栈顶。

接下来当 main 调用 add_one 函数时,会创立一块新的栈帧并蕴含足够的空间来寄存它本人的数据。栈指针的指向也切换到以后最新栈顶。add_one 函数接收数据类型为 i32 的入参 i,因而须要在栈帧为它保留 4 字节的内存,add_one 函数没有局部变量。另外,它还要存储一个返回地址,这是 main 函数中的下一条指令,当 add_one 函数实现时,执行应返回该指令。

add_one 函数返回之后,返回值 23 就会被存储在 main 的局部变量 b 中,同时栈指针也会被更新。这里有一点要留神,此时 add_one 的栈帧并没有被开释,它会在程序调用下一个函数时被笼罩。

留神一下栈内存的调配形式:调配或开释内存只须要挪动栈指针。栈内存的调配速度很快,因为它不须要进行零碎调用。当然,它也有肯定的局限性,即只有在编译时便已知的、具备固定大小的变量才能够存储在栈上。另外,也不能返回对函数外部局部变量的援用。

fn add_one(i: i32) -> &'static i32 {
    let result = i + 1;
    &result
}

起因很显著,从刚刚对栈的剖析便可得悉。如果你尝试返回一个定义在 add_one 函数内的局部变量的援用,但实际上,当 add_one 返回后,其内存就被开释了,当下一个函数被调用时,新的栈帧就会笼罩原来的内存区域。在带有垃圾回收器的语言里,编译器可能自动检测到这种问题,并把该变量调配在堆上,而后返回对它的援用,但把变量调配到堆上会带来一些额定开销。无关堆内存的细节,一会便会讲到。

既然 Rust 没有垃圾回收器,它也不会强制把数据调配到堆上,所以编译器不能编译这段代码。浏览一下错误信息,当初你应该很分明为什么编译器会抛出这样的谬误了吧?

当初来看一下堆内存。

该例中 main 正在调用 heap 函数,它会为该函数创立一个栈帧。

fn main() {let result = heap();
}
fn heap() -> Box<i32> {let b = Box::new(23);
    b
}

而后咱们把值 23 应用 Box 调配到堆上,并把它存储在变量 b 中,函数 heap 的栈帧将有足够的空间存储该值,这是因为 Box 只是一个指针,存储在 b 中的值是一个来自堆上的地址,该地址里放着 23。

在 64 位零碎上,指针的大小是 8 字节,所以变量 b 的大小也是 8 字节。指针指向的值是 23,其类型为 i32,它在堆上须要 4 个字节的大小。heap 函数返回蕴含 i32 的 Box,返回值会存储在 main 函数的局部变量 result 中。

当你将一个变量赋值给另一个变量时,它的栈内存会被复制。在这种状况下,用于存储地址的 8 个字节会从 heap 函数的栈帧复制到 reasult 变量。

当初,就算 heap 函数的栈帧被开释,result 变量也保留着堆上数据的地址。堆容许你共享数据。之前还提到,每个线程都有本人的栈,但它们共享同一块堆内存。假如,程序在 heap 上调配越来越多的数据,直到堆上调配的内存简直用完了。通常,程序有一个内存管理器,它会通过零碎调用负责向操作系统申请更多堆内存。在 Linux 零碎中,这些零碎调用通常是 brksbrk,它们会减少程序可用的堆内存大小,从而减少用户程序的总可用内存。

在 Rust 中,堆内存分配器由 GlobalAlloc trait 形容,该 trait 定义了堆内存分配器必须实现的办法。作为程序员,你可能极少间接应用它,编译器会在须要时主动调用该 trait 的办法。兴许你相熟 c 规范库中的 malloc 函数,它并不是零碎调用,当程序向内核申请内存时,malloc 还是会调用 brksbrk。Rust 的内存分配器应用了 c 库提供的 malloc 函数。当应用像 ldd 这类工具来查看二进制文件的动静依赖关系时,将会看到其中一个是 libc,意味着 Rust 二进制文件须要 c 规范库或 libc 作为共享对象或已在操作系统中编译好的库。这一假设是安全可靠的,因为 libc 更像是操作系统的一部分,并且这种动静链接形式有助于升高 Rust 的编译体积。

另一方面,内存分配器并不总是通过零碎调用在堆上调配更多内存,每当程序应用 Box 或者其它相似的货色在堆上分配内存时,内存分配器会成块地去申请内存,以缩小零碎调用的次数。堆和栈不同,内存不肯定从堆的某一端开始开释,当一些内存被开释后,这些内存并没有立刻返还给操作系统,内存分配器会跟踪哪些内存分页是已应用的、哪些是闲暇的,这时,当要在堆上调配更多数据时,程序就不必再期待操作系统或内核。

大略你曾经晓得了为什么调配堆内存会比调配栈内存带来更大的性能开销:它可能须要零碎调用,并且内存分配器每次调配时都可能要寻找一块空间内存以供调配。

3. 根本类型及元组

这一节开始,正式看一看 Rust 的各数据类型在内存中的布局状况。

对于 有符号整型 无符号整型,只听其名字便能够晓得它的大小:例如 i16u16 在内存中都占用两个字节,它们全副调配在函数的栈帧上。isizeusize 的大小取决于机器字长,在 32 位零碎上,其大小是 32 位,也就是 4 个字节。

char 数据类型存储 unicode 字符,此处展现了些例子。它们在内存中均占用 4 字节,也调配在栈上。

元组 是不同数据类型的汇合。例子中变量 a 是由 charu8i32 组成的元组,其内存布局只是将成员彼此相邻地排列在栈上,示例中 char 占用 4 字节,u8 占用 1 字节,i32 占用 4 字节。既然所有成员都是在栈上调配的内存,所以整个元祖也是在栈上分配内存。

留神,该元组尽管看起来在内存中仅占用 9 个字节,但事实并非如此。对于这一点,能够应用规范库提供的 size_of 函数来查看某一数据类型的实在大小。

std::mem::size_of::<T>()

每种数据类型都有一个对齐属性,且调配给该数据类型的总字节数应该是对齐属性的整数倍。不仅 Rust 如此,每个编译器都如此。这样做有助于 CPU 更快更无效地读取数据。align_of 函数能够用于展现某种数据类型的对齐属性。

use std::mem::{align_of, size_of};

size_of::<(char, u8, i32)>();    // 12
align_of::<(char, u8, i32)>();   // 4

对于该元组,其对齐属性为 4。也就是说,纵使只须要 9 个字节,Rust 理论仍会应用 12 字节来示意该元组。额定的 3 字节将作为填充而增加。

4. 援用类型

这一节来看援用数据类型 &T

let a: i32 = 25;
let b: &i32 = &a;

代码中,变量 a 是 i32 类型的,其中贮存的值是 25。

变量 b 是对 a 的援用。之后的解说中,将不再具体展现某一类型的具体大小,而是把注意力放到整体上,更多地关注它们是在堆上还是在栈上存储。此处,a 存储在栈上,占用 4 字节,援用 b 也存储在栈上,它保留了变量 a 在栈上的地址。存储地址须要一个机器字长,因而在 64 位零碎中,变量 b 将占用 8 个字节。

如果咱们将 b 的援用存储在另一个变量 c 中,c 的数据类型将是对 i32 的援用的援用(&&i32)。变量 c 也将占用 8 字节并保留 b 的地址。

留神,援用也能够指向堆上的数据。

最初要说的是,可变援用 &mut T 在内存中也具备雷同的布局。援用和可变援用之间的区别在于它们的应用形式以及编译器对可变援用施加的额定限度。

5. 数组、Vector 及切片援用

数组 的大小固定,并且该大小也是其数据类型的一部分。此处数组的每个元素会在栈上相邻排列,然而,在数组创立后就不能够再扭转它的大小了。切记,只有编译时已知的、大小固定的数据类型能力调配到栈上。

数组的一个可扭转大小的替代品是 Vector。示例中 Vector v 和数组保留了雷同的元素,但数据是在堆上调配的,而申明变量 v 的函数栈帧上则蕴含 3 个机器字长。第一个字长示意指向堆上数据的地址,其余两个字长用于存储 Vector 的容量(cap)和长度(len)。

容量字段示意堆上有多少空间被保留用于存储数据,当向 vector 中增加更多数据时,如果还没有达到为其调配的容量,Rust 并不需要在堆中调配更多的空间。而当长度和容量雷同时,并且还有更多元素须要被增加到 vector 中,Rust 必须在堆中调配一个更大的数组空间,而后将以后所有元素复制到新的数组中,继而更新指针指向这个新的内存地位。留神,栈上的 Vector 始终保持固定大小。

另一个与之相干的数据类型是 T 切片(slice)。留神,该数据类型和固定大小的数组很像,只不过不须要在数据类型中指定其大小。切片更像是对它底层数组中某些元素的视图。

此处,s1 示意示例数组中的前 2 个元素,而 s2 示意堆上 vector 的前 2 个元素。切片的问题在于它并不指定元素的个数,也就意味着在编译期,Rust 并不分明须要应用多少字节来示意切片,换句话说,你不能用变量来存储切片,毕竟它们大小未知,不能调配在函数的栈帧上。这种类型被称为 动静大小类型(DST)。另外还有一些其它的动静大小类型,如字符串切片和 trait 对象,将在后续内容中解说。

简直所有时候,咱们都只会用到切片的援用。之前的章节曾经讲过,援用只是一个指针,并应用 1 个机器字长来寄存指向数据的地址。当你用到一个对 DST 的援用时(比方对切片的援用),Rust 会应用额定的 1 个机器字长来存储数据的长度,这种援用也被称为 胖指针,因为咱们将一些附加信息连同指针一起存储。

既然切片援用能够应用 2 个机器字长来示意,它便能够存储在栈上。最初有一点要揭示你,切片援用所指向的实在数据既能够在栈上,也能够在堆上。

6. 字符串类型

这一节来探讨字符串类型。String 类型的内存布局和 Vector 雷同,惟一的区别是 String 必须是 UTF-8 编码。

如果将字符串间接存储在变量中,其类型会变为对字符串切片的援用,该字符串不在堆上调配,而是间接存储在已编译的二进制文件中。据我目前所知,Rust 没有明确指出把该字符串具体存到哪个分段(segment)中,但应该就是在 code segment 自身上。

这类字符串具备 'static 生命周期,意味着它们永远不会被开释,并在程序的整个生命周期中都可用。就像对一般切片的援用一样,对字符串切片的援用也是一个胖指针,应用 2 个机器字长来示意,一个用于存储理论数据的起始内存地址,另一个则用于存储长度。

能够应用 range 操作来获取字符串的一部分,但这会返回一个字符串切片。

然而,因为字符串切片在编译期的大小未知,不能被寄存在函数的栈帧上,因而 Rust 不容许将它赋值给变量。所以此时你还是得用个 援用类型

7. 构造体类型

Rust 有三种构造体(struct)类型。上面这个构造体便是其中之一,它领有命名字段:

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

另外还有元组构造体(tuple-like struct):

struct Data(Vec<usize>);

以及单元构造体(unit-like struct):

struct Data;

单元构造体不蕴含任何数据,因而 Rust 编译器甚至不须要为其分配内存。另外两种构造体根据其成员有类似的示意形式,并且十分相似于咱们之前讲过的元组类型。让咱们看看第一种具备命名字段的构造体在内存中的示意形式:

它有两个字段:一个 Vec 和一个元组。构造体在栈上的布局等效于将它的各个成员彼此相邻排列。示例中 Vec 将占用三个 usize,而元组将占用另外两个 usize,留神咱们疏忽了内存对齐和填充。如果 nums 成员内有元素,它们将被调配在堆上。

8. 枚举类型

和构造体一样,Rust 反对多种枚举(Enums)示意办法。上面展现了一个 c 格调的枚举:

enum HTTPStatus {
    Ok,
    NotFound,
}

在内存中,它们被存储为从 0 开始的整数。

Rust 编译器会抉择可能存储该枚举类型的最大的变体(variant)中最短的整型。示例中最大的变体为 1,它只须要 1 个字节就能存储。你也能够为各个变体指定其整数值:

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

示例中,最大变体值为 404,在内存中至多须要 2 个字节来存储,因而枚举的每个变体都占用 2 字节。

在下一个示例中,枚举有 3 个变体:

enum Data {
    Empty,
    Number(i32),
    Array(Vec<i32>),
}

Empty 变体不存储任何其它数据,Number 变体中有一个 i32,Array 变体保留了一个元素类型为 i32 的 Vec。首先来看一下 Array 变体的内存布局:

首先是一个整数标记,这里就是 2。而后是三个 usize 用来存储 Vec。编译器还将增加一些 padding 以满足内存对齐。在 64 位零碎上,这个变体总共须要 32 字节。

当初来看一下 Number 变体,它存储一个 i32 类型,占 4 个字节。它也须要一个整数标记值,这里是 1,占用 1 个字节。因为所有的变体都具备雷同大小,编译器会为其填充,直到填满 32 个字节。

对于 Empty 变体,它只须要 1 个字节来存储整数标记即可。然而,编译器还是会为它填充 31 字节的 padding。

既然枚举的大小由它的最大变体决定,那么很显著,缩小内存占用的一个技巧就是升高最大变体的大小。该例子中,相比于间接把 Vec 存储在 Array 变体中,如果咱们抉择只存储 Vec 的指针,这个变体须要的最大内存便能够间接升高一半。Box 是指向堆上数据的指针,因而 Box 在栈上的局部只须要由 1 个 usize 来存储堆上数据的地址,在 64 位零碎上就是 8 个字节。一个被装箱的 Vec 的内存布局如图所示:

在函数的栈帧上,须要调配一个 usize 去存储它所指向的数据的内存地址。在堆上,须要调配 3 个 usize 去示意 Vec,记住,如果 Vector 里有值,这些值也将保留在堆上,并且指向具体值的指针将存储在 Vec 的指针字段中。

最罕用的枚举之一是 Option,它用于示意可能为 null 或空的值。

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

例如,假如你想示意一个指针,该指针指向一个在堆上调配的 i32 类型的值,你还想同时示意它的 0 值 也就是还没有被初始化的状态,在其它应用指针的编程语言中,通常能够应用 null 或者 nil 指针示意这种状态。在 Rust 中,能够示意为 Option<Box<i32>>,这使得 Rust 编译器可能确保代码不会产生指针相干的异样——比方解援用一个空指针。

Option 有两个变体,None 变体不存储任何值,它只存整数标记。Some 变体存储理论数据和整数标记,图示的例子中,因为 T 是一个 Box 指针,故而须要用 1 个机器字长来存储。思考一下,这里其实会产生内存节约,别的编程语言能够只用 1 个机器字长来示意指针,而 Rust 必须应用额定的 1 字节来寄存整数标记,还有随之而来的 padding 也会占用空间。事实上,如果寄存在 Option 中的值是 Box 或其它智能指针,Rust 编译器可能作出一些优化。因为任何智能指针的值都不容许为 0,所以 Rust 能够用一个 usize 示意 Option<Box<i32>>,它不再须要整数标记,指针为 0 的值能够用来示意 None,如果值不为 0,它则能够示意 Some。这么一来,Rust 中由 Option 包裹的智能指针和其它语言中的指针便一样了,不同之处在于,Rust 能够提前躲避解援用空指针的问题。

9. 所有权与智能指针

在进一步学习之前,咱们先来疾速认识一下 Rust 中 copymove 的区别。针对原始数据类型(比方整型),如果你将一个变量赋值给另一个,新变量将获取一份存储在右侧变量中的数据的正本。

Rust 对右侧的值做了一次逐位复制,这样做是可行的,毕竟这些值只应用栈上的字节来示意。Rust 容许之后的代码同时应用这两个变量。当函数返回后,它的栈帧会主动进行清理。

let v: Vec<String> = vec!["Odin".to_string(),
    "Thor".to_string(),
    "Loki".to_string(),];

当初咱们看一下须要在堆上调配数据的状况。此处示例一个在堆上调配了 String 的 Vector,每个字符串应用三个 usize 示意,别离存储着数据地址、容量和长度。在为 Vector 调配的堆内存中,用于存储字符串 header 的数据顺次排列,真正用于存储字符串的理论数据会被调配在堆上的其它地位,而指向它们的指针则保留在字符串 header 中。

在函数的栈帧中,须要为变量 v 调配 3 个机器字长以存储 Vector 的 header,header 中会有一个指针指向堆中的地址,从 所有权 的角度看,变量 v 领有整个堆上数据的所有权。因为 Rust 没有垃圾回收器,变量在超出作用域后须要负责清理它所领有的堆内存。

接下来,咱们将变量 v 赋值给一个新变量 v2,假如对该变量在栈上的局部做了逐位复制,那么 v2 也将领有一个指向同一块堆内存的指针,在领有垃圾回收器的语言中这是常有的事儿。这样做内存开销很小,因为无论堆中数据有多大,咱们只须要复制位于栈上的几个字节即可,垃圾收集器能够跟踪对堆调配的援用的数量,当所有援用都超出作用域后,垃圾收集器将负责开释该内存。

但 Rust 没有垃圾收集器,取而代之的是所有权机制。目前为止,咱们尚不分明哪个变量负责清理堆内存。另一种抉择是在赋值给一个新变量时同时将堆内存进行复制,然而这种复制操作可能会导致内存占用升高,继而造成零碎性能升高。Rust 的策略是针对各状况做出明确抉择——

如果你打算创立一个变量并且领有整个堆内存的正本,你须要调用 clone 办法;如果你不想让它克隆这个值,Rust 编译器就不容许你在后续代码中再应用变量 v 了,咱们称变量 vmove 了。

当初变量 v2 成为该值的所有者,当 v2 超出作用域后,它会负责开释堆中的这个值。

有时可能会须要一个领有多个所有者的值,大多数状况下,你能够应用一般的援用去共享该值,但这么做的问题是当所有者超出作用域范畴时,相应的援用也将无奈应用。事实上,咱们想要的共享所有权是指所有变量都是该值的所有者,并且只有当所有变量都超出作用域范畴时,这个值才应被开释。这便是智能指针 Rc 存在的目标。

当将 vector 包裹在智能指针 Rc 里时,用于存储 vector head 的三个机器字长(usize)会和援用计数一起调配到堆上。

在函数栈帧上,变量 v 值只须要一个 usize 用来保留 Rc 在堆上的地址。而后能够通过 clone 第一个 Rc 类型变量 v 来创立第二个变量。这里的 clone 并不会复制堆上的数据,相同,它只是复制了一次存储在栈上的指针,并简略地让援用计数的值加 1。

当初 vv2 都是数据的所有者了。这也正是 Rc 被叫作援用计数指针(Reference Counted pointer)的起因。Rc 有一个限度,它指向的值是不可变的,能够应用外部可变性等办法解决这一限度,但此文不会对其展开讨论。每当数据所有者超出其作用域,援用计数就会减 1,当它变为 0 时,整个堆内存值将被开释。

Rust 有一些非凡的标记 trait , 如 SendSync。简略来说,如果一个类型实现了 Send,则意味着该类型的值能够从一个线程传递到另一个线程。如果一个类型实现了 Sync,则意味着能够在多线程间应用共享援用共享其值。

Rc 指针没有实现 SendSync,假如两个线程领有指向雷同数据的 Rc 指针,在某个工夫点,两个线程同时 clone 并生成了他们的 Rc 指针,两者都将尝试更新同一份援用计数,这会导致数据竞争。

Rust 的一个次要长处就是它躲避了所有与内存相干的 BUG,如果你的确须要跨线程共享数据,能够应用原子援用计数指针(Atomically Reference Counted pointer)Arc。它的工作原理和 Rc 简直雷同,只是援用计数更新变为原子操作,能够多线程间平安地执行。然而原子操作会带来一些额定的性能开销,这就是为什么 Rust 须要 RcArc 两种独立的类型——若不须要,你不用破费额定的开销!

留神,默认状况下 Arc 也是不可变的,即便多个线程具备指向雷同数据的指针,也不容许它们扭转数据。如果须要在多线程间共享可变援用,能够在 Arc 内再包装一个 Mutex

let data: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));

当初即便两个线程尝试同时拜访数据,它们都须要先获取锁。同一时间内,只有一个线程可能取得锁,因而只有一个“写入者”能够扭转数据,Mutex 会爱护数据。

10. trait 对象

trait 对象是对 trait 类型的一个援用。有很多办法能够将具体类型转换为 trait 对象。第一个示例中,转换产生在给变量 w 赋值时:

use std::io::Write;

let mut buffer: Vec<u8> = vec![];
let w: &mut dyn Write = &mut buffer;

第二个示例中,转换产生在将某种具体类型作为参数传递给接管 trait object 的函数时:

use std::io::Write;

fn main() {let mut buffer: Vec<u8> = vec![];
    writer(&mut buffer);
}
fn writer(w: &mut dyn Write) {// ...}

在这两种状况下,Vec<u8> 都被转换为实现了 Writer 的 trait 对象。在内存中,trait 对象是一个胖指针,它由两个一般指针组成,因而每个 trait 对象均占用两个机器字长。其中,第一个用来寄存指向值的指针,在示例中就是 Vec;第二个指向一张表,这张表可能示意值的类型,能够被称为 虚表 vtable

vtable 在编译时生成,并被雷同类型的所有对象共享。vtable 蕴含了一系列指针指向示意函数的机器码,这些函数是实现 Writer trait 必须实现的办法。当你调用一个 trait 对象的办法时,Rust 会主动应用 vtable。留神,像第五节讲的切片一样,dyn Writer 也是动静大小类型,所以咱们也总是应用它的援用。同时,在下面的示例中间接把 Vec<u8> 转换为实现了 Writer 的 trait 对象之所以可行,是因为规范库为 Vec<u8> 实现了 Writer trait。

刚刚曾经见识到 Rust 可能将一般援用转换为 trait 对象,另外 Rust 也能够对智能指针(如 BoxRc)做同样的转换,这时,它们也会转为胖指针。

let mut buffer: Vec<u8> = vec![];
let w: &mut dyn Write = &mut buffer;
let mut buffer: Vec<u8> = vec![];
let w: Box<dyn Write> =Box::new(buffer);
let mut buffer: Vec<u8> = vec![];
let w: Rc<dyn Writer> = Rc::new(buffer);

Box<dyn Writer> 意味着领有一个在堆上实现了 Writer 的值,无论它是一般援用还是智能指针,在产生转换时,Rust 晓得援用的实在类型(此例中就是 Vec<u8>)。因而,它只是通过增加适当的 vtable 地址,将一般指针转化为胖指针。

11. 函数指针和闭包

函数指针只须要一个 usize 来存储函数的机器码地址。

例子中 test_func 是一个返回 bool 的函数,咱们把该函数存储在 main 函数的一个变量里。

最初来看一下闭包。Rust 没有具体的闭包类型,语言自身指定了三个 trait FnFnMutFnOnce 来形容它。首先看一下 FnOnce 闭包:

fn main() {let c = create_closure();
}

fn create_closure() -> impl FnOnce() {let name = String::from("john");
    || {drop(name);
    }
}

此处咱们申明了一个名为 create_closure 的函数,它返回一个实现了 FnOnce 的 trait 对象,在函数体内创立了一个字符串,咱们晓得,String 在栈上须要 3 个机器字长。

而后咱们创立一个闭包。闭包能够应用关闭函数内的数据,在示例的闭包内,只是简略的 drop 了刚刚创立的 name 变量。留神,FnOnce 仅是一个 trait,它只定义了对象的行为或办法。Rust 外部应用构造体来示意闭包,Rust 会依据闭包应用的变量创立一个适当的构造体,并为该构造体实现最合适的 trait。

struct MyClosure {name: String,}

impl FnOnce for MyClosure {fn call_once(self) {drop(self.name)
    }
}

闭包 trait 的理论签名略微有点简单,我在这里展现的只是一个简化版本。在例子中,闭包应用这样一个构造体示意:它只有一个 name 字段,该字段被关闭函数捕捉。call_once 是实现 FnOnce trait 时必须实现的办法。因为该 struct 只有一个 String 类型字段,它的内存布局将和 String 的雷同。留神一下 call_once 的函数签名,它蕴含一个 self,意味着它只能被调用一次。该例很显著,如果咱们调用两次闭包,它就会反复 drop 曾经被开释的字符串。

下一个示例中,咱们来创立一个 FnMut 闭包。

let mut i: i32 = 0;

let mut f = || {i += 1;};
f();
f();

println!("{}", i);  // 2

这次是 FnMut,因为咱们正在扭转变量 i 的值。这种状况下,用于示意闭包的构造体内将领有一个堆变量 i 的可变援用,同时,FnMut 须要 call_mut 函数接管一个 &mut self,意味着这个闭包能够被屡次调用。

留神,在第一个代码片段中应用了 mut 去申明保留了可变闭包的变量 ff 必须是可变的,因为 call_mut 函数接管一个对 self 的可变援用,如果尝试把 f 改为不可变的,编译器将抛出错误信息。

当你了解了其中的细节,错误信息就说的通了。错误信息的意思是说,调用该闭包须要一个可变借用。

最初一个闭包 trait 是 Fn trait。上面的例子中,闭包外部简略地打印了 message 变量。

fn create_closure() {let msg = String::from("hello");
    
    let my_print = || {println!("{}", msg); 
    };
    
    my_print();  // hello
    my_print();  // hello}

println 宏只获取参数的援用,因而 Rust 将在此处实现 Fn trait,构造体内只有一个对堆字符串的援用。还要留神,Fn trait 的 call 办法须要一个对 self 的援用,因而能够屡次调用该闭包,闭包内保留的变量也不用是可变的。

在下一个示例中,咱们会应用和刚刚雷同的闭包,然而相比于把它保留在变量中,咱们间接把闭包返回。

fn create_closure() {let msg = String::from("hello");
    
    || {println!("{}", msg); 
    }
}

这种状况下,编译器将给出如下编译谬误:被借用的字符串 msg 可能会超出以后函数的生命周期。

回忆一下该闭包的构造体内存布局,闭包内只存储了对字符串的援用,咱们在本教程一开始就晓得了,当函数返回后,它的栈帧就会被开释,所以该闭包不能持有对其函数栈帧内存区域的援用。Rust 要求咱们应用 move 关键字明确示意心愿让闭包拿走它所用到的变量的所有权。

fn create_closure() {let msg = String::from("hello");
    
    move || {println!("{}", msg); 
    }
}

当应用 move 关键字后,该闭包对应的构造体内就不再是一个援用了,而是字符串自身。

struct MyClosure {msg: String,}
impl Fn for MyClosure {fn call(&self) {println!(“{}”, self.msg);
    }
}

至此,咱们见到的闭包还都只有一个成员,上面的例子展现了一个拿走两个对象(一个字符串,一个 Vec)所有权的闭包。这也没啥非凡的,用于示意此闭包的构造体的内存布局将等效于字符串和 Vec 在栈上的示意形式——逐位并排搁置。

此模式在其它中央亦实用,例如异步生态中大量应用的 Future trait。在内存中,编译器应用 Enum 示意理论对象,并为它实现 Future 办法,此教程不再深刻解说。

此至,针对 Rust 内存布局的所有解说完结,心愿对你了解 Rust 语言有所帮忙!

ssbunny
2022-12-05

正文完
 0