Rust 第六天—Rust进阶2

168 阅读17分钟

今天依然是总结一些进阶的Rust内容:

  • 闭包
  • 迭代器
  • 智能指针
  • 多线程相关

1. 闭包

在函数式编程中闭包是一个常见的操作,渐渐地越来越多的语言支持闭包.闭包和匿名函数往往是纠缠在一起的,二者相互关联却略有差异.

匿名函数: 一个没有名称的函数,往往就是一个函数指针

闭包: 如果匿名函数捕获了作用域中的变量,那么就成为了一个闭包.(闭包本质还是匿名函数)

在Rust中使用匿名函数或者闭包还是比较简单的,类似于c++中的匿名函数[](param){}语法,Rust中则更为简单|param|{}

let sum=|x,y|{x+y};
println!("{:?}",sum(1,3));
​
​
let x:i32=5;
let sum=|y| x+y;
println!("{:?}",sum(10));

image-20230719095449004

这个demo中上面就是匿名函数,而下面就是闭包,区别就在于有没有获取作用域中的变量.需要注意的是,闭包捕获作用域中的值时会去存储这些值,也就是说消耗了额外的内存空间,会有一定的性能影响.

三种Fn特征

  • FnOnce: 转移捕获变量的所有权
  • FnMut: 可变借用捕获变量
  • Fn: 不可变借用捕获变量

有的时候,捕获变量的生命周期是小于闭包生命周期的.这个时候就需要将所有权利用move进行转移.一个很简单的例子,闭包实现对输入加某个值,但是这个值是在闭包调用的作用域外部设置的.

pub fn get_add_x(x:i32) -> impl Fn(i32)->i32 {
    let add=|y|{x+y};
    return add;
}
​
//main
let add=closure::get_add_x(5);
println!("{:?}",add(2));

这里设置一个需要增加的值x,然后闭包实现对输入的值加上x.如果直接编译会报错

image-20230719104146393

提示也很清晰,闭包在x的生命周期之外还有借用,那么就需要转移所有权,因此只需要加上move关键字就可以顺利调用.

总的来说,上面三种Fn特征含义是闭包如何使用捕获到的变量,这样就表示实现了哪种Fn特征.

2. 迭代器

如果熟悉Python的迭代器,这部分理解起来会非常轻松.实现基本是一致的,都是通过next()方法实现对可迭代对象的遍历.

如果实现了Iterator特征那么就成为了迭代器,当然也可以通过实现IntoIterator特征从而通过into_iter()等方法变成一个迭代器.

那就开始实现一个迭代器看看

struct MyIter {
    start:i32,
    end:i32,
    current:i32,
}
​
impl MyIter{
    fn new(start:i32,end:i32)->Self{
        MyIter{
            start,
            end,
            current:start,
        }
    }
}
​
impl Iterator for MyIter{
    type Item = i32;
​
    fn next(&mut self) -> Option<Self::Item> {
        if self.current<=self.end{
            let v=self.current;
            self.current+=1;
            Some(v)
        }else{
            None
        }
    }
}
​
//main
let mut iter =MyIter::new(-1,2);
println!("{:?}",iter.next());
println!("{:?}",iter.next());
println!("{:?}",iter.next());
println!("{:?}",iter.next());
println!("{:?}",iter.next());

image-20230719194121207

当然,迭代器在性能方面也是相当高效的,是Rust的零成本抽象之一.

3. 智能指针

和C++一样,Rust中也有智能指针的概念.其实在之前介绍Rust基础部分的时候,就提到过Rust的堆栈以及内存安全相关问题.当时也说了,在栈中存储的变量必须是大小固定的,其余的都在堆中进行存储.那后续介绍到长度可变的类型如vector,string时,不知道有没有人考虑过底层实现,其实这些都是基于智能指针封装的结构体.可以看看源码部分

image-20230719201011326

image-20230719201117767

很明显可以看出通过智能指针将数据存储到堆中,如果有兴趣的话可以看看这个实现Vec跟着实现一下vector.

回到今天的主题,下面来看看Rust中常见的几种智能指针:

  1. Box<T> 将值分配到堆中
  2. Rc<T> 引用计数,允许多个所有权
  3. Ref<T>|RefMut<T> 允许在运行时检查借用规则而不是编译期

3.1 Box

Box是最简单的智能指针,作用是将值存储在堆上.通常智能指针都实现了DerefDrop特征,代表着可以通过解引用获取堆上地址的值以及离开作用域时对智能指针实现释放.

通过Box我们可以手动将数据分配到堆上,这就避免了一些大块数据分配到栈中转移所有权时数据拷贝的消耗.当然除了这个最基本的功能之外,我们还可以利用分配到堆中这个特性实现一些有趣的功能.

对于一些动态大小的类型数据,对于Rust在编译期需要确定数据大小的特性来说,就需要借用某些指针来实现,通常将这类动态大小的数据类型称为DST(动态大小类型) .在Lisp中常见的Cons List,通过递归调用cons函数产生嵌套的cons list,看起来应该长这个样子

An infinite Cons list

很明显,这种递归调用的数据类型无法在编译期确定其大小.因此,我们可以用Box封装List实现在栈中存储的只有指针,这样编译期的数据大小就确定了.更改后数据长这个样子

A finite Cons list

#[derive(Debug)]
enum List{
    Cons(i32,Box<List>),
    Nil,
}
​
​
fn main() {
    let list=Cons(1,Box::new(Cons(1,Box::new(Nil))));
    println!("{:?}",list);
}

image-20230720153314105

此外,Box还有一个特殊的关联函数Box::leak,它可以强制变量从内存中泄漏.乍一听貌似这个是个不好的函数,但是它确实有适合自己的使用场景.当我们需要在运行时产生一个‘static生命周期的变量,这个时候就需要利用Box::leak使它能在程序运行时一直能够被使用;并且比较起其他实现方法它的性能应该是最高的,这些还是得益于Box这个简单的封装.

3.2 Rc

引用计数,这个词在某些带有GC的语言中常常会听到,通过记录一个数据被引用的次数来决定是否应该对这个数据进行清理释放.而在Rust中由于所有权机制的存在,对一个数据如果需要多个所有者的场景,如多个线程同时操作某个对象且无法确定哪个线程最后执行结束时,这种情况下所有权机制就会使得编码变得十分困难.不过好在Rust提供了Rc引用计数这个智能指针,可以使得我们轻松的实现数据的共享.

fn main() {
    let str_a = Rc::new(String::from("aaa"));
    {
        let str_b = Rc::clone(&str_a);
        {
            let str_c =Rc::clone(&str_a);
            println!("count_a={}",Rc::strong_count(&str_a));
        }
        println!("count_a={}",Rc::strong_count(&str_a));    
    }
    println!("count_a={}",Rc::strong_count(&str_a));
}

image-20230720182251942

这个demo展示了我们利用Rc创建对象,并且多次复制对象(仅仅是复制智能指针不复制底层数据,底层数据是共享)同时使引用计数的个数加1;在生命周期结束,即离开作用域后相应的智能指针会调用Drop释放资源并且将引用计数减1.

不过,我们使用Rc仅仅是共享数据,无法对数据进行更改.记得之前的规则吗?可以存在多个不可变借用,或者至多一个可变借用,因此Rc仍然是不可变借用,无法通过它来修改值,并且它仅仅适用于单线程而不适用于多线程中(Rc没有实现Send特征,并且引用计数器不是原子化的计数,因此是线程不安全的).

3.3 Arc

相较于Rc,Arc则是线程安全版本,完整名称为Atomic Rc.看到名字应该就体会到了,Arc相较于Rc利用原子操作实现了线程安全.但是相应的,功能强大的前提就是开销会增加,因此为了保证高效性能,最佳的方式就是多线程之间使用Arc而线程内部使用Rc.

fn main() {
    let s=Arc::new(String::from("test!!!"));
    for i in 1..10{
        let tmp=Arc::clone(&s);
        let handle=thread::spawn(move||{
            thread::sleep(i*time::Duration::from_millis(10));
            println!("tmp={},count={}",tmp,Arc::strong_count(&tmp));
        });
    }
    thread::sleep(5*time::Duration::from_secs(1));
}

image-20230720184541223

为了展示多线程中引用计数的正确性,我们给了每个线程创建之前不同的休眠时间.别忘了,我们在主线程中还有一个最开始的s会占用一次引用计数!

3.4 改变引用内部值

上面提到了,我们可以使用Rc或者Arc引用计数来对同一个数据实现共享.但是引用计数仍然是不可变借用,还是无法实现我们一开始想在不同线程对数据进行更改的想法.还好,Rust提供了CellRefCell来实现内部可变性.

3.4.1 Cell

Cell<T>用于T实现了Copy特征的情况,可以通过get()方法来取值,set()方法来设置新的值.

fn main() {
    let a=Cell::new(1);
    let b=a.get();
    a.set(23);
    let c=a.get();
    println!("b={},c={}",b,c);
}

image-20230721201258306

Cell的底层其实是一个封装UnsafeCell的结构体,实质上还是对其中的value字段进行操作,因此在性能上和普通的可变借用几乎一致,并没有额外的性能消耗

image-20230721202144493

3.4.2 RefCell

Cell有一个局限性,那就是值必须实现Copy.可往往很多情况下,我们的数据并没有实现这个特征,因此这个时候就需要RefCell来封装从而实现可变值.

常见的组合就是Rc+RefCell一起使用,实现一个数据拥有多个所有者,并且能实现可变.

fn main() {
    let a=Rc::new(RefCell::new(111));
    let b=Rc::clone(&a);
    let c= Rc::clone(&a);
    *c.borrow_mut()+=111;
    println!("a={:?},b={:?},c={:?}",a,b,c);
}

image-20230722084350101

Rc是共享底层数据,因此在任意一个所有者上进行数据操作,其余所有拥有者所指的内存数据均会发生变化.看起来我们貌似实现了之前的想法,可以对数据进行共享与更改,不过这仍然是单线程中的.如果在多线程中依然不能使用RefCell来进行数据操作,因为它不是线程安全的操作

那到底我们在多线程中需要怎么实现安全的数据共享与更改呢?这部分内容就留到下一节继续.

4. 多线程相关

4.1 并发与并行

这部分内容之前在go专栏中就介绍过,这里就提一下go与Rust之间不同的地方.首先我们现在大部分电脑的cpu都不再是单核处理器,也就是cpu核心数大于1.假设目前硬件的核心数为N,线程队列个数为M,那么就称为M:N的线程模型.

  • Golang(Goroutine): 程序内部的M个协程映射于操作系统上的N个线程,属于M:N线程模型
  • Rust: 标准库使用1:1的线程模型,即一个程序线程对应一个系统线程

Rust的标准库从性能角度出发,实现1:1线程模型,可以得到最小的运行时实现;否则还需要对程序线程进行管理,导致运行时存在额外的性能消耗(如上下文切换).如果需要M:N的线程模型实现,可以使用Tokio.关于Tokio的使用,会单独来讲.

4.2 多线程使用

在介绍Arc的小节里已经使用过多线程,那也算是一个最基本创建线程的demo,这一小节就来具体的介绍一下多线程的使用.

4.2.1 线程的创建

我们可以通过thread::spawn来创建一个新的线程

fn main() {
    let handle=thread::spawn(||{
       for i in 1..10{
           println!("number {} from spawn thread",i);
           thread::sleep(Duration::from_millis(1));
       }
    });
​
​
    for i in 1..5{
        println!("number {} from main thread",i);
        thread::sleep(Duration::from_millis(1));
    }
    handle.join().unwrap();
}

image-20230724093013622

这里我们使用thread::spawn创建了一个新的线程,并且线程内调用一个匿名函数来打印1-9.在主线程内打印1-5,同时为了防止主线程打印结束自动退出程序而子线程没有执行结束的情况,我们添加join()让主线程等待所有子线程结束后再退出.

4.2.2 数据所有权的转移

有的时候我们需要在子线程中调用主线程中的变量,但是由于线程创建执行不存在固定先后顺序,因此无法保证变量的生命周期;当然这一点我们在之前就已经涉及到了,还记得之前讲的move来转移所有权吗.

fn main() {
    let test_vector=vec![1,2,3,4];
    let handle = thread::spawn(move ||{
        println!("test vector is {:?}",test_vector);
    });
    handle.join().unwrap();
}
​

image-20230724093925609

4.2.3 线程嵌套

套娃谁又不爱呢?我们可以创建一个线程,然后在这个线程内部再创建一个线程,看看会发生什么?

fn main() {
    let new_thread = thread::spawn(move || {
        // 再创建一个线程
        thread::spawn(move || {
            loop {
                println!("I am a new thread.");
                thread::sleep(Duration::from_millis(10));
            }
        })
    });
​
    new_thread.join().unwrap();
    println!("Child thread is finish!");
​
    thread::sleep(Duration::from_millis(100));
}

image-20230724094929055

new_thread创建子线程后,new_thread就已经结束了,但是内部的子线程还并未结束直到主线程结束它才结束.

4.2.4 线程屏障

搞深度学习的应该都熟悉这个词Barrier,经常在多卡训练时为了等待不同进程的数据同步就会利用到这个;在Rust的多线程这里也是一样,我们可以利用Barrier实现线程同步.

fn main() {
    let num_threads=3;
    let mut handles = Vec::with_capacity(num_threads);
    let barrier = Arc::new(Barrier::new(num_threads));
    for _ in 0..num_threads{
        let b=Arc::clone(&barrier);
        handles.push(thread::spawn(move ||{
            println!("waiting before");
            b.wait();
            println!("waiting finish");
        }));
    }
​
    for handle in handles{
        handle.join().unwrap();
    }
}

image-20230724100341017

这个例子几乎包含了多线程使用的所有常见操作,首先创建一个vec用来存放多个线程,然后每个线程内部通过move转移所有权,从而拥有一个Arc封装的Barrier,最后遍历得到所有线程并join.

其实,看到Barrier::new()的参数需要线程数,那么差不多也能猜到它的实现方式.wait()方法应该是类似于一个计数器,直到wait()调用次数等于之前new()时的线程数,这个时候才能取消等待.当然既然是多线程中的计数,那么肯定得加锁.除此之外,既然涉及到等待与取消等待,还和计数条件有关,那么自然想到条件变量实现.再去看看源码,几乎跟我们的想法一致.

image-20230724101324661

4.2.5 条件变量

上一小节既然已经看到了标准库中使用条件变量实现了信号量控制线程,那么这一节就来具体看看条件变量的使用方法.其实如果熟悉c++的话,这一部分基本写法是一致的仅有个别的语法存在差异而以.

fn main() {
    let flag=false;
    let pair=Arc::new((Mutex::new(flag),Condvar::new()));
    let pair_clone=pair.clone();
    thread::spawn(move||{
        let (lock,cv)=&*pair_clone;
        println!("change flag to true");
        *lock.lock().unwrap()=true;
        cv.notify_all();
    });
​
    let (lock,cv)=&*pair;
    let mut flag=lock.lock().unwrap();
    while !*flag{
        flag=cv.wait(flag).unwrap();
    }
    println!("flag changed!");
}

image-20230724104819977

这个demo很简单,主线程被条件变量阻塞等待直到我们子线程更改好条件.还记得之前提过无论是Cell还是RefCell都不能在多线程情况下更改数据内部值吗?那这个例子给我们启发,是不是可以利用Mutex来实现呢?

fn main() {
    let num_threads=5;
    let mut x=Arc::new(Mutex::new(1));
    let mut handles=Vec::with_capacity(num_threads);
​
    for i in 0..num_threads{
        let x_clone=x.clone();
        handles.push(thread::spawn(move ||{
            let mut x_tmp= x_clone.lock().unwrap();
            println!("x={}",*x_tmp);
            *x_tmp+=1;
        }));
    }
​
    for handle in handles{
        handle.join().unwrap();
    }
    println!("x={}",x.lock().unwrap());
}

image-20230724110302350

终于,我们利用ArcMutex解决了我们一直希望在多线程中安全更改数据的难题.

4.2.6 仅执行一次

对于一些初始化操作,我们只希望执行一次就好,而不是每个线程都去执行一遍,这个时候就可以使用call_once().

static ONCE: Once =Once::new();
fn main() {
    let handle1 =thread::spawn(move||{
        ONCE.call_once(||{
            println!("init sth.")
        })
    });
    let handle2 =thread::spawn(move||{
        ONCE.call_once(||{
            println!("init sth.")
        })
    });
​
    handle1.join().unwrap();
    handle2.join().unwrap();
}

image-20230724110911790

仅仅打印出一个init,也就代表着不论多少个线程,call_once可以保证只执行一次.

4.3 channel

在上面我们利用ArcMutex实现了线程间的数据传输,但是熟悉Go的话应该会被内置的channel所吸引,有了channel可以减少锁的使用并且仍然能保证数据安全与数据同步.与Go中channel类似的实现在Rust中也提供了std::sync::mpsc这个多发送单接收的通道模型.

fn main() {
    let (tx,rx) = mpsc::channel();
    let mut x=1;
    thread::spawn(move||{
        x+=1;
        tx.send(x).unwrap();
    });
​
    println!("x={}",rx.recv().unwrap());
}

image-20230724113007433

这里,我们在子线程内将x加1然后传给接收者.如果数据在堆上,所有权会直接转移给接收者,而发送者部分不再拥有所有权.

既然是多发送者模型,当然可以实现多个线程发送数据

fn main() {
    let (tx,rx) = mpsc::channel();
    let num_threads=5;
    let mut handles =Vec::with_capacity(num_threads);
    for i in 0..num_threads{
        let tx_tmp=tx.clone();
        handles.push(thread::spawn(move||{
            tx_tmp.send(i).unwrap();
        }));
    }
​
    for handle in handles{
        handle.join().unwrap();
    }
    drop(tx);
    for receive in rx{
        println!("receive: {}",receive);
    }
}

image-20230724114502423

在这里我们开了5个线程去发送数据,最后遍历接收者得到传输过来的数据.需要注意的是,我们在遍历之前添加了一行drop(tx)来关闭发送者.虽然Rust中通道是自动关闭的,但是由于主线程中的发送者一直没有drop也就导致接收者一直企图接收,最终造成主线程的阻塞.因此,必须手动去drop掉主线程中的发送者.

通常我们用mpsc::channel()创建的都是异步通道,即无论接收者是否正在接收都不会影响发送者的行为;当然我们也可以用mpsc::sync_channel(n)创建一个同步通道,只有在消息被接收者接收后发送者才能发送,其中n代表缓冲区大小.当n=0时为同步通道,发送第n+1条消息的时候会触发阻塞.为了防止现实中消息消费不及时导致的大量内存占用,往往设置一个带有缓冲区的同步通道.

4.4 锁与信号量

4.4.1 互斥锁

关于互斥锁的使用其实在之前的demo中已经涉及过,这里稍微再总结一下.通常我们会通过Mutex::new()来创建一个互斥锁,然后lock()方法进行上锁;这里是尝试获取锁,如果没有获取到锁会一直阻塞当前线程.当获得到的锁离开作用域后会自动drop,因此也不需要我们手动去释放锁,这点很类似于c++中的lock_guard,不过Rust标准库中貌似没有熟悉的unique_lock这种灵活的锁机制实现.

除了上面的互斥锁,还有适合读多写少场景的读写锁

fn main() {
    let lock=RwLock::new(1);
    {
        let read1=lock.read().unwrap();
        let read2=lock.read().unwrap();
        println!("read1={},read2={}",read1,read2);
    }
​
    {
        let mut write=lock.write().unwrap();
        *write+=1;
    }
    println!("lock={}",lock.read().unwrap());
}

image-20230724181101392

Rust中读写锁的规则与其他语言的读写锁一致,可以多读,但是多写以及读写都是互斥的,读写同时存在会发生死锁.安全起见的话,可以尝试用带有try的方法进行操作.

4.4.2 信号量

信号量在多线程中可以很好的控制最大任务数量来防止服务器压力过大,当任务数超过设定的阈值时,剩下的任务需要等待当前任务队列中的任务完成,并且信号量减少到阈值以下时才能继续执行,否则就会等待阻塞.

目前在标准库中的信号量已经不推荐使用了,更多是使用Tokio中的信号量.因此,我打算把这部分放到后面Tokio的部分再详细写,这里就当作是个铺垫.

4.5 Send与Sync

SendSync是Rust中并发安全很重要的两个特征,其中

  • Send: 可以在线程之间安全的转移所有权
  • Sync: 可以在线程之间安全的共享

这里有一个隐藏的依赖关系,如果能够实现安全的转移所有权,那么这个对象一定是可以安全共享的,代码化来解释就是如果&TSend那么T一定是Sync.

在Rust中几乎所有类型都默认实现了这两个特征,如果自定义的结构体内部所有成员都实现了这两个特征,那么这个结构体也自动实现了这两个特征.但是有一些类型并没有实现这两个特征,比如之前说到的Rc,Cell,RefCell无法用于多线程就是因为没有实现这两个特征,除此之外,裸指针也是没有实现这两个特征的.

我们可以手动实现这两个特征,但是这是不安全的,实在需要时也必须用unsafe包裹.