1.7 内存 Pt.5堆内存vs.栈内存、虚拟内存、数据在RAM中展示的建议1.7.1. 动态内存分配在任何时刻运行中的程序都会占据一部分的内存。有时候程序需要更多的内存就需要向操作系统请求这就是动态内存分配(dynamic allocation)。下面是内存动态分配的步骤示意图通过系统调用向系统请求内存。在Unix类系统里使用alloc()函数在Windows系统中使用HeapAlloc()函数程序使用分配的内存如果内存在使用过后不再需要了那么程序会把不再需要的内存释放给操作系统。Unix类系统里使用free()函数Windows使用HeapFree()函数。PS程序和系统之间在请求内存时会有分配器(allocator)。它是嵌入在程序幕后的一个专业的子程序它会执行一些优化来避免CPU内和操作系统的大量工作1.7.2. 为什么栈内存和堆内存之间存在性能差异首先需要说明的是栈(stack)内存和堆(heap)内存只是概念而已内存在物理上并没有这2个区域。访问栈内存块的原因在于- 函数本地变量都分配在栈内存上在RAM上彼此相邻连续布局。连续布局对于缓冲很友好。访问堆内存慢的原因- 分配在堆内存上的数据不太可能彼此相邻- 访问堆内存上的数据需要对指针进行解引用页表查找表和区访问主内存栈内存与堆内存的简单对比StackHeap备注简单复杂安全危险危险指Unsafe Rust快慢死板灵活- Stack上的数据结构在生命周期内大小不能改变- Heap上的数据结构更加灵活因为指针可以改变1.7.3. 虚拟内存虚拟内存是程序所见到的内存视图程序可以访问的所有数据都是由操作系统在其地址空间中提供的。从直觉上来看程序的内存就是一系列字节从开始位置0到结束位置n。例如有个程序汇报书用了100KB的RAM那么n就应该在100000左右。看个例子fn main() { let mut n_nonzero 0; for i in 0..10000 { let ptr i as *const u8; let byte_at_addr unsafe { *ptr }; if byte_at_addr ! 0 { n_nonzero 1; } } println!({}, n_nonzero); }这个例子会逐字节地扫描正在运行程序的内存起始位置是0终止于9999let ptr i as *const u8;把i转化为u8类型一个u8占一字节的原始不可变指针目的是检查这个内存地址。这里我们把每一个地址作为一个单元。实际上很多值都不止一个字节都是跨字节的但是在这里我们就不考虑下一行let byte_at_addr unsafe { *ptr };对指针进行了解引用对原始指针的操作要放到unsafe块里读取里面的值赋给byte_at_addr如果byte_at_addr的值不等于0那么n_nonzero的值就加1最后打印出n_nonzero的值输出segmentation faultsegmentation fault指的是当CPU或操作系统检测到程序试图请求非法无权访问的内存地址时所产生的错误。segment指的是虚拟内存中的块。虚拟内存被划分为块来最小化虚拟和物理地址之间转换所需的空间。那么到底是访问哪个内存是非法的呢是0。当i等于0的时候就相当于是一个Null指针这个指针不能被解引用。这也部分解释了为什么原始指针的操作必须放到unsafe块里。那我们让循环从1开始fn main() { let mut n_nonzero 1; for i in 1..10000 { let ptr i as *const u8; let byte_at_addr unsafe { *ptr }; if byte_at_addr ! 0 { n_nonzero 1; } } println!({}, n_nonzero); }输出segmentation fault一样的错误。这个例子行不通我们换一个例子static GLOBAL: i32 1000; fn noop() - *const i32 { let noop_local 12345; noop_local as *const i32 } fn main() { let local_str a; let local_int 123; let boxed_str Box::new(b); let boxed_int Box::new(789); let fn_int noop(); println!(GLOBAL: {:p}, GLOBAL as *const i32); println!(local_str: {:p}, local_str.as_ptr()); println!(local_int: {:p}, local_int as *const i32); println!(boxed_int: {:p}, Box::into_raw(boxed_int)); println!(boxed_str: {:p}, Box::into_raw(boxed_str)); println!(fn_int: {:p}, fn_int); }声明了GLOBAL、local_str和local_int等变量静态变量也算变量有的是存储在堆内存上有的是存储在栈内存上把这些变量的内存地址打印出来local_str.as_ptr()打印的是字符串数据的地址。相比对local_str as *const str使用{:p}更推荐这种写法*const str是宽指针当前 Rust 会把它格式化成Pointer { addr: ..., metadata: ... }而不是单纯的十六进制地址。输出GLOBAL: 0x102572ae4 local_str: 0x102572ae0 local_int: 0x16d8c260c boxed_int: 0x102d89b10 boxed_str: 0x102d89c10 fn_int: 0x16d8c2620虽然我们的程序很小但是变量在虚拟内存上的分布很分散。但是还是有一定的分布规律-GLOBAL和local_str的地址比较接近-box_int和box_str的地址比较接近-local_int和fn_int的地址比较近虚拟内存的空间大小大概是2^48但实际上物理内存肯定没有这么大。而且在虚拟内存空间里有一部分是系统为自己预留的预留的地址没发使用。通过例子通过这些例子我们能得到一些信息- 某些内存地址是非法的访问越界的内存程序就会关掉你的程序。- 内存地址并不是随机的看起来不同类型的值在内存中分布的比较广但实际上是有规律的。1.7.4. 翻译虚拟地址到物理地址程序里访问数据需要虚拟地址程序只能访问虚拟地址。虚拟地址会被翻译为物理地址这就要涉及到程序、系统、CPU、RAM硬件有时候还会涉及到硬盘及其他设备- CPU负责执行翻译具体来说是CPU里的内存管理单元MMU负责这项工作- 系统负责存储指令- 这些指令也存在内存中一个预定义的地址中最坏的情况下每次访问内存都会发生两次内存查找一次是要访问的内存一次是指令。CPU会维护一个最近转换地址的缓存。它有自己的快速内存来加速内存的访问。由于历史原因该内存称为转换后备缓冲区(Translation Lookaside buffe简称TLB)。为了提高性能程序员需要保持数据结构精简避免深度嵌套。尤其是达到TLB容量对于x86处理器来说大概是100页以后。解释一下专业名词- 页(Page)指的是实际内存中固定大小的字块64位系统通常是4k- 字(Word)指针大小的任何值对于CPU寄存器的宽度。Rust中的usize和isize就是字长类型(Word-length type)。虚拟地址被分为很多块叫做页(page)通常4KB大小。分成块的好处在于避免了需要为每个变量都存储转换映射。而且页是统一大小的有助于避免内存碎片可用RAM中出现空的、不可用的空间。注意上面所说的只是通用性的指导例如像微控制器情况就不同了。1.7.5. 数据在RAM中展示的指导性建议将程序热工作的部分保持在4KB以内从而保持快速查找性能较好。很多程序不能把热工作的部分控制在4KB以内4KB这个目标对于这种程序不合理那么下一个目标应该是4KB * 100。这意味着CPU可维护其转换缓存TLB来支持你的程序。避免深度嵌套数据结构。如果指针指向另一个页那么性能就会受到影响。在遍历数组时访问的顺序会影响缓存的利用率(因为CPU会从RAM读取小块字节这叫chache line或者叫缓存行)从而影响程序的性能。C/C、Rust、PythonNumPy等语言的二维数组在内存中是按行存储的。例如int matrix[3][3] { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };在内存中是这样存储的1 2 3 | 4 5 6 | 7 8 9如果用行优先遍历for (int i 0; i 3; i) { for (int j 0; j 3; j) { process(matrix[i][j]); } }matrix[i][j]在内存中是连续的能很好地利用缓存行减少 RAM 访问提高速度。如果用列优先遍历for (int j 0; j 3; j) { for (int i 0; i 3; i) { process(matrix[i][j]); } }由于列在内存中是分散的访问matrix[i][j]时可能跨多个缓存行。CPU可能会频繁从RAM读取新缓存行导致缓存未命中cache miss影响性能。总结一下- 在 C/C、Rust、PythonNumPy等默认使用行主序Row-Major的语言中尽量按行遍历数组。- 在 MATLAB、Fortran 等使用列主序Column-Major的语言中按列遍历更高效。注意虚拟化会让情况更加糟糕如果在虚拟机内运行应用程序管理器(Hypervisor)还必须为其客户端的操作系统转换地址。这就是为什么许多CPU自带虚拟化支持——通过减少转换来减少额外的性能开销。如果在虚拟机中运行容器那就是又添加一层间接同时也就增加了延迟。所以说如果想要获得裸机的性能就必须在裸机上运行程序。