APP下载

Rust | 实践:基础与实践(一)

原创

Rust

整理、归纳以及分享 Rust 在日常开发实践~

编辑器相关

Rust 在保存没有自动格式化代码

  1. 环境:Ubuntu22.04

  2. 编辑器:VSCode(by defauly) + rust-analyzer

  3. 解决方案:

按住 Ctrl + shift + p, 选择用户配置文件 - settings.json ,配置如下:

               
  • 1
  • 2
  • 3
  • 4
"[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", "editor.formatOnSave": true } COPY

内存

1. 只读数据段(RODATA)

1.1 字符串常量(string literal),在编译时被存入可执行文件的 .RODATA 段(GCC)或者 .RDATA 段(VC++),然后在程序加载时,获得一个固定的内存地址。

1.2 为了表述一个字符串,需要使用三个 word:

  • 第一个表示「指针」;
  • 第二个表示「字符串当前长度」;
  • 第三个表示「内存的总容量」

在 64 位系统下,三个 word 是 24 个字节。

2. 栈


Q1: 一个函数运行时,**怎么确定究竟需要多大的帧呢?**

在编译并优化代码时:一个函数就是一个最小的编译单元。

在这个函数里,编译器得知道要用到哪些寄存器、栈上要放哪些局部变量,而这些都要在编译时确定。所以编译器就需要明确每个局部变量的大小,以便于预留空间。

Q2:在实际工作中,为什么要避免把大量的数据分配在栈上呢?

主要考虑到调用栈的大小,避免栈溢出(stack overflow)。 一旦当前程序的调用栈超出了「系统允许的最大栈空间」,无法创建新的帧,来运行下一个要执行的函数,就会发生栈溢出,这时程序会被系统终止,产生崩溃信息。


  1. 栈是程序运行的基础。每当一个函数被调用时,一块连续的内存就会在栈顶被分配出来,这块内存被称为帧(frame)

  2. 栈是自顶向下增长的,一个程序的调用栈最底部,除去入口帧(entry frame),就是main() 函数对应的帧,而随着 main()函数一层层调用,栈会一层层扩展;调用结束,栈又会一层层回溯,把内存释放回去。

在调用的过程中,一个新的帧会分配足够的空间存储寄存器的上下文。在函数里使用到的通用寄存器会在栈保存一个副本,当这个函数调用结束,通过副本,可以恢复出原本的寄存器的上下文,就像什么都没有经历一样。此外,函数所需要使用到的局部变量,也都会在帧分配的时候被预留出来。

  1. 编译器在编译一个函数时,需要知道要用到哪些寄存器、栈上要放哪些局部变量,这些都要在编译时确定。所以编译器就需要明确每个局部变量的大小,以便预留空间。

  2. 栈上的内存在函数调用结束之后,所使用的帧被回收,相关变量对应的内存也都被回收待用。所以栈上内存的生命周期是不受开发者控制的,并且局限在当前调用栈。

  3. 栈上的内存分配是非常高效的。只需要改动栈指针(stack pointer),就可以预留相应的空间;把栈指针改动回来,预留的空间又会被释放掉。预留和释放只是动动寄存器,不涉及额外计算、不涉及系统调用,因而效率很高。

3. 堆

当需要使用动态大小的内存时,只能使用堆, 常见的如:可变长度的数组、列表、哈希表、字典,它们都分配在堆上。

  1. 堆上分配内存时,一般都会预留一些空间,这是最佳实践。

  2. 除了动态大小的内存需要被分配到堆上,动态生命周期的内存也需要分配到堆上。

栈上的内存在函数调用结束之后,所使用的帧被回收,相关变量对应的内存也都被回收待用。所以栈上内存的生命周期是不受开发者控制的,并且局限在当前调用栈。

  1. 堆上分配出来的每一块内存需要显式地释放,这就使堆上内存有更加灵活的生命周期,可以在不同调用栈之间共享数据

  2. 堆越界(heap out of bounds), 是第一大内存安全问题。

  3. 使用已释放内存(use after free), 如果堆上内存被释放,但栈上指向堆上内存的相应指针没有被清空,就有可能发生使用已释放内存(use after free)的情况,程序轻则崩溃,重则隐含安全隐患。根据微软安全反应中心(MSRC)的研究,这是第二大内存安全问题。

GC, ARC

  1. 以 Java 为首的一系列编程语言,采用了追踪式垃圾回收(Tracing GC)的方法,来自动管理堆内存。这种方式通过定期标记(mark)找出不再被引用的对象,然后将其清理(sweep)掉,来自动管理内存,减轻开发者的负担。

  2. ObjC 和 Swift 则走了另一条路:自动引用计数(Automatic Reference Counting)。在编译时,它为每个函数插入 retain/release 语句来自动维护堆上对象的引用计数,当引用计数为零的时候,release 语句就释放对象。

  3. 从效率上来说,GC 在内存分配和释放上无需额外操作,而 ARC 添加了大量的额外代码处理引用计数,所以 GC 效率更高,吞吐量(throughput)更大。

  4. GC 释放内存的时机是不确定的,释放时引发的 STW(Stop The World),也会导致代码执行的延迟(latency)不确定。所以一般携带 GC 的编程语言,不适于做嵌入式系统或者实时系统。当然,Erlang VM是个例外, 它把 GC 的粒度下放到每个 process,最大程度解决了 STW 的问题。

我们使用 Android 手机偶尔感觉卡顿,而 iOS 手机却运行丝滑,大多是这个原因。而且做后端服务时,API 或者服务响应时间的 p99(99th percentile)也会受到 GC STW 的影响而表现不佳。

错误处理

  1. 错误处理作为代码的一个分支,会占到代码量的 30% 甚至更多。在实际工程中,函数频繁嵌套的时候,整个过程会变得非常复杂,一旦处理不好就会引入缺陷。常见的问题是系统出错了,但抛出的错误并没有得到处理,导致程序在后续的运行中崩溃。

  2. 很多语言并没有强制开发者一定要处理错误,Rust 使用Result<T, E>类型来保证错误的类型安全,还强制开发者必须处理这个类型返回的值,避免开发者丢弃错误。

最佳实践

  1. 在Rust里,可以使用 Trait 做接口设计、使用泛型做编译期多态、使用 Trait Object 做运行时多态。用好 Traid 和泛型,可以非常高效地解决复杂的问题。

  2. unsafe rust,是把 Rust 编译器在编译器做的严格检查退步成为 C++ 的样子,由开发者自己为其所撰写的代码的正确性做担保。

理解

类型注解

  1. 类型是「静态分析」的一种形式。

  2. 可以把「类型」认为是编译器添加到程序中的值的一个标签(tag)。

变量

  1. 函数参数也是变量

  2. 函数参数与let声明的变量的区别:函数参数必须显式的声明类型,编译器不会推断

  3. Rust 中没有像JavaScript或Python中的「truthy」或「falsy」值的概念。

as

  1. 将大整型整数使用as 转换成更小类型的整数时,rust 将会执行“截断(truncation)”

struct

  1. 方法 VS 函数两个关键的区别:
  • 方法必须定义在 impl 块内部
  • 方法可以使用self作为第一个参数,self 是一个关键字,表示调用该方法的结构体的实例

self

  1. 如果 方法使用self 作为第一个参数,它可以使用方法调用语法进行调用:
               
  • 1
  • 2
  • 3
// Method call syntax: <instance>.<method_name>(<parameters>) let is_open = ticket.is_open(); COPY

静态方法

  1. 如果一个方法没有使用self作为第一个参数,它就是静态方法
               
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
struct Configuration { version: u32, active: bool } impl Configuration { fn default() -> Configuration { Configuration { version: 0, active: false } } } COPY
  1. 只能通过「函数调用语法(function call syntax)」调用静态方法
               
  • 1
let default_config = Configuration::default(); COPY

要点

  1. Pascal 之父,图灵奖得主尼古拉斯·沃斯(Niklaus Wirth)有一个著名的公式:算法 + 数据结构 = 程序。

  2. FFI,是 Rust 和其它语言互通操作的桥梁。掌握好 FFI,你就可以用 Rust 为你的 Python/JavaScript/Elixir/Swift 等主力语言在关键路径上提供更高的性能,也能很方便地引入 Rust 生态中特定的库。

  3. 编译器在编译并优化代码的时候,一个函数就是一个最小的编译单元。

  4. 编译器在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。

  5. 堆上内存分配会使用 libc 提供的 malloc() 函数,其内部会请求操作系统的系统调用,来分配内存。系统调用的代价是昂贵的,所以要避免频繁地 malloc()。

  6. 访问野指针,导致堆越界。堆越界是第一大内存安全问题。

  7. 如果堆上内存被释放,但栈上指向堆上内存的相应指针没有被清空,就有可能发生使用已释放内存(use after free)的情况, 程序轻则崩溃,重则隐含安全隐患。这是第二大内存安全问题。

  8. 存入栈上的值,它的大小在编译期就需要确定。 栈上存储的变量生命周期在当前调用栈的作用域内,无法跨调用栈引用。

  9. 堆上可以存入大小未知或者动态伸缩的数据类型。

  10. 栈上存放的数据是静态的,固定大小,固定生命周期;堆上存放的数据是动态的,不固定大小,不固定生命周期。

  11. 每种语言都有它们各自的优劣和适用场景,谈不上谁一定取代谁。社区的形成、兴盛和衰亡是一个长久的过程。

  12. Rust 借鉴了 Hashell,有完整的类型系统,支持泛型。为了性能考虑,Rust 在处理泛型函数的时候会做 「单态化(monomorphization)」,泛型函数里每个用到的类型会编译出一份代码,这也是为什么Rust 编译速度如此缓慢。

  13. Rust 的诞生目标就是取代 C/C++,想要做出更好的系统层面的开发工具,所以在语言设计之初就要求不能有运行时。

  14. 预测:在整个编程语言的生态里,未来 Rust 会像水一样,无处不在且善利万物。

  15. 不管你未来是否使用 Rust,单单是学习Rust 的过程,就能让你成为一个更好的程序员。

  16. 在编译时,一切无法确定大小或大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。

术语

  1. 堆越界 - heap out of bounds。
  2. 垃圾回收 - garbage collection。
  3. 自动引用计数 - Automatic Reference Counting。
  4. 吞吐量 - throughput
  5. STW - Stop the World

参考资料

  1. Raulmelo(Blogs) - https://raulmelo.dev/en/til 

  2. docs.rs - https://docs.rs/ 

  3. Rust Playground - https://play.rust-lang.org/ 

持续更新......:)

评论区

写评论

登录

所以,就随便说点什么吧...

这里什么都没有,快来评论吧...