11. rust多线程开发

一、前言

多线程路径在很多语言中都是绕不开的话题,原因无它:可以提高程序执行效率。

如果不理解线程的,可以先看看下面这两篇文章:

本文重在实践,也就是如何用rust开发多线程程序,以及一些注意细节,并不会再过多讨论相关的概念。

二、启动新线程

在rust中启动新线程是一件很简单的事情,下面直接用一个例子进行说明:

fn main(){
    let t=std::thread::spawn(||{
        println!("这是一个新线程");
    });
    let _ = t.join(); //等待线程执行结束
}

上面的代码很简单,就是单纯的用std中的thread中的spawn函数创建一个新线程去打印一个字符串,但由于主线程结束会自动终止其它线程的执行,所以我在后面调用了其上的join函数来等待新开的线程执行结束。

注意,符号_在rust中意思等同于“忽略”,在这里的意思就是不需要返回值,同时可以屏蔽rust编译器的警告。

spawn函数的作用就是新开一个线程并执行传递进来的函数,这里函数我写的是闭包形式,当然你也可以写一个独立的函数:

fn main() {
    let t = std::thread::spawn(new_thread_fun);
    let _ = t.join(); //等待线程执行结束
}

fn new_thread_fun() {
    println!("这是一个新线程");
}

上面这两段代码是完全等价的,不过为了少写一个函数名,大部分人一般都更乐意用闭包,所以一定要习惯这种写法,后面文章中也会对其进行详细介绍。

rust中的闭包其实和C++中的lambda表达式差不多,其它语言基本都有类似的写法,后面章节会对其做更详细的介绍。

同时spawn函数新建线程后,还会返回一个实例,该实例可以将其理解为就是我们刚才新建的那个线程。

如果我们需要等待那个线程执行结束再执行后面的代码,那就可以调用其上的join函数。

这个join函数应该还是很常见的,基本各种语言中等待线程执行结束都用的这个名字,其本意是‘加入’‘合并’,也就是合并执行流的意思,等待子线程执行流执行结束合并到主线程。

如果你将多线程运行比喻为多条河流,那么调用join函数处就是两条河流的合并位置。

三、所有权

安全是rust语言首要的任务,多线程环境又是最容易引起数据竞争问题的地方,因此rust对于线程也做了相当多的限制,为的就是保证多线程对于数据访问的安全。

而首先要提到的就是所有权问题,看一个例子:

image-20231212203107744

上面这种情况应该是最常遇到的,也就是我想要在新开的线程中使用外部的变量。

但这样做肯定是有问题的,因为一旦你新线程内访问修改它,外面的线程也访问修改它,就会造成数据不一致的、数据竞争的问题。

而rust对此的解决方案就是直接通过move关键字,将你新线程内使用到的变量所有权从外部移交到新线程内,这样不就只能新线程这一个线程使用了吗?

此时只有一个线程能使用变量了,也就自然不会存在所谓的数据竞争问题了:

image-20231212203434521

通过在闭包之前添加move关键字,就会将其所有权移交到新线程内部,因此外面自然也就无法再继续使用了。

四、共享数据

虽然上面这种移交所有权的方式确实安全了,但在很多时候却是不符合我们所希望的。

至少就我自己平时开发而言,大多数开线程都是希望其执行一些繁重耗时的任务,并将其结果保存、或者就存储到某个变量上,然后我在主线程中来取。

但这种移交所有权的方式是无法得到任何线程执行后的结果的,所以此时我们就需要让多个线程共享数据,而不是移交所有权。

最简单的方式就是使用Arc

fn main() {
    let str = "hello www.kucoding.com".to_string();
    let d = std::sync::Arc::new(str);
    let d1 = d.clone();
    let t = std::thread::spawn(move || {
        println!("{:?}", d1.as_ptr());
    });
    t.join().unwrap();
    println!("{:?}", d.as_ptr());
}

注意,对于控制多线程访问的方法、工具,官方都将其放到了std::sync中,比如这里的Arc便是。

Arc的意思是原子引用计数,其中“原子”的意思是它本身内部计数值不存在多线程访问安全问题,因为它已经是最小执行单位了,只有执行与不执行这两种情况,因此它本身是线程安全的。

然后这里我通过Arcnew函数实例化一个Arc变量,传入的参数则是需要让它管理的数据,也就是我们这里这个字符串。

紧接着关键一步来了,我通过其上的clone函数又克隆了一个Arc对象,并且在新线程中使用的也是克隆后的Arc对象。

let d1 = d.clone();
let t = std::thread::spawn(move || {
    println!("{:?}", d1.as_ptr());
});

通过前面提到的所有权移交,此时新线程使用的是克隆得到的Arc对象,而本线程使用的则是最初的Arc对象。

注意:通过Arc封装后的数据大部分情况下并不影响其原本类型的使用。

所以这里在Arc实例上调用的as_ptr函数实际上依旧是其管理的String上面的函数,目的是获取该字符串所在内存的地址,也就是C/C++中常提到的指针。

此时打印结果如下:

image-20231212210556003

是不是有点神奇的感觉?明明前面我们都克隆了,怎么此时两个线程中打印的两个字符串的地址还相同呢?

地址相同,自然就意味着这两个实际上依旧还是同一个字符串了。

而这就是Arc的解决方案,它的实现原理大概如下:

  1. Arc内部会保存一个计数,一般就是一个整数,它记录的是当前其管理数据的引用计数,比如这里通过new函数构造Arc对象并管理数据,此时引用计数就是1
  2. 由于其是“原子”的,所以这个引用计数不存在多线程访问冲突问题,因此其本身是线程安全的。
  3. clone函数本质上只是克隆了一下Arc对象本身,而并不会去克隆其管理的数据,仅仅只是将引用计数增加1,而这个引用计数变量在这些管理相同数据的Arc变量之间是共享的,但由于前面提到的Arc“原子”特性,所以即使多个Arc变量共享该计数、并分处于不同线程,此时依旧是线程安全的。
  4. 一旦某个线程结束,或者进一步说是某个Arc变量生命周期计数,那么该计数就自动减1
  5. 一旦计数减为0,那么该数据的内存才会被真正释放。

上面的运行逻辑可能还是比较复杂繁琐,对于只是使用来说,只需要记住两个点就行了:

  1. 通过一个Arc变量clone而来的Arc变量,实际上保存的都是同样的数据。
  2. Arc变量的使用方式与其管理的数据有关,大多数时候是可以直接忽略Arc本身的,比如调用方法、实际上是直接调用的其管理数据上绑定的方法。

但上面的方案仅仅只能实现多线程访问相同数据,如果此时你想在其中一个线程中修改数据,依旧是不行的。

毕竟仅仅只是通过引用计数,依旧无法保证线程中修改数据行为的安全,它只能保证访问安全。

所以想要进一步对其修改,我们还需要一个锁结构:

fn main() {
    let str = "hello www.kucoding.com".to_string();
    let m = std::sync::Mutex::new(str);
    let d = std::sync::Arc::new(m);

    let d1 = d.clone();
    let t = std::thread::spawn(move || {
        let mut m_data = d1.lock().unwrap();
        println!("{}", m_data);
        m_data.push_str(" world");
    });
    
    t.join().unwrap();
    let d_data = d.lock().unwrap();
    println!("{}", d_data);
}

这里就有点套娃了,我们通过Mutex这个锁结构来管理数据str,然后再用Arc原子计数结构来管理Mutex锁变量m

Arc的目的是让线程间可以共享读取数据,这里的目的自然也就是让同一个锁Mutex能在多个线程中存在。

然后下一步,如果我们想要修改锁中保存的数据,那就可以通过lock函数来将这个锁结构真正的锁住,一旦一个线程将其锁住了,那么其它线程就只能等待其解锁。

而这就是其实现多线程修改数据且能保证安全的原因:同一时间只能有一个线程将该锁结构上锁。

这就意味着同一时间只能只有一个线程修改、访问该数据,这自然就安全了!

作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux