一、前言
多线程路径在很多语言中都是绕不开的话题,原因无它:可以提高程序执行效率。
如果不理解线程的,可以先看看下面这两篇文章:
本文重在实践,也就是如何用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对于线程也做了相当多的限制,为的就是保证多线程对于数据访问的安全。
而首先要提到的就是所有权问题,看一个例子:
上面这种情况应该是最常遇到的,也就是我想要在新开的线程中使用外部的变量。
但这样做肯定是有问题的,因为一旦你新线程内访问修改它,外面的线程也访问修改它,就会造成数据不一致的、数据竞争的问题。
而rust对此的解决方案就是直接通过move
关键字,将你新线程内使用到的变量所有权从外部移交到新线程内,这样不就只能新线程这一个线程使用了吗?
此时只有一个线程能使用变量了,也就自然不会存在所谓的数据竞争问题了:
通过在闭包之前添加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
的意思是原子引用计数,其中“原子”的意思是它本身内部计数值不存在多线程访问安全问题,因为它已经是最小执行单位了,只有执行与不执行这两种情况,因此它本身是线程安全的。
然后这里我通过Arc
的new
函数实例化一个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++中常提到的指针。
此时打印结果如下:
是不是有点神奇的感觉?明明前面我们都克隆了,怎么此时两个线程中打印的两个字符串的地址还相同呢?
地址相同,自然就意味着这两个实际上依旧还是同一个字符串了。
而这就是Arc
的解决方案,它的实现原理大概如下:
Arc
内部会保存一个计数,一般就是一个整数,它记录的是当前其管理数据的引用计数,比如这里通过new
函数构造Arc
对象并管理数据,此时引用计数就是1
。- 由于其是“原子”的,所以这个引用计数不存在多线程访问冲突问题,因此其本身是线程安全的。
clone
函数本质上只是克隆了一下Arc
对象本身,而并不会去克隆其管理的数据,仅仅只是将引用计数增加1
,而这个引用计数变量在这些管理相同数据的Arc
变量之间是共享的,但由于前面提到的Arc“原子”特性,所以即使多个Arc
变量共享该计数、并分处于不同线程,此时依旧是线程安全的。- 一旦某个线程结束,或者进一步说是某个
Arc
变量生命周期计数,那么该计数就自动减1
。 - 一旦计数减为
0
,那么该数据的内存才会被真正释放。
上面的运行逻辑可能还是比较复杂繁琐,对于只是使用来说,只需要记住两个点就行了:
- 通过一个
Arc
变量clone
而来的Arc
变量,实际上保存的都是同样的数据。 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
函数来将这个锁结构真正的锁住,一旦一个线程将其锁住了,那么其它线程就只能等待其解锁。
而这就是其实现多线程修改数据且能保证安全的原因:同一时间只能有一个线程将该锁结构上锁。
这就意味着同一时间只能只有一个线程修改、访问该数据,这自然就安全了!