星夜的蓝天

Rust死灵书笔记(上)
Rust死灵书笔记 安全与非安全代码 unsafe关键字的两层含义: 声明代码中存在编译器无法检查的安全规范 声...
扫描右侧二维码阅读全文
12
2021/01

Rust死灵书笔记(上)

Rust死灵书笔记

安全与非安全代码

unsafe关键字的两层含义:

  • 声明代码中存在编译器无法检查的安全规范
  • 声明开发者会自觉遵守相关规范而不会去主动破坏它

标准库中的部分非安全函数:

  • slice::get_unchecked:可接受不受检查的索引值,也就是存在内存安全机制被破坏的可能性
  • mem::transmute,强制类型转换,允许随意绕过类型安全机制的限制
  • 所有指向确定大小类型(sized type)的裸指针都有offset方法,当传入的偏移量越界的时候将导致Undefined Behavior
  • 所有的FFI(Foreign Function Interface)的函数都是unsafe的,因为其他语言可以做各种操作,而Rust编译期无法检查它。

Rust1.0中,有两个非安全的trait

  • send,承诺所有的实现都可以安全地发送到(move)到另一个线程。如果一种类型的所有值的类型都实现了send,它本身会自动实现send
  • sync,承诺线程可以通过共享的引用共享它的实现。如果一种类型的所有值的类型都实现了sync,它本身会自动实现send

安全的Rust代码不能导致未定义的行为

非安全Rust能做什么

  • 解引用裸指针
  • 调用非安全函数(包括C语言函数、编译器内联函数、直接分配内存等)
  • 实现非安全trait
  • 访问或修改可变静态变量

以上操作为非安全,使用不正确会产生UB,

可能出现未定义行为的种类

  • 解引用null指针,悬垂指针,或未赋值的指针
  • 读取未初始化的内存
  • 破坏指针混淆规则
  • 创建非法的基本类型
    • 垂悬引用与null引用
    • 空的fn指针
    • 0和1以外的bool类型值
    • 未定义的枚举类型的项
    • [0x0,0xD&FF][0xE000,0x10FFFF]以外的char类型的值
    • utf-8编码的str
  • 不谨慎地调用其他语言
  • 数据竞争

通常认为下列操作是安全的

  • 死锁
  • 条件竞争
  • 内存泄漏
  • 调用析构函数失败
  • 整形值溢出
  • 终止程序
  • 删除产品数据库

非安全Rust的非本地性(稳定性依赖于其他安全代码的状态)

例:

fn index(idx:usize,arr:&[u8]) -> Option<u8> {
    if idx< arr.len() {
        unsafe {
            Some(*arr.get_unchecked(idx))
        }
    } else{
        None
    }
}

以上函数是安全和正确的,但如果把<改成<=,那么程序就将出现问题

fn index(idx:usize,arr:&[u8]) -> Option<u8> {
    if idx <= arr.len() {
        unsafe {
            Some(*arr.get_unchecked(idx))
        }
    } else{
        None
    }
}

Rust的安全机制是非模块化的,非安全的代码不一定受控于自身,有可能受控于其他安全的代码。因此非安全代码不需要也不应该信任所有安全代码。

Rust中的数据表示

repr(Rust)

首先,每种类型都有一个数据对齐方式。对齐属性决定了类型的储存地址必须是n的整倍数,并且类型大小也要是对齐属性的整倍数,x86u64f64上都是按照32位对齐的。

一种类型的大小都是它对齐属性的整倍数,这保证了这种类型的值在数组中的偏移量都是其类型尺寸的整数倍,可以按照偏移量进行索引。

动态尺寸类型的大小和对齐可能无法静态获取

Rust有如下几种复合类型:

  • 结构体
  • 元祖
  • 数组
  • 枚举

如果枚举类型的变量没有关联数据,它就被称为无成员枚举。

struct A{
    a:u8,
    b:u32,
    c:u16,
}

在对齐属性和类型尺寸相同的平台上,这个结构体会按照32位对齐。

struct A{
    a:u8,
    _pad1:[u8,3],
    b:u32,
    c:u16,
    _pad2:[u8,2],
}

内存优化原则要求不同的泛型可以有不同的成员顺序。

struct Foo<T,U> {
    count:u16,
    data1:T,
    data2:U,
}

rust保证以上类型的每个实例在Rust视图下的数据布局完全相同,但不能保证他们在内存空间的数据布局相同(不能保证有一样的数据填充和成员顺序)

枚举类型更为复杂:

enum Foo{
    A(u32),
    B(u64),
    C(u8),
}

布局:

struct FooRepr {
    data:u64,//根据tag的不同,可能为u64,u32或u8
    tag:u8,//0=A,1=B,2=C
}

size_of::<Option<&T>>() == size_of::<&T>()

以上等式成立,属于Rust的"null指针优化"

如果一个枚举类型只包含一个单值变量(如None,没有携带其他数据)和一个(级联)的非null指针变量(比如&T),那么tag其实是不需要的,因为单值变量完全可以用null指针表示,这也是Option的表示方法。

动态尺寸类型(DST,Dynamically Sized Type)

对于无法知道确切大小的类型,Rust会提供一个胖指针,他包括指针本身和一些其他额外的信息。

Rust提供了两种主要的DST

  • trait对象,trait对象表示实现了某种指定trait的类型,具体的类型被擦除了,取而代之的是运行期的一个虚函数表(胖指针)
  • slice,简单来说就是一个连续储存结构的视图

对于结构体,可以在最后的位置保存一个DST,这样结构体本身也会变成一个DST。

struct Foo{
    info:u32,
    data:[u8],
}

零尺寸类型(ZST,Zero Sized Type)

Rust实际允许一种类型不占用内存空间:

struct Foo;//没有成员=没有尺寸
struct Baz{
    foo:Foo,
    qux:(),
    baz:[u8;0],
}

rust认为所有对ZST的操作可以被视为无操作(no-op),因此在编写好map<key,value>,后,set<key>=map<key,()>,

安全代码不需要考虑ZST,但非安全代码需要考虑ZST带来的影响,例如,标准内存分配器在需要分配空间大小为0的内存空间时可能返回nullptr,很难区分究竟是这种情况还是内存不足。

空类型

例:

enum Void{} //没有变量=空类型,无法实例化

创建指向空类型的裸指针实际上是合法的,但对它的解引用是一个未定义行为。

可选的数据表达方法

repr(c)

这是最重要的一种repr,目的是为了和C语言保持一致,数据的顺序、大小、对齐方式都和C/C++中一模一样,所有你需要通过FFI交互的类型都应该有repr(c),

  • 尽管C语言不支持大小为0的类型,但是ZST的尺寸仍然是0,而且他也与C++中的空类型有着明显的不同,C++的空类型还是要占用一个字节的空间。
  • DST的指针(胖指针),元祖,和带有成员变量的枚举都是C中没有的,因此也不是FFI中类型安全的。
  • 如果T是一个FFI安全的非空指针,那么Option<T>可以保证和T拥有相同的布局和ABI,当然他也是FFI安全的。这一规则也适用于&,&mut和函数指针等所有非空的指针。
  • repr(c)中元组结构体和结构体基本相同,唯一不同的是其成员都是未命名的。
  • 对于枚举的处理和repr(u*)是相同的(见下一节),选择的类型尺寸等于平台上C的应用二进制接口(ABI)的默认枚举尺寸。注意C中枚举的数据布局是确定的,所以这确实是一种"最合理的假设",不过,当C代码编译时加了一点特殊的编译器参数时,这一点可能就不正确了。

repr(u+),repr(i*)

这两个可以指定无成员枚举的大小,如果枚举变量对应的整数值对于设定的大小越界了,将产生一个编译期错误。你可以手工设置越界元素为0以避免编译错误,不过要注意Rust是不允许一个枚举中的两个变量拥有相同的值的。

"无成员枚举"的意思是枚举的每一个变量里都不关联数据,不指定repr(u+)repr(i*)的无成员枚举仍然是一个Rust的合法原生类型,他们都没有固定的ABI表示方法。给他们指定repr使其有了固定的类型大小,方便在ABI中使用

Rust中所有的成员的枚举都没有确定的ABI表示方式,即使关联的数据只是PhantomData或者ZST

PhantomData 不消耗存储空间,它只是模拟了某种类型的数据,以方便静态分析

https://learnku.com/docs/nomicon/2018/310-phantom-data/4721

因此如果需要使用PhantomData,建议使用Unique<T>指针

repr(packed)

强制Rust不填充空数据,各个类型的数据紧密排列。这样有助于提升内存的使用效率,但很可能会导致其他的副作用

在大部分平台都强烈与要求数据对齐的情况下,这意味着加载未对齐的数据会很低效(X86),甚至是错误的(在一些ARM芯片上)

不建议使用。

所有权与生命周期

let mut data = vec![1,2,3];
let x = &data[0];
data.push(4);
//push方法可能导致data内部的储存位置重新分配,因此是内存不安全的
println!("{}",x);

以上无法编译

struct V;

impl V {
    fn share(&self){}
    fn mas(&mut self) -> &Self {&*self}
}

fn main() {
    let mut t = 15;
    let mut foo = V;
    let _load = foo.mas();
    foo.share();
    println!("ok");
}

以上正常编译通过。

引用

有两种引用的类型:

  • 共享引用:&
  • 可变指针:&mut

他们遵守以下的规则:

  • 引用的生命周期不能超过被引用的内容
  • 可变引用不能存在别名(alias)

别名

fn compute(input: &u32,output: &mut u32){
    if *input > 10 {
        *output = 1;
    }
    if *input > 5{
        *output *= 2;
    }
}

可能的优化

fn compute2(input: &u32, output: &mut u32) {
    let cached_input = *input;
    if cached_input > 10 {
        *output = 1;
    } else if cached_input > 5 {
        *output *= 2;
    }
}

事实上,对于除Rust以外的其他所有语言,这个优化都是有错误的,原因是:

这种优化方式的前提是不存在别名,例如两个参数可能会重合。

非Rust情况(未优化):

  • 输入两个参数的地址是一样的。假定值都是15
  • 进行input检查,大于10让output指向的值变为1
  • 由于两个地址一样,input也被改变
  • 第二个检查失败
  • 答案是1

非Rust情况(进行优化):

  • 输入两个参数的地址是一样的。假定值都是15
  • 缓存输入值
  • 进行缓存检查,大于10让output指向的值变为1
  • 由于两个地址一样,input也被改变
  • 第二个缓存检查仍然通过
  • 答案是2

这样子会发现两个情况不一样

Rust 1.50 nightly,如果传入两个相同的地址,无法通过编译。

高阶trait边界(HRTB)

太复杂没看懂

where for <'a>

子类型和变性

其实就是OOP中的继承,但是是在声明周期里的

'static是所有类型的子类型

'big:'small表示big活的比small长,因此bigsmall的子类型(反直觉)

变性

Rust存在三种变性

  • 如果TU的子类型,F<T>也是F<U>的子类型,那么F对于T是协变的
  • 如果当TU的子类型时,F<T>也是F<U>的子类型,那么F对于T是逆变的
  • 其他情况(及子类型之间没有关系),则F对于T是不变的

一般来说记住协变性就好

  • &'a T对于'aT是协变的
  • &'a mut T对于'a是协变的,对于T是不变的
  • fn(T) -> U对于T是逆变的,对于U是协变的
  • Box,Vec以及所有的集合类型对于他们保存的类型都是协变的。
  • UnsafeCell<T>,Cell<T>,RefCell<T>,Mutex<T>和其他内部可变类型对于T都是不变的
use std::cell::Cell;

struct Foo<'a, 'b, A: 'a, B: 'b, C, D, E, F, G, H, In, Out, Mixed> {
    a: &'a A,     // 对于'a和A协变
    b: &'b mut B, // 对于'b协变,对于B不变

    c: *const C,  // 对于C协变
    d: *mut D,    // 对于D不变

    e: E,         // 对于E协变
    f: Vec<F>,    // 对于F协变
    g: Cell<G>,   // 对于G不变

    h1: H,        // 对于H本该是可变的,但是……
    h2: Cell<H>,  // 其实对H是不变的,发生变性冲突的都是不变的

    i: fn(In) -> Out,       // 对于In逆变,对于Out协变

    k1: fn(Mixed) -> usize, // 对于Mix本该是逆变的,但是……
    k2: Mixed,              // 其实对Mixed是不变的,发生变性冲突的都是不变的
}

Drop检查

struct Inspector<'a>(&'a u8);

fn main() {
    let (inspector, days);
    days = Box::new(1);
    inspector = Inspector(&days);
}

这段程序是正确且可以正常编译的。days并不严格地比inspector存活得更长,但这没什么关系。只要inspector还存活着,days就一定也活着。

struct Inspector<'a>(&'a u8);

impl<'a> Drop for Inspector<'a> {
    fn drop(&mut self) {
        println!("再过{}天我就退休了!", self.0);
    }
}

fn main() {
    let (inspector, days);
    days = Box::new(1);
    inspector = Inspector(&days);
    // 如果days碰巧先被销毁了
    // 那么当销毁Inspector的时候,它会读取被释放的内存
}

只有泛型需要考虑这个问题

一个安全地实现Drop的类型,它的泛型参数生命周期必须严格地长于它本身

例如,下面的Inspector的这一变体就不会访问借用的数据:

struct Inspector<'a>(&'a u8, &'static str);

impl<'a> Drop for Inspector<'a> {
    fn drop(&mut self) {
        println!("Inspector(_, {}) knows when *not* to inspect.", self.1);
    }
}

fn main() {
    let (inspector, days);
    days = Box::nex(1);
    inspector = Inspector(&days, "gadget);
    // 假设days碰巧先被销毁。
    // 可当Inspector被销毁时,它的析构函数也不会访问借用的days。
}

但是,借用检查器在分析main函数的时候会拒绝上面两段代码,并指出days存活得不够长。

因此,drop检查器强制要求一个值借用的所有数据的生命周期必须严格长于值本身。

PhantomData(幽灵数据)

在编写非安全代码时,我们常常遇见这种情况:类型或生命周期逻辑上与一个结构体关联起来了,但是却不属于结构体的任何一个成员。这种情况对于生命周期尤为常见。比如,&'a [T]Iter大概是这么定义的:

struct Iter<'a, T: 'a> {
    ptr: *const T,
    end: *const T,
}

我们使用一个特殊的标志类型PhantomData做到这一点。PhantomData不消耗存储空间,它只是模拟了某种类型的数据,以方便静态分析。这么做比显式地告诉类型系统你需要的变性更不容易出错,而且还能提供drop检查需要的信息。

Iter逻辑上包含一系列&'a T,所以我们用PhantomData这样去模拟它:

use std::marker;

struct Iter<'a, T: 'a> {
    ptr: *const T,
    end: *const T,
    _marker: marker::PhantomData<&'a T>,
}

这种情况很常见,以至于标准库为它自己创造了一个叫Unique<T>的组件,它可以:

  • 封装一个*const T处理变性
  • 包含一个PhantomData<T>
  • 自动实现Send/Sync,模拟和包含T时一样的行为
  • 将指针标记为NonZero以便空指针优化

分解借用

可变引用的Mutex属性在处理复合类型时能力非常有限。借用检查器只能理解一些简单的东西,而且极易失败。他对结构体还算是充分了解,知道结构体的成员可能被分别借用。所以这段代码现在可以正常工作:

struct Foo {
    a: i32,
    b: i32,
    c: i32,
}

let mut x = Foo {a: 0, b: 0, c: 0};
let a = &mut x.a;
let b = &mut x.b;
let c = &x.c;
*b += 1;
let c2 = &x.c;
*a += 10;
println!("{} {} {} {}", a, b, c, c2);

但是,借用检查器对于数组和slice的理解却是一团浆糊,所以这段代码无法通过检查:

let mut x = [1, 2, 3];
let a = &mut x[0];
let b = &mut x[1];
println!("{} {}", a, b);

为了能“教育”借用检查器我们的所作所为是正确的,我们还是要使用非安全代码。比如,可变slice暴露了一个split_at_mut的方法,它接收一个slice然后返回两个可变slice。一个包括索引值左边所有的值,另一个包含右边所有的值。我们知道这个方法是安全的,因为两个slice没有重叠部分,也就不会出现别名问题。但是它的实现还是要涉及到非安全的内容:

fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) {
    let len = self.len();
    let ptr = self.as_mut_ptr();
    assert!(mid <= len);
    unsafe {
        (from_raw_parts_mut(ptr, mid)),
         from_raw_parts_mut(ptr.offset(mid as isize), len - mid))
    }
}

Rust能够理解你把一个可变引用安全地分解为多个部分

类型转换

除了Trait作为参数式不会触发隐式类型转换,其他基本都会产生

显式转换必须通过关键字as主动地触发:expr as Type

std::mem::transmute会进行unsafe的强制类型转换,例如将[u8;4]转换为u32,也称为变形。

这个函数会越过一切的类型检查,仅能保证大小相同,十分危险,慎用。

未初始化

Rust不允许使用未初始化的变量

非安全方式

一个特殊情况是数组。安全Rust不允许部分地初始化数组。初始化一个数组时,你可以通过let x = [val; N]为每一个位置赋予相同的值,或者是单独指定每一个成员的值let x = [val1, val2, val3]。不幸的是,这个要求太苛刻了。很多时候我们需要用增量或者动态的方式初始化数组。

非安全Rust给我们提供了一个很有力的工具以处理这一问题:mem::uninitialized。这个函数假装返回一个值,但其实它什么也没有做。我们用它来欺骗Rust我们已经初始化了一个变量了,从而可以做一些很神奇的事情,比如有条件还有增量地初始化。

接下来,我们还必须使用ptr模块。特别是它提供的三个函数,允许我们将字节码写入一块内存而不会销毁原有的变量。这些函数为:writecopycopy_nonoverlapping

  • ptr::write(ptr, val)函数接受val然后将它的值移入ptr指向的地址
  • ptr::copy(src, dest, count)函数从src处将count个T占用的字节拷贝到dest。(这个函数和memmove相同,不过要注意参数顺序是反的!)
  • ptr::copy_nonoverlapping(src, dest, count)copy的功能是一样的,不过它假设两段内存不会有重合部分,因此速度会略快一点。(这个函数和memcpy相同,不过要注意参数顺序是反的!)

很显然,如果这些函数被滥用的话,很可能导致错误或者未定义行为。它们唯一的要求就是被读写的位置必须已经分配了内存。但是,向任意位置写入任意字节很可能造成不可预测的错误。

下面的代码集中展示了它们的用法:

use std::mem;
use std::ptr;

// 数组的大小是硬编码的但是可以很方便地修改
// 不过这表示我们不能用[a, b, c]这种方式初始化数组
const SIZE: usize = 10;

let mut x: [Box<u32>; SIZE];

unsafe {
    // 欺骗Rust说x已经被初始化
    x = mem::uninitialized();
    for i in 0..SIZE {
        // 十分小心地覆盖每一个索引值而不读取它
        // 注意:异常安全性不需要考虑;Box不会panic
        ptr::write(&mut x[i], Box::new(i as u32));
    }
}

println!("{:?}", x);

需要注意,你不用担心ptr::write和实现了Drop的或者包含Drop子类型的类型之间无法和谐共处,因为Rust知道这时不会调用drop。类似的,你可以给一个只有局部初始化的结构体的成员赋值,只要那个成员不包含Drop子类型。

但是,在使用未初始化内存的时候你需要时刻小心,Rust可能会在值未完全初始化的时候就尝试销毁它们。如果一个变量有析构函数,那么变量作用域的每一个代码分支都应该在结束之前完成变量的初始化。否则会导致崩溃

基于所有权的资源管理(OBRM)的风险

OBRM(又被成为RAII:Resource Acquisition is Initialization,资源获取即初始化),在Rust中你会有很多和它打交道的机会,特别是在使用标准库的时候。

这个模式简单来说是这样的:如果要获取资源,你只要创建一个管理它的对象。如果要释放资源,你只要销毁这个对象,由对象负责为你回收资源。而所谓资源通常指的就是内存。BoxRc,以及std::collections中几乎所有的东西都是为了方便且正确地管理内存而存在的。这对于Rust尤为重要,因为我们并没有垃圾回收器帮我们管理内存。关键点就在这:Rust要掌控一切。不过我们并不是只能管理内存。差不多所有的系统资源,比如线程、文件、还有socket,都可以用到这些API。

构造函数

创建一个自定义类型的实例的方法只有一种:先命名,然后一次性初始化它的所有成员:

struct Foo {
    a: u8,
    b:u32,
    c: bool,
}

enum Bar {
    X(u32),
    Y(bool),
}

struct Unit;

let foo = Foo { a: 0, b: 1, c: false };
let bar = Bar::X(0);
let empty = Unit;

就是这样。其他的所谓创建类型实例的方式,不过是调用一些函数,而函数的底层还是要依赖于这个真正的构造函数。

虽然Rust确实有一个Default trait,它与默认构造函数很相似,但是这个trait极少被用到。这是因为变量不会被隐式初始化Default一般只有在泛型编程中才有用。而具体的类型会提供一个new静态方法来实现默认构造函数的功能。这个和其他语言中的new关键字没什么关系,也没有什么特殊的含义。它仅仅是一个明明习惯而已。

析构函数

Rust通过Drop trait提供了一个成熟的自动析构函数,包含了这个方法:

fn drop(&mut self);

这个方法给了类型一个彻底完成工作的机会。

drop执行之后,Rust会递归地销毁self的所有成员

注意,递归销毁适用于所有的结构体和枚举类型,不管它有没有实现Drop。所以,这段代码

struct Boxy<T> {
    data1: Box<T>,
    data2: Box<T>,
    info: u32,
}

在销毁的时候也会调用data1data2的析构函数,尽管这个结构体本身并没有实现Drop。这样的类型“需要Drop却不是Drop”。

如果想阻止递归销毁并且在drop过程中将self的所有权移出,通常的安全的做法是使用Option

#![feature(allocator_api, ptr_internals)]

use std::alloc::{Alloc, GlobalAlloc, Global, LayOut};
use std::ptr::{drop_in_place, Unique, NonNull};
use std::mem;

struct Box<T>{ ptr: Unique<T> }

impl<T> Drop for Box<T> {
    fn drop(&mut self) {
        unsafe {
            drop_in_place(self.ptr.as_ptr());
            let c: NonNull<T> = self.ptr.into();
            Global.dealloc(c.cast(), LayOut::new::<T>());
        }
    }
}

struct SuperBox<T> { my_box: Option<Box<T>> }//注意这里

impl<T> Drop for SuperBox<T> {
    fn drop(&mut self) {
        unsafe {
            // 回收box的内容,而不是drop它的内容
            // 需要将box设置为None,以阻止Rust销毁它
            let my_box = self.my_box.take().unwrap();
            let c: NonNull<T> = my_box.ptr.into();
            Global.dealloc(c.cast(), LayOut::new::<T>());
            mem::feorget(my_box);
        }
    }
}

泄露(资源泄露)

基于RAII的模型的Rust对于资源的处理能力在safe Rust下已经是很不错的了,但是在unsafe Rust下,还是会存在一些奇怪的地方

我们可能要给泄露一个更严格的定义:无法销毁不可达(unreachable)的值。Rust也不能避免这种泄露。事实上Rust还有一个制造泄露的函数:mem::forget。这个函数获取传给它的值,但是不调用它的析构函数。

对于代理类型,我们就要十分小心它的析构函数了。有几个类型可以访问一个对象,却不拥有对象的所有权。代理类型很少见,而需要你特别小心的类型就更稀少了。但是,我们要仔细研究一下标准库中的三个有意思的例子

  • Vec::Drain
  • Rc
  • thread::scoped::JoinGuard

Drain

drain是一个集合API,它将容器内的数据所有权移出,却不占有容器本身。我们可以声明一个Vec所有内容的所有权,然后复用分配给它的空间。它产生一个迭代器(Drain),以返回Vec的所有值。

人话就是他可以把容器里面的东西弄出来,然后原本的容器还在,不过可能需要重新排布一下。

Panic 栈展开

Rust有一个分层的错误处理体系:

  • 如果有些值可以为空,就用Option
  • 如果发生了错误,而错误可以被正常处理,就用Result
  • 如果发生了错误,但是没办法正常处理,就让线程panic
  • 如果发生了更严重的问题,中止(abort)程序

OptionResult在大多数情况下都是默认的优先选择,因为API的用户可以根据自己的考虑将它们变为panic中止panic会导致线程停止正常的执行流程、展开栈(unwind stack)、调用析构函数,整个流程和函数返回时一样。

Rust的展开方式没有试图和其他任何一种语言的展开方式相兼容。所以,从其他语言展开Rust的栈,或者从Rust展开其他语言的栈,全都属于未定义行为。你必须在进入FFI调用之前捕获所有的Panic!你可以决定具体的实现方法,但不能什么都不做。

异常(exception)安全性

虽然前面说过我们应该慎用展开,但是还是有许多的地方会Panic。

  • 如果你对None调用unwrap
  • 使用超出范围的索引值
  • 或者用0做除数
  • ……

在更广大的程序设计世界里,应对展开这件事通常被称之为“异常安全“。在Rust中,我们需要考虑两个层次的异常安全性:

  • 在非安全代码中,异常安全的下限是要保证不能违背内存安全性。我们称之为最小异常安全性。
  • 在安全代码中,异常安全性要保证程序时刻在做正确的事情。我们称之为最大异常安全性。

污染

这些类型在遇到panic的时候可能会污染(poison)自己。污染没有什么特殊的含义,它通常只是指禁止其他人正常地使用它。最明显的例子是标准库中的Mutex类型。Mutex会在它的一个MutexGuards(Mutex在获取锁的时候返回的对象)因为panic而销毁的时候污染自己,这之后所有尝试给Mutex上锁的操作都会返回Err或者Panic

并发和并行

无脑tokio就对了

数据竞争与竞争条件

安全Rust保证了不存在数据竞争。数据竞争指的是:

  • 两个或两个以上的线程并发地访问同一块内存
  • 其中一个线程做写操作
  • 其中一个线程是非同步(unsynchronized)的

数据竞争导致未定义行为,所以不可能在安全Rust中存在。大多数情况下,Rust的所有权系统就可以避免数据竞争:不可能有可变引用的别名,因此也就不可能有数据竞争。但是内部可变性把这件事弄得复杂了,这也是为什么我们要有Send和Sync(见下)。

死锁是可能出现的,例子:

Send和Sync

不是所有人都遵守可变性的原则。有一些类型允许你拥有一块内存的多个别名,同时还改变内存的值。

Rust根据SendSync这两个trait获取相关信息。

  • 如果一个类型可以安全地传递给另一个线程,这个类型是Send
  • 如果一个类型可以安全地被多个线程共享(也就是&TSend),这个类型是Sync

SendSync还是自动推导的trait。和其他的trait不同,如果一个类型完全由SendSync组成,那么这个类型本身也是SendSync。几乎所有的基本类型都是SendSync,因此你能见到的很多类型也就都是SendSync

主要的例外情况有:

  • 裸指针不是Send也不是Sync(因为它们没有安全性保证)
  • UnsafeCell不是Sync(所以CellRefCell也不是)
  • Rc不是SendSync(因为引用计数是共享且非同步的)

不是自动推导的类型也可以很容易地实现SendSync

struct MyBox(*mut u8);

unsafe impl Send for MyBox {}
unsafe impl Sync for MyBox {}

原子操作

数据访问是程序设计世界的基础,指我们对数据的普通操作

原子访问可以告诉硬件和编译器,我们的程序是多线程的。每一个操作有着严格的先后顺序

Rust暴露的原子排序方式包括:

  • 顺序一致性(SeqCst)
  • 释放(Release)
  • 获取(Acquire)
  • Relaxed

https://www.jianshu.com/p/511cde6b62a6

Rust在std::sync::atomic中预置了一部分原子操作的Ordering。基本如下:

  • Ordering::Relaxed
  • Ordering::Acquire
  • Ordering::SeqCst
  • Memory fence(内存屏障)

直观看起来,acquire保证在它之后的访问永远在它之后。可在它之前的操作却有可能被重排到它后面、类似的,release保证它之前的操作永远在它之前。但是它后面的操作可能被重排到它前面。 SeqCst它之前的一定在他之前,他之后的一定在他之后,Relaxed是最弱的,他可以被随意重排,但能保证他的操作仍然是原子的。

  • SeqCst全部固定

  • acquire前面不一定,后面固定
  • release前面固定,后面不一定(重排)

  • Relaxed随意重排
最后修改:2021 年 05 月 24 日 08 : 49 PM
如果觉得我的文章对你有用,请随意赞赏

1 条评论

  1. XCloudFance

    催更,再不更新我要死了( ̄﹃ ̄

发表评论

召唤看板娘