# The First Rust Class

# 开篇词

rust_learning_routes

# 学习 Rust 的难点

  1. Rust 中最大的思维转换就是变量的所有权和生命周期

# 如何学好 Rust?

firstPrinciple

# 1. 精准学习

  1. 深挖一个个高大上的表层知识点,回归底层基础知识的本原,再使用类比、联想等方法,打通涉及的基础知识;然后从底层设计往表层实现,一层层构建知识体系
  2. 第一性原理:回归事物最基础的条件,将其拆分成基本要素解构分析,来探索要解决的问题。

# 2. 刻意练习

用精巧设计的例子,通过练习进一步巩固学到的知识,并且在这个过程中尝试发现学习过程中的不自知问题,让自己从“我不知道我不知道”走向“我知道我不知道”,最终能够在下一个循环中弥补知识的漏洞。

# 前置篇

# 内存

每个线程分配一个 stack,每个进程分配一个 heap。stack 是线程独占,heap 是线程共用。 stack 大小是确定的,heap 大小是动态的。

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

#

  1. 栈是自顶向下增长;
  2. 每当一个函数被调用时,一块连续的内存(帧 frame)就会在栈顶被分配出来;
  3. 一个新的帧会分配足够的空间存储寄存器的上下文;
  4. 在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。
  5. 栈上的内存在函数调用结束之后,所使用的帧被回收,相关变量对应的内存也都被回收待用。
  6. 所以栈上内存的生命周期是不受开发者控制的,并且局限在当前调用栈。
  7. 对于存入栈上的值,它的大小在编译期就需要确定。栈上存储的变量生命周期在当前调用栈的作用域内,无法跨调用栈引用。

#

  1. 堆可以存入大小未知或者动态伸缩(动态大小、动态生命周期)的数据类型。
  2. 堆上分配出来的每一块内存需要显式地释放,这就使堆上内存有更加灵活的生命周期,可以在不同的调用栈之间共享数据。
# 堆内存自动管理方式
  1. Tracing GC: tracing garbage collection; 追踪式垃圾回收
  2. ARC: Automatic Reference Counting; 自动引用计数

# 数据

# 值和类型

  1. 值是无法脱离具体的类型讨论的
# 类型
  1. 原生类型

    1. 字符、整数、浮点数、布尔值、数组(array)、元组(tuple)、指针、引用、函数、闭包
    2. 所有原生类型大小都是固定的,因此它们可以被分配到栈上。
  2. 组合类型

    1. 结构体(structure type) -- struct
    2. 标签联合(tagged union) -- enum

# 指针和引用

  1. 指针是一个持有内存地址的值,可以通过 derefence 来访问它指向的内存地址,理论上可以解引用到任意数据类型。
  2. 比正常指针携带更多信息的指针称为胖指针。

# 代码

# 函数,方法,闭包

  1. 函数也是对代码中重复行为的抽象。
  2. 面向对象的编程语言中,在类或者对象中定义的函数,被称为方法(method)。方法往往和对象的指针发生关系
  3. 闭包引用的上下文中的自由变量,会被捕获到闭包的结构中,成为闭包类型的一部分。

# 接口,虚表

  1. 作为一个抽象层,接口将使用方和实现方隔离开来,使两者不直接有依赖关系,大大提高了复用性和扩展性
  2. 在生成这个引用的时候,我们需要构建胖指针,除了指向数据本身外,还需要指向一张涵盖了这个接口所支持方法的列表。这个列表,就是我们熟知的虚表(virtual table)。
  3. 虚表一般存储在堆上 ???
  4. 虚表是每个 impl TraitA for TypeB {} 时就会编译出一份。
  5. 比如 String 的 Debug 实现, String 的 Display 实现各有一份虚表,它们在编译时就生成并放在了二进制文件中(大多是 RODATA 段中)。
  6. 所以虚表是每个 (Trait, Type) 一份。并且在编译时就生成好了

# 运行方式

# 同步,异步

# 编程范式

# 泛型编程

# 缺陷

# 学习资料

  1. rust book (opens new window)
  2. rustnomicon rust 死灵书 (opens new window)
  3. docs.rs (opens new window)
  4. 标准库文档 (opens new window)

# 基础篇

  1. Rust 是一门基于表达式(expression-based)的语言 Rust is an expression-oriented language.
  2. 语句(Statements)是执行一些操作但不返回值的指令。表达式(Expressions)计算并产生一个值

# 基本语法和基础数据类型

  1. 变量类型一般可以省略;
  2. const/static 变量必须声明类型;
  3. 函数参数的类型和返回值的类型都必须显示定义;
  4. 宏编程的主要流程就是实现若干 From 和 TryFrom

# 所有权和生命周期

核心点Rust 通过单一所有权来限制任意引用的行为

  1. Copy trait 与 Drop trait 不能共存。
  2. 所有权转移时,优先使用 copy 语义, 默认使用 move 语义。
# 所有权规则
  1. 一个值只能被一个变量所拥有,这个变量被称为所有者
  2. 一个值同一时刻只能有一个所有者
  3. 当所有者离开作用域,其拥有的值被丢弃
# Move 语义:
  1. 赋值或者传参会导致值 Move,所有权被转移,一旦所有权转移,之前的变量就不能访问。
# Copy 语义和 Clone 语义
  1. 符合 Copy 语义的类型,在你赋值或者传参时,值会自动按位拷贝。
  2. 原生类型,包括函数、不可变引用和裸指针实现了 Copy;
  3. 数组和元组,如果其内部的数据结构实现了 Copy,那么它们也实现了 Copy;
  4. 可变引用没有实现 Copy;
  5. 非固定大小的数据结构,没有实现 Copy。
  6. Copy 语义仅拷贝栈上的内存。
  7. Clone trait 是 copy 的 super trait, 深拷贝, 深拷贝得到的堆内存需用通过 Drop trait 来释放。
  8. 任何有资源需要释放(Drop trait)的数据结构,都无法实现 Copy trait
# Borrow 语义
  1. Borrow 语义通过引用语法(& 或者 &mut)来实现; 在 Rust 下,所有的引用都只是借用了“临时使用权”,它并不破坏值的单一所有权约束。
  2. 默认情况下,Rust 的借用都是只读的;
  3. Rust 所有的参数传递都是传值;
  4. 借用的生命周期及其约束: 借用不能超过(outlive)值的生存期
  5. 在一个作用域内,仅允许一个活跃的可变引用
  6. 在一个作用域内,活跃的可变引用(写)和只读引用(读)是互斥的,不能同时存在。

# 多个所有者
  1. Rust 处理很多问题的思路:编译时,处理大部分使用场景,保证安全性和效率;运行时,处理无法在编译时处理的场景,会牺牲一部分效率,提高灵活性。
  2. Arc(Atomic Reference Counter);
  3. Rc(Reference Counter): 对一个 Rc 结构进行 clone(),不会将其内部的数据复制,只会增加引用计数。Rc 是一个只读的引用计数器
  4. Box::leak(),它创建的对象,从堆内存上泄漏出去,不受栈内存控制,是一个自由的、生命周期可以大到和整个进程的生命周期一致的对象。

Box

# 内部可变性
  1. Rc<RefCell<T>>针对单线程
  2. Arc<Mutex<T>>/Arc<RwLock<T>>针对多线程环境

# 生命周期
  1. 一般来说,堆内存的生命周期,会默认和其栈内存的生命周期绑定在一起
  2. 生命周期参数,描述的是参数和参数之间、参数和返回值之间的关系,并不改变原有的生命周期。
  3. 所有引用类型的参数都有独立的生命周期 'a 、'b 等。
  4. 如果只有一个引用型输入,它的生命周期会赋给所有输出。
  5. 如果有多个引用类型的参数,其中一个是 self,那么它的生命周期会赋给所有输出。

动态、静态生命周期

# 类型系统

  1. 类型系统是一种对类型进行定义、检查和处理的工具
  2. 类型,是对值的区分,它包含了值在内存中的长度、对齐以及值可以进行的操作等信息;
  3. Rust 下的内存安全更严格:代码只能按照被允许的方法和被允许的权限,访问它被授权访问的内存;
  4. Rust 中除了 let / fn / static / const 这些定义性语句外,都是表达式,而一切表达式都有类型
  5. unit 是只有一个值的类型,它的值和类型都是 ();
  6. 即使上下文中含有类型的信息,也需要开发者为变量提供类型,比如常量和静态变量的定义;需要明确的类型声明。

原生类型: 组合类型: Rust 类型系统:

# 多态

  1. 参数多态:代码操作的类型是一个满足某些约束的参数,而非具体的类型;=> 泛型 Rust Generic
  2. 特设多态: 一般指函数的重载;包括运算符重载 => Rust Trait
  3. 子类型多态:在运行时,子类型可以被当成父类型使用。=> Rust Trait Object

# 泛型数据结构

  1. 函数,是把重复代码中的参数抽取出来;
  2. 泛型,是把重复数据结构中的参数抽取出来;

生命周期标注也是泛型的一部分

# 单态化

  1. 好处: 泛型函数的调用是静态分派(static dispatch);
  2. 缺点 1: 编译速度慢;一个泛型函数,编译器需要找到所有用到的不同类型,一个个编译;
  3. 缺点 2: 编译出的二进制代码会比较大,存在 N 份。
  4. 缺点 3: 代码以二进制分发会损失泛型的信息。单态化之后,原本的泛型信息就被丢弃了。

# trait

  1. 定义了类型使用这个接口的行为;
  2. 在 trait 中,方法可以有缺省的实现;
  3. 允许用户把错误类型延迟到 trait 实现时才决定,这种带有关联类型的 trait 比普通 trait,更加灵活,抽象度更高
  4. trait 的”继承“: trait B 在定义时可以使用 trait A 中的关联类型和方法

# Trait Object

  1. 表现为&dyn Trait 或者 Box<dyn Trait>:(动态分派(dynamic dispatch));

  2. 底层逻辑就是胖指针:数据本身+虚函数表 vtable;

  3. 如果 trait 所有的方法,返回值是 Self(trait object 产生时原来的类型会被抹去) 或者携带泛型参数(trait object 是运行时的产物),那么这个 trait 就不能产生 trait object。

  4. rust会为实现了trait object类型的trait实现,生成相应的vtable,放在可执行文件中(一般在TEXT或RODATA段)。

# Traits

  1. send/sync: 如果一个类型 T: Send,那么 T 在某个线程中的独占访问是线程安全的;如果一个类型 T: Sync,那么 T 在线程间的只读共享是安全的;

  2. Clone 是深度拷贝,栈内存和堆内存一起拷贝;

  3. Copy 是按位浅拷贝,与 Drop 互斥;

  4. 不支持 Send / Sync 的数据结构主要有:

    1. 裸指针 *const T / *mut T。它们是不安全的,所以既不是 Send 也不是 Sync。
    2. UnsafeCell 不支持 Sync。也就是说,任何使用了 Cell 或者 RefCell 的数据结构不支持 Sync。
    3. 引用计数 Rc 不支持 Send 也不支持 Sync。所以 Rc 无法跨线程。
  5. 只需要实现From<T>Into<T>会自动实现;

# 延迟绑定

  1. 从数据的角度看,[数据结构]是[具体数据]的延迟绑定,[泛型结构]是[具体数据结构]的延迟绑定;
  2. 从代码的角度看,[函数]是一组实现某个功能的[表达式]的延迟绑定,[泛型函数]是[函数]的延迟绑定;
  3. [trait] 是[行为]的延迟绑定

# 数据结构

  1. 指针是一个持有内存地址的值,可以通过解引用来访问它指向的内存地址,理论上可以解引用到任意数据类型;
  2. 引用是一个特殊的指针,它的解引用访问是受限的,只能解引用到它引用数据的类型,不能用作它用

# 智能指针:

  1. 是一个胖指针;
  2. 智能指针String 对堆上的值具有所有权,而普通胖指针&str没有所有权;
  3. 在 Rust 中,凡是需要做资源回收的数据结构,且实现了 Deref/DerefMut/Drop,都是智能指针
# Box<T>在堆上创建内存
# Cow<'a, B>提供写时克隆
# 分发手段
  1. 使用泛型参数做静态分发
  2. 使用 trait object 做动态分发
  3. 这种根据 enum 的不同状态来进行统一分发的方法是第三种分发手段,其效率是动态分发的数十倍。
# MutexGuard<T>用于数据加锁
  1. 通过 Drop trait 来确保,使用到的内存以外的资源在退出时进行释放

# 切片 Slice

  1. &[T] 只读切片,只是一个借用

  2. &mut[T] 可写的切片

  3. Box<[T]> 堆上分配的切片: 而 Box<[T]> 一旦生成就固定下来,没有 capacity,也无法增长;对数据具有所有权。

  4. Vec 可以通过 into_boxed_slice() 转换成 Box<[T]>Box<[T]> 也可以通过 into_vec() 转换回 Vec;

  5. 当我们需要在堆上创建固定大小的集合数据,且不希望自动增长,那么,可以先创建 Vec,再转换成 Box<[T]> ;

  6. Box<[T]>&[T]的区别:

    1. Box<[T]>指针指向的是堆内存数据;&[T]指针指向的数据可以是堆、栈内存数据;
    2. Box<[T]> 对数据具有所有权;&[T]只是一个借用;

# 哈希表

  1. 哈希表最核心的特点就是:巨量的可能输入和有限的哈希表容量
  2. Rust 哈希表算法的设计核心:
    1. 二次探查(quadratic probing)
    2. SIMD(单指令多数据) 查表(Single Instruction Multiple Data lookup)
  3. 解决哈希冲突机制
    1. 链地址法(chaining)
    2. 开放寻址法(open addressing)
  4. 通过 shrink_to_fit / shrink_to 释放掉不需要的内存

哈希冲突解决机制

SIMD 查表

# 错误处理的主流方法

  1. 返回值
  2. 异常处理
  3. 类型系统
    1. 在 Rust 代码中,如果你只想传播错误,不想就地处理,可以用 ? 操作符
    2. 使用 Option 和 Result 是 Rust 中处理错误的首选
    3. 立刻暴露 Panic!, catch_unwind!

# 闭包

  1. 闭包是一种匿名类型,一旦声明,就会产生一个新的类型(调用闭包时可以直接和代码对应),但这个类型无法被其它地方使用。这个类型就像一个结构体,会包含所有捕获的变量。
  2. 不带 move 时,闭包捕获的是对应自由变量的引用;
  3. 带 move 时,对应自由变量的所有权会被移动到闭包结构中
  4. 闭包的大小跟参数、局部变量都无关,只跟捕获的变量有关,闭包捕获的变量都存储在栈上。
  5. 闭包是存储在栈上(没有堆内存分配),并且除了捕获的数据外,闭包本身不包含任何额外函数指针指向闭包的代码。
  6. 闭包的调用效率和函数调用几乎一致

# 进阶篇

# 类型系统

# 泛型

  1. 架构师的工作不是作出决策,而是尽可能久地推迟决策,在现在不作出重大决策的情况下构建程序,以便以后有足够信息时再作出决策。
  2. 通过使用泛型参数,BufReader 把决策交给使用者。
  3. 泛型参数三种常见的使用场景:
    1. 使用泛型参数延迟数据结构的绑定;
    2. 使用泛型参数和 PhantomData,声明数据结构中不直接使用但在实现过程中需要用到的类型;
    3. 使用泛型参数让同一个数据结构对同一个 trait 可以拥有不同的实现。
  4. PhantomData:
    1. 被广泛用在处理,数据结构定义过程中不需要,但是在实现过程中需要的泛型参数;
    2. 在定义数据结构时,对于额外的、暂时不需要的泛型参数,用 PhantomData 来“拥有”它们,这样可以规避编译器的报错。
    3. 实际长度为零,是个 ZST(Zero-Sized Type), 类型标记。

# Trait Object

  1. 使用 Trait Object 是有额外的代价的,首先这里有一次额外的堆分配,其次动态分派会带来一定的性能损失
  2. 当在某个上下文中需要满足某个 trait 的类型,且这样的类型可能有很多,当前上下文无法确定会得到哪一个类型时,我们可以用 trait object 来统一处理行为。
  3. 和泛型参数一样,trait object 也是一种延迟绑定,它让决策可以延迟到运行时,从而得到最大的灵活性。
  4. 后果是执行效率的打折。在 Rust 里,函数或者方法的执行就是一次跳转指令,而 trait object 方法的执行还多一步,它涉及额外的内存访问,才能得到要跳转的位置再进行跳转,执行的效率要低一些。
  5. 返回/线程间传递 trait object 都免不了使用 Box 或者 Arc,会带来额外的堆分配的开销。

# 围绕trait来设计和架构系统

  1. 软件开发的整个行为,基本上可以说是不断创建和迭代接口,然后在这些接口上进行实现的过程。
  2. 用trait做桥接
  3. SOLID原则
    1. SRP:单一职责原则,是指每个模块应该只负责单一的功能,不应该让多个功能耦合在一起,而是应该将其组合在一起。
    2. OCP:开闭原则,是指软件系统应该对修改关闭,而对扩展开放。
    3. LSP:里氏替换原则,是指如果组件可替换,那么这些可替换的组件应该遵守相同的约束,或者说接口。
    4. ISP:接口隔离原则,是指使用者只需要知道他们感兴趣的方法,而不该被迫了解和使用对他们来说无用的方法或者功能。
    5. DIP:依赖反转原则,是指某些场合下底层代码应该依赖高层代码,而非高层代码去依赖底层代码。

# 网络开发

应表会传网链 物 ISO/OSI七层模型及对应协议

# Unsafe Rust

unsafe rust 场景

可以使用、也推荐使用 unsafe 的场景

  1. 实现 unsafe trait:
    1. 主要是Send / Sync 这两个 trait;
    2. 任何 trait,只要声明成 unsafe,它就是一个 unsafe trait;
    3. unsafe trait 是对 trait 的实现者的约束
    4. unsafe fn 是函数对调用者的约束,需要加 unsafe block
  2. 调用已有的 unsafe 函数:
    1. 需要加 unsafe block;
    2. 定义 unsafe 函数,在其中调用 unsafe 函数;
  3. 对裸指针做解引用
  4. 使用 FFI

不推荐的使用 unsafe 的场景

  1. 访问或者修改可变静态变量
    1. 任何需要 static mut 的地方,都可以用 AtomicXXX / Mutex / RwLock 来取代。
  2. 在宏里使用 unsafe
  3. 使用 unsafe 提升性能
    1. 而有些时候,即便你能够使用 unsafe 让局部性能达到最优,但作为一个整体看的时候,这个局部的优化可能根本没有意义。

撰写 unsafe 代码

  1. 一定要用注释声明代码的安全性

# FFI(Foreign Function Interface)

一门语言,如果能跟 C ABI(Application Binary Interface)处理好关系,那么就几乎可以和任何语言互通。

处理 FFI 的注意事项

  1. 如何处理数据结构的差异?
  2. 谁来释放内存?
  3. 如何进行错误处理?

rust 调用其他语言

Rust shim 主要做四件事情:

  1. 提供 Rust 方法、trait 方法等公开接口的独立函数。注意 C 是不支持泛型的,所以对于泛型函数,需要提供具体的用于某个类型的 shim 函数。
  2. 所有要暴露给 C 的独立函数,都要声明成 #[no_mangle],不做函数名称的改写。
  3. 数据结构需要处理成和 C 兼容的结构。
  4. 要使用 catch_unwind 把所有可能产生 panic! 的代码包裹起来。

FFI 的其它方式

  1. 通过网络:REST API、gRPC
  2. protobuf 来序列化 / 反序列化要传递的数据

# 并发篇

并发concurrent:轮流处理,多队列一件事;并行parallel:同时执行,多队列多件事;

并发vs并行

并发和并行都是对“多任务”处理的描述,其中并发是轮流处理,而并行是同时处理。

在处理并发的过程中,难点并不在于如何创建多个线程来分配工作,在于如何在这些并发的任务中进行同步

我们来看并发状态下几种常见的工作模式:

  1. 自由竞争模式、
  2. map/reduce 模式、
  3. DAG 模式:

# Atomic

Atomic 是一切并发同步的基础

# Mutex

用来解决这种读写互斥问题的基本工具

# RwLock

# Semaphore

# Condvar

典型场景是生产者 - 消费者模式

在实践中,Condvar 往往和 Mutex 一起使用:Mutex 用于保证条件在读写时互斥,Condvar 用于控制线程的等待和唤醒

# Channel

Channel 把锁封装在了队列写入和读取的小块区域内,然后把读者和写者完全分离

channels

channels2

# Actor

actor 是一种有栈协程。每个 actor,有自己的一个独立的、轻量级的调用栈,以及一个用来接受消息的消息队列(mailbox 或者 message queue),外界跟 actor 打交道的唯一手段就是,给它发送消息。

  1. Atomic 在处理简单的原生类型时非常有用,如果你可以通过 AtomicXXX 结构进行同步,那么它们是最好的选择。
  2. 当你的数据结构无法简单通过 AtomicXXX 进行同步,但你又的确需要在多个线程中共享数据,那么 Mutex / RwLock 可以是一种选择。不过,你需要考虑锁的粒度,粒度太大的 Mutex / RwLock 效率很低。
  3. 如果你有 N 份资源可以供多个并发任务竞争使用,那么,Semaphore 是一个很好的选择。比如你要做一个 DB 连接池。
  4. 当你需要在并发任务中通知、协作时,Condvar 提供了最基本的通知机制,而 Channel 把这个通知机制进一步广泛扩展开,于是你可以用 Condvar 进行点对点的同步,用 Channel 做一对多、多对一、多对多的同步。

如果说在做整个后端的系统架构时,我们着眼的是:有哪些服务、服务和服务之间如何通讯、数据如何流动、服务和服务间如何同步;那么在做某一个服务的架构时,着眼的是有哪些功能性的线程(异步任务)、它们之间的接口是什么样子、数据如何流动、如何同步。

# Future

# Reactor Pattern(反应器模式)

Reactor Pattern 包含三部分:

  1. tasks:待处理任务
  2. Executor: 调度执行tasks
  3. Reactor: 维护事件队列

reactor pattern

使用 Future 的注意事项

  1. 我们要避免在异步任务中处理大量计算密集型的工作;
  2. 在使用 Mutex 等同步原语时,要注意标准库的 MutexGuard 无法跨越 .await,所以,此时要使用对异步友好的 Mutex,如 tokio::sync::Mutex;
  3. 如果要在线程和异步任务间同步,可以使用 channel。

# 状态机

# Pin

Pin 是为了让某个数据结构无法合法地移动,而 Unpin 则相当于声明数据结构是可以移动的,它的作用类似于 Send / Sync,通过类型约束来告诉编译器哪些行为是合法的、哪些不是。

# 自引用数据结构

# Generator

  1. rust中的生成器被实现为状态机。计算链的内存占用是由单个步骤所需的最大占用定义的

# async/await

# Stream trait

# 实战篇

# 生产环境

# 数据处理

# 软件架构

渐进式的架构设计,从 MVP 的需求中寻找架构的核心要素,构建一个原始但完整的结构(primitive whole),然后围绕着核心要素演进

分层结构、流水线结构和插件结构

# 高级篇

#

syn/quote

Last Updated: 2/8/2023, 2:30:06 AM