最强肉坦:RUST多线程

Rust最近非常火,作为coder要早学早享受。本篇作为该博客第一篇学习Rust语言的文章,将通过一个在其他语言都比较常见的例子作为线索,引出Rust的一些重要理念或者说特性。这些特性都是令人心驰神往的,相信我,当你读到最后,一定会有同样的感觉(除非你是天选之子,从未受过语言的苦 ^ ^ )。

本文题目之所以使用“最强肉坦”来形容Rust,就是为了凸显该语言的一种防御能力,是让人很放心的存在。

关键字:Rust,变量,所有权,不可变性,无畏并发,闭包,多线程,智能指针

问题:多线程修改共享变量

这是几乎每种编程语言都会遇到的实现场景,通过对比Java和Rust的实现与运行表现,我们可以清晰地看出Rust的不同或者说Rust的良苦用心,以及为了实现这一切所带来的语言特性。我们首先来看Java的实现方法。

java实现方法

package com.evswards.multihandle;

import java.util.ArrayList;
import java.util.List;

public class TestJavaMulti001 {
    public static void main(String[] args) throws InterruptedException {
        class Point {
            int x;
            int y;

            public Point(int x, int y) {
                this.x = x;
                this.y = y;
            }
        }
        Point p = new Point(1, 2);
        List<Thread> handles = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(this + ": " + p.x);
                    p.x++;
                }
            });
            handles.add(t);
            t.start();
        }
        for (Thread t : handles) {
            t.join();
        }
        System.out.println("total: " + p.x);
    }
}

下面对以上代码进行简要的说明:

1、直接看main方法体,首先定义了一个类Point,是一个坐标点,它有x和y两个成员都是int类型,并且有一个x和y共同参与的构造方法。

2、接下来,通过Point构造方法我创建了一个坐标点的实例p,它的值是(1,2)。

3、然后是一个Thread的列表,用来保存多线程实例,作用是可以保证主线程对其的一个等待,而不是主线程在多线程执行完以前就执行完了。

4、一个10次的循环,循环体中是创建一个线程,首先打印p的x坐标,然后对其执行自增操作。然后将当前线程实例加入前面定义的Thread列表,并启动该线程执行。

5、对多线程进行一个join的操作,用来保证主线程对其的一个等待。

6、最后打印出p的x坐标的值。

接下来,我们看一下它的输出:

/Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home/bin/java ...

com.evswards.multihandle.TestJavaMulti001$1@2586b45a: 1

com.evswards.multihandle.TestJavaMulti001$1@20cc06fb: 1

com.evswards.multihandle.TestJavaMulti001$1@3f1d0da9: 1

com.evswards.multihandle.TestJavaMulti001$1@28817d5f: 1

com.evswards.multihandle.TestJavaMulti001$1@2f7aa756: 3

com.evswards.multihandle.TestJavaMulti001$1@25d849fd: 6

com.evswards.multihandle.TestJavaMulti001$1@4df93c85: 7

com.evswards.multihandle.TestJavaMulti001$1@2e14a730: 8

com.evswards.multihandle.TestJavaMulti001$1@26795870: 8

com.evswards.multihandle.TestJavaMulti001$1@54359f35: 10

total: 11

Process finished with exit code 0

可以看出多线程执行的一个随机性(前几个线程在执行时的速度最快,当他们各自达到x坐标的时候,基本上还没有被修改太多次,因此有很多的1被打印出来),然后在join方法的作用下,最终total的值是我们预想的11,即1被自增了10次的正确结果。

这段Java实现的多线程修改共享变量的代码就介绍到这里,暂且先不去谈它的一个健壮性以及代码编写的合理性,但至少可以证明,这个问题对于Java的编写来讲,不是特别麻烦,只要稍微懂一些JavaSE的知识就可以写出来。下面,仿照这段Java语言对于这个问题的写法,我们来写Rust,看看它是如何处理的以及最终的实现版本是什么样子。

Rust的实现方法

1、Rust helloworld

我们这篇Rust的文章是一个入门学习材料,因此要从头说起。但我不准备介绍Rust的下载和IDE的方式,这部分内容可以直接参考https://doc.rust-lang.org/book/ch01-00-getting-started.html。另外,作为Rust的包管理工具,Cargo是一个重要知识点,但我也不准备在此仔细研究,作为入门材料,只要知道如何使用即可。那么让我们直接到IDE里面完成Hello_World的编写并运行成功。

fn main() {
    println!("Hello World!")
}

在IDE默认生成的rust工程中,main.rs文件是入口源码,其中的main方法是入口方法。

语法:用fn声明一个函数;打印函数是println!(),它是静态内部方法可以直接调用。

执行后打印的内容:

/Users/liuwenbin24/.cargo/bin/cargo run --color=always --package prosecutor_core_rt --bin prosecutor_core_rt
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running target/debug/prosecutor_core_rt
Hello World!

Process finished with exit code 0

这里正确打印出来了字符串"Hello World!“,但它的前后有很多debug日志,这些内容并不是经常有用,我们在此约定:后面出现的打印结果中,不再粘贴无用的debug日志,而一些警告、错误的日志会被粘贴出来的进行分析。因为这些警告和错误日志恰恰是rust编译器为程序员提供的最为精华的部分。

2、结构体struct

结构体struct是rust的一个复合数据类型。结构体的使用与其他语言类似,关键字是struct。相当于Java的Class。Java的坐标点类的写法:

class Point {
    int x;
    int y;
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

2.1 整型

前面学会了struct可以替换Class,但是Point的x和y坐标的整型数据结构该如何在rust中表现呢?

rust的整型关键字可分为有符号和无符号两种:

1、i8, i16, i32, i64, i128 属于有符号,可以表示正负数,i后面的数字代表空间占据固定的二进制位数。

2、u8, u16, u32, u64, u128 属于无符号,只能表示正数,所以同等二进制位数下,无符号可表示的正数的最大值是有符号的两倍。同样的,u后面的数字代表空间占据固定的二进制位数。

rust在定义变量的时候,正好是与java反过来的,即变量名放前面,数据类型放后面。例如 num: i32

那么到这里,我们就能够使用Rust写出Point的结构体了,代码如下:

struct Point {
    x: i32,
    y: i32,
}

2.2 变量

下面,我们希望在main方法中创建Point的实例并完成初始化赋值。这里就要使用到变量。

rust的变量的修饰符是let,这与java的数据类型不同,let仅有声明变量的作用,至于数据类型要在变量名的后面,正如2.1讲解的整型的例子那样。

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{},{}", p.x, p.y)
}

我们在main方法中定义了变量p,给它赋值了Point的实例,该实例直接初始化了x=1, y=2。

这里有一个不同之处在于,java的main方法是由静态修饰符static修饰的,因此若Point类写在main方法的外面,main方法体还要使用Point的话,就需要显式指定Point类也未static静态类。然而,rust是没有这个限制的,struct写在哪里都可以,这里我们与java做点区分,还是放在main函数的外面比较合理。

下面,看一下打印输出结果:

1,2

3、可变变量

2.2讲过了变量,为什么可变变量要使用二级标题单独讲?因为这是rust一个比较重要的防御性设计。我们现在回顾一下本文的问题啊,其中有关键字是要修改变量。

rust的一个变量若想在后续被修改,必须显式地被关键字mut所修饰,例如: let mut num: i32 = 10 ;

因此,接着前面的rust代码,我们若想修改p的坐标值,需要mut声明。

fn main() {
    let mut p = Point { x: 1, y: 2 };
    p.x += 1;
    println!("{},{}", p.x, p.y)
}

打印结果:

2,2

4、借用变量

本文的问题在java的实现过程中需要将p传到Thread类的Runnable接口的run方法中,这在java中是无需多虑的,然而在rust中,变量在作用域之间的传递会出现问题。我们仍旧继续在前面的rust代码基础上去编写。

fn f2(_a: Point) {}

fn main() {
    let mut p = Point { x: 1, y: 2 };
    p.x += 1;
    f2(p);
    println!("{},{}", p.x, p.y);
}

我们增加了一个f2函数,参数是一个Point类型的内部变量a。同时在第6行增加了对于f2函数的调用,这段代码看上去没有执行什么有效逻辑,但是运行一下会报错如下:

error[E0382]: borrow of moved value: p
--> src/main.rs:14:28
|
11 | let mut p = Point { x: 1, y: 2 };
| ----- move occurs because p has type Point, which does not implement the Copy trait
12 | p.x += 1;
13 | f2(p);
| - value moved here
14 | println!("{},{}", p.x, p.y);
| ^^^ value borrowed here after move
|
= note: this error originates in the macro $crate::format_args_nl (in Nightly builds, run with -Z macro-backtrace for more info)

前面说到了rust程序执行时的报错日志是非常精华的部分,让程序员仿佛永远在一个耐心的大神旁边编程。这里的结果中最重要的一句是:error[E0382]: borrow of moved value: p,就是说这个p首先它已经被moved了,然后不能被借出。

4.1 rust的基础类型

rust有四种基础数据类型:整型(见2.1)、浮点型(f32\f64)、布尔(true/false)、字符(char,默认占4个字节)

4.2 指针复习

与C语言的指针概念一致,基础数据类型不需要指针,它的变量直接指向内存中的值。而引用类型是需要指针的,引用类型的变量指向一个指针,然后指针再指向内存中实际的值,所以指针是一个内存地址。由于引用类型的变量不像基础类型的那样在创建的时候就确定了分配内存的长度,所以有了指针。指针会指向该变量在内存中存储的首个字节单元的地址,例如0x69。然后引用类型的变量同时还默认包含了size或者length这种记录长度的属性,一个变量的数据在内存中的存储是连续的,因此通过首个内存单元地址和长度这两个属性,就可以从内存中获取到完整的数据。

4.3 野指针

C和C++语言往往会出现野指针的情况,即实际内存存储单元已经被销毁或修改,而原来的指针却仍旧存在,这时候该指针就被称为野指针。野指针一般是由于多个指针指向了同一个内存地址,而内存地址在销毁或者变化时也会同时销毁掉相关的指针,但它不能保证全部销毁掉,一旦形成漏网之鱼,指针就进化为野指针潜藏在你的系统中准备作妖。野指针在不被调用的时候不会出问题,系统稳定运行,但一旦被触发,就会报错,报错的情况依据最新内存的数据情况而定,所以报错日志并不可靠,再加上复杂的代码逻辑,调试起来那是相当麻烦。

4.4 引用所有权

为避免野指针的情况发生,如果由我来设计的话,也会想得到有两个方面来解决:

第一、要保证在指针与内存单元的一对一关系,如果非得有一对多的情况,要严加管理,至少要显式声明,写入逻辑明确指针的数量。

第二、在第一步的基础上,当内存单元发生变化,指针需要被销毁时,一定要确保所有关联的指针全都被销毁,杜绝漏网之鱼。

俗话说得好“想起来容易,做起来难”,但rust语言就真的是实现了。这里就引出了rust的引用所有权的设定。所有权就是对指针的所有权,每个内存单元只能由一个变量的指针所指向,如果其他变量的指针也要指向这个内存单元,则必须原来的“主人“要将所有权出借。

Rust变量出借关键字&,用来形容一个变量的引用,我们将创建一个引用的行为称为 借用borrowing

继续写代码:

fn f2(_a: &Point) {}

fn main() {
    let mut p = Point { x: 1, y: 2 };
    p.x += 1;
    f2(&p);
    println!("{},{}", p.x, p.y);
}

我们在第6行给参数p增加了变量引用&,同时重新定义了f2函数的参数类型为Point。main函数的变量p被借用给了f2函数作为入参,当f2函数执行完毕,就会还给main函数。这样修改完以后,执行成功了。

接着来研究rust所有权问题。我们知道不同编程语言对于内存管理的策略有所不同。

1、java有自己大名鼎鼎的GC,即垃圾回收器,程序员可以对内存的情况完全不管。所以java程序员的操作系统知识远不如其他编程语言从业者来的扎实,这是一方面的劣势。另一方面,GC也不是完全可靠的,java系统在运行过程中,至少有30%的错误来自于内存层面的问题,对于强于业务代码而弱于系统知识的java程序员来说,这种问题无疑是棘手的。

2、C++看上去灵活许多,可以自己申请内存、分配内存,以及手动执行内存销毁等。但是,程序员拥有了越高的权利意味着他承担的责任也就越大。造成的劣势首先是程序员的操作系统知识要很过硬,这就使得C++的门槛要远高于java。接着,为了避免内存错误,程序员需要在安全方面编写大量的代码对内存进行管理,这无疑是耗时耗力的。而前面讲到的野指针问题,往往也是在这个阶段出的问题,因为你永远无法对自己编写的C++内存管理代码完全自信。

那么,rust语言在这方面就考虑了很多,毕竟作为后来者,它能够立足的根本就是吸取教训,开拓进取嘛。因此,所有权机制就诞生了,它就是Rust语言对于自身内存管理的一个别称。

Rust所有权的规则:

  • 程序中每一个值都归属于一个变量,称作该变量拥有此值的所有权。
  • 值的所有权在同一时间只能归属于一个变量,当吧这个值赋予一个新变量时,新变量获得所有权,旧的变量失去该值的所有权,无法再对其访问和使用。
  • 每个变量只能在自己的作用域中使用,程序执行完,该变量即作废,变量的值被自动垃圾回收。

所有权转移的三种情况

  • 一个变量赋值给另一个变量。
  • 一个变量作为参数传入一个函数,其所有权转移给了形参。
  • 一个函数把某变量作为返回值返回时,所有权转移给接收返回值的变量。

5、Vec集合

接着使用Rust来解决我们的目标问题。对应前面java的实现,接下来要搞定的是:

List<Thread> handles = new ArrayList<>();

这行java的常用的列表集合的写法,在rust中该如何实现?

rust有一个集合容器,关键字Vec。

这里有几点要说明:

1、Vec在rust中的功能和实现原理与java的List很相似,可以新增元素,都是长度可变的,当顺序排列到内存末尾不够使用时,会把整个Vector的内容复制一份到一个新的内存足够的连续的内存空间上,所以在长度变化的时候,会有一个内存空间的切换,也就是说Vec的内存空间地址不是一成不变的。

2、Vec只能存储同一个数据类型的数据,可以在初始化的时候使用泛型来指定。

Vec的写法:

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut p = Point { x: 1, y: 2 };
    p.x += 1;
    let mut v: Vec<Point> = Vec::new();
    v.push(p);
    let a = v.get(0).expect("没找到");
    println!("{},{}", a.x, a.y);
}

这段rust代码执行成功,输出2,2,下面来分析一波:

1、先要夸一波,rust编译器真的聪明,几乎可以不去参考官方文档,只依靠编译器的报错信息和指导即可以完成编程。所以学习rust最简单的办法就是多写。

2、回到源码,首先学习一下Vec的初始化:let mut v: Vec<Point> = Vec::new();泛型中指定了集合中存储的元素类型是我们创建的结构体Point类型,等号右边是Vec类对于new()方法的调用,注意是使用"::"两个冒号来代表”谁的方法“。这里的new函数相当于是类的构造器,但它是静态的,可以直接调用。

3、为Vec插入元素,即v.push(p);这个用法看起来差不多,只是要注意方法名不是add,而是push,不过也没关系,编码的时候都会有方法提示 (=_=!)

4、读取Vec的元素内容,注意与指定泛型的默认转换。let a = v.get(0).expect("没找到");注意这里的a默认已经是&Point类型了,也就是我们在使用Vec的时候不必单独考虑引用出借的问题。expect("")方法就是万一找不到,用这个提示来代替。这种错误属于数据错误,但是rust也会提前想到让我们自己去定义错误日志,从而快速排查。

5、最后,就是验证打印成功。

下面,我们换一种写法,在集合创建的时候就把Point实例初始化进去,我们知道这种场景在java中是很容易实现的,那么我们来看rust是如何编写。以下仅粘贴不同的部分。

let v = vec![p];

这代码直接把p初始化到了集合中,然后赋值给变量v,目前v就是一个Vec集合结构,它只有一个元素,就是Point类型的实例p。

5.1 宏

我在编写上面的rust代码时,把vec!写成了Vec!。程序执行时报错,我才发现宏的概念,因为报错的时候显示error: cannot find macro "Vec" in this scope。这里的macro,我们如果在使用Excel的时候可能会注意到。由此可得到几个结论:

1、宏的关键字是小写加半角叹号,就像vec!那样。

2、宏的参数可以是括号修饰的入参(),也可以是方括号修饰的数组[]。

3、前面常用到的println!()也是宏,而不是函数。从这里才会注意到这一点,注意区分。

对于宏的解释:

1、它是指Rust中的一系列功能,可以定义声明宏和过程宏。

2、通过关键字macro_rules! 声明宏,我们也可以编写宏并使用到它。

3、宏与函数的区别,宏是一种为写其他代码而写代码的方式,即元编程。宏会以展开的方式来生成比手写更多的代码。

4、宏在编译器翻译代码时被展开,而方法是在运行时被调用。

5、宏的定义会比函数更复杂。

下面是vec!宏的定义源码:

#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
\(( temp_vec.push(\)x);
)*
temp_vec
}
};
}

6、循环

接着去看java的实现,我们刚刚解决了java List对应的rust写法问题,继续往下看是一段for循环,那么rust中是如何实现的呢?

rust有loop、while、for三种循环,其中while和for循环与java的使用方法差不多。而独有的loop循环是一个死循环,没有限定条件,要配合一个break关键字进行使用,另外loop也可以有返回值被接收。

下面写一个10次的循环:

for i in 0..10 {
    println!("{}",i);
    p.x += 1;
}

1、通过第2行的打印,我发现0..10代表的是10次,而1..10代表的是9次。所以这个范围应该是[0,10),终止值是闭区间,也即不包含终止值。

2、做完1的实验,我们可以把第2行的打印代码删除,那么这个变量i就没有人使用了,这时候也可以用单下划线_代替,代表被丢弃的名称,因为没人用。那么最终的代码就变为:

for _ in 0..10 {
    p.x += 1;
}

7、线程

继续看前面java的源码,刚刚我们解决了rust循环的语句,下面要进入到循环体中来了。循环体中首先遇到的就是对线程的使用。在这一章,我们可以查看到官方文档中对应的是16章,名字叫Fearless Concurrency

”无畏并发“!

有点霸气,其实前面学习到的rust的所有权、出借、可变变量等所有这些特性,都是为了线程安全而设计的。因此到了线程这一趴, rust真可以大声喊一句,”我是无畏并发!“。有一种”该我上场表演了“的感觉。

下面看一下rust是如何创建线程的。

7.1 包引用

就像C++那样,rust的包引用很相似:

use std::thread;

这样就把包引用到当前类中来了。要注意的是这里引用的包都是在cargo的管理下,都能够找得到的。当然了,它并不是针对thread这种在std标准库中就有的包,而是第三方包或者我们自己开发的包这种比较难找的包,需要手动加载。

7.2 闭包

Rust 的 闭包closures)是可以保存进变量或作为参数传递给其他函数的匿名函数。

闭包的定义以一对竖线(|)开始,在竖线中指定闭包的参数。如果有多于一个参数,可以使用逗号分隔,比如 |param1, param2|

let closure = ||{
  println!("{}",p.x);
};
closure();

双竖线中间没参数,后面直接跟大括号修饰的闭包方法体,是打印p的x坐标。别忘了在外面要主动调用一下该方法,即第4行的作用。

闭包的使用要注意变量的作用域,这里要结合rust的所有权概念一起使用。下面我们尝试在闭包中增加参数,如下:

let closure = |Point|{
  println!("{}",p.x);
};
closure(&p);

这里我们给闭包增加了一个参数,是Point类型。然后在第4行调用该函数的时候,传入了p的引用。这里是从main函数作用域下的变量p借用给了闭包closure作为它的入参使用,当闭包执行完毕,还需要还回。

move语义

前面学习到了变量借用的机制,那么如果函数间调用,借走变量的函数执行完毕要归还的时候发现被借的函数早已执行完毕内存被销毁掉了,这时候怎么办?从所有权机制上来分析,变量在这个时间点,它的所有权只有且必须是借走变量的函数所拥有,那么这种情况就不再使用借用机制,而是转移机制。关键字move。

let closure = move |Point|{
  println!("{}",p.x);
};
closure(p);

回到刚才的闭包代码,在闭包的双竖线之前增加关键字move,同时去掉第4行调用闭包函数时参数的引用&。这样执行也是成功的,但是p的所有权永久地转移给了闭包里。

7.3 spawn

Rust中创建一个新线程,可以通过thread::spawn函数并传递一个闭包,在其中包含线程要运行的方法体。

spawn这个单词不常用,它是产卵的意思,其实就是一个new,但是作者不甘寂寞,对我们来说也算是加强印象。

use std::thread;

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut p = Point { x: 1, y: 2 };
    let mut handles = vec![];
    p.x += 1;
    for _ in 0..10 {
        let handle = thread::spawn(|| {
            println!("hello");
        });
        handles.push(handle);
    }
    println!("{},{}", p.x, p.y);
}

以上代码实现了创造10个线程的过程,但是线程内部的执行逻辑却比较简单,并不涉及变量的内容,输出的结果:

hello
hello
hello
hello
hello
hello
hello
hello
2,2
hello
hello

可以看到输出的结果中,2,2的结果并不在最后,说明main线程是在我们spawn出来的线程之前就执行完了,因此,我们要加上join方法的调用,用来保证主函数的最后执行。

for handle in handles{
    handle.join().expect("TODO: panic message");
}
println!("{},{}", p.x, p.y);

我们在main函数最后的打印代码之前增加了对所有spawn出来的线程的遍历,并把他们逐一join到主线程中。这样一来,无论执行多少次,都能保证变量p的x和y坐标的打印永远在最后一行。

hello
hello
hello
hello
hello
hello
hello
hello
hello
hello
2,2

8、一个错误版本

到此,看上去为了解决本文最上面的那个问题,我们的rust知识储备已足够。下面我们尝试完成一个版本的实现,它看上去与java的实现很相似。

use std::thread;

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut p = Point { x: 1, y: 2 };
    let mut handles = vec![];
    for i in 0..10 {
        let handle = thread::spawn(move || {
            println!("{},{}", i, p.x);
            p.x += 1;
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().expect("TODO: panic message");
    }
    println!("{},{}", p.x, p.y);
}

首先我们定义了结构体Point,然后在main函数中,我们设定了可变变量p并赋值Point类型分别x=1,y=2。然后我们创建了一个空集合。接下来是一个for循环,然后是线程的创建,这里用到了闭包。闭包首先设定变量的所有权被转移,然后是一个空参闭包,内容首先打印线程的标号和转移进来的变量p的x坐标的值,然后对x的坐标值加1。最后将当前线程添加到空集合中。接着,遍历集合,保证每个子线程都join到主线程之前执行。最后,打印p的x和y坐标。这段代码与最上面的java实现逻辑很类似,只是语言语法不同。下面来看一下执行结果:

3,1
8,1
6,1
1,1
4,1
5,1
2,1
0,1
9,1
7,1
1,2

这个结果明显是不对的,首先,每个线程进来读到的p的x坐标值都是1,然后最后main函数打印的p的值也没有改变。这说明我们的多线程改变共享变量的目的失败了。

我们回头分析一下,应该是p变量再转移进来以后,其他线程包括主线程都有一个自己的p,这是保存在线程栈中的值,而我们希望的是多线程修改同一个共享变量,这就需要把这个p放到堆里,让所有线程都访问同一个变量。

9、智能指针

指针pointer)是一个包含内存地址的变量的通用概念。

Rust 中最常见的指针是前面介绍的 引用reference)。引用以 & 符号为标志并借用了他们所指向的值。

智能指针smart pointers)是一类数据结构,他们的表现类似指针,但是也拥有额外的元数据和功能。

在 Rust 中,普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针;相反,在大部分情况下,智能指针 拥有 他们指向的数据。Rust现存的智能指针很多,这里会研究其中4种智能指针:

  • Box<T>,用于在堆上分配值
  • Rc<T>,(reference counter)一个引用计数类型,其数据可以有多个所有者。
  • Arc<T>,(atomic reference counter)可被多线程操作,但只能只读。
  • Mutex<T>,互斥指针,能保证修改的时候只有一个线程参与。

9.1 Box指针

第8章给出了一个错误版本,其中比较重要的部分是因为我们的变量p在多线程环境下被分配到了每个线程的栈内存中,根据rust所有权的机制,它在线程间不断的move,这样的变量是无法满足我们的要求的。因此,我们希望变量能够被储存在堆上。

定义一个Box包装变量:

let mut p = Box::new(Point { x: 1, y: 2 });

解引用

前面一直说引用&,那么如何读出引用的值,就需要解引用*。因此,读取Box变量的写法:

println!("{}", (*p).x);

执行成功。这里要注意解引用时要加括号,否则会作用到x上面引发报错。

Box变量虽然被强制分配在堆上,但它只能有一个所有权。所以还不是真正的共享。

9.2 Rc指针

Box指针修饰的变量只能保证强制被分配到堆上,但同一时间仍旧只能有一个所有权,不算真的共享。下面来学习Rc指针。Rc是一个引用计数智能指针,首先它修饰的变量也会分配在堆上,可以被多个变量所引用,智能指针会记录每个变量的引用,这就是引用计数的概念。下面看一下如何编写使用Rc智能指针。

use std::rc::Rc;

fn main() {
    let mut p = Rc::new(Point { x: 1, y: 2 });
    let p1 = Rc::clone(&p);
    let p2 = Rc::clone(&p);
    println!("{},{},{}", p.x, p1.x, p2.x);
}

1、首先变量p被指定由Rc所包装。

2、接着,p1和p2都是由p的引用克隆而来,所以他们都指向p的内存。

3、尝试打印p和p1,p2的x坐标的值,我们用Box指针的话,这样是不行的,一定会报错。但是Rc指针是可以的。

执行成功,打印出1,1,1。

Rc智能指针学习到这里,看上去是可以满足我们的多线程修改共享变量的目的,那我们捡起来之前的rust代码,并将p修改为Rc智能指针所修饰,再去执行一下做个试验。

error[E0277]: Rc<Point> cannot be sent between threads safely

结果是不行的,报错提示了,说明Rc指针不能保证线程安全,因此只能在单线程中使用。看来Rc指针是不能满足我们的需求了。下面我们继续来学习Arc指针。

9.3 Arc指针

Arc指针是比Rc多了一个Atomic的限定词语,这是原子的意思。熟悉多线程的朋友应该了解,原子性代表了一种线程安全的特性。那么它该如何使用,是否能满足我们的要求呢?我们来编写一下。

let mut p = Arc::new(Point { x: 1, y: 2 });
let mut handles = vec![];
for i in 0..10 {
  let p1 = Arc::clone(&p);
  let handle = thread::spawn(move || {
    println!("{},{}", i, p1.x);
    // p.x += 1;
  });
  handles.push(handle);
}
for handle in handles {
  handle.join().expect("TODO: panic message");
}
println!("{}", p.x);

1、我们修改了第1行p为Arc的修饰。

2、然后第4行增加了对p的引用的克隆。这是在循环体内执行的,保证每个线程都能有单独的变量使用,同时借由Arc的特性,这些变量都共同指向了同一个内存值。

3、我们注释掉了第7行对于共享变量的修改操作,否则会报错:error[E0594]: cannot assign to data in an Arc

总结一下,Arc智能指针继承了Rc的能力,同时又能够满足多线程下的操作,使得变量真正成为共享变量。然而Arc不能被修改,是只读权限,这就无法满足我们要修改的需求。我们距离目标越来越近了。

9.4 Mutex指针

下面来介绍Mutex指针,它是专门为修改共享变量而生的。Mutex指针能够保证同一时间下,只有一个线程可以对变量进行修改,其他线程必须等待当前线程修改完毕方可进行修改。

Mutex指针的功能描述,与java的多线程上锁的过程很相似。可变不共享,共享不可变。

下面我们在之前的基础上尝试修改:

fn main() {
    let mut p = Mutex::new(Point { x: 1, y: 2 });
    let mut handles = vec![];
    for i in 0..10 {
        // let p1 = Arc::clone(&p);
        let handle = thread::spawn(move || {
            let mut p0 = p.lock().unwrap();
            println!("{},{}", i, p0.x);
            p0.x += 1;
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().expect("TODO: panic message");
    }
    println!("{}", p.lock().unwrap().x);
}

1、首先第2行我们将p改为用Mutex指针修饰。

2、第7行要注意,Mutex之所以能够是互斥,因为它内部是通过锁机制来实现了多线程下的线程安全。所以这里要先得到p的锁即p.lock(),然后在解包装,就能得到里面的值。我们将它复制给p0。

3、最后打印的时候也要注意同样的写法。

那么这段代码的执行仍旧是失败,报错提示error[E0382]: use of moved value: p

其实问题还是出在了共享变量上,Mutex单独修饰的变量并不是共享变量,因为它的所有权在同一时间仍旧是只有一个,也就是说这里其实缺少了Rc的能力。

10、终版

前面我们学习了4种智能指针,Box和Rc首先被淘汰,因为他们距离我们的需求都比较遥远,但是他们两个的学习可以很有效地帮助我们学习其他的智能指针。而Arc和Mutex这两个智能指针在编写代码的时候,总是感觉跟我们的目标擦肩而过。那么我们可以想一想,如果使用Arc来包装Mutex指针,然后Mutex指针再包装一层变量。这样我们就可以既满足多线程下修改的线程安全,同时又能够克隆出来多个变量的引用,共同指向同一内存。下面就来实现一下本文题目的最终版本。

use std::sync::{Arc, Mutex};
use std::thread;

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut p = Arc::new(Mutex::new(Point { x: 1, y: 2 }));
    let mut handles = vec![];
    for i in 0..10 {
        let pp = Arc::clone(&p);
        let handle = thread::spawn(move || {
            let mut p0 = pp.lock().unwrap();
            println!("thread-{}::{}", i, p0.x);
            p0.x += 1;
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().expect("TODO: panic message");
    }
    println!("total: {}", p.lock().unwrap().x);
}

正如前面分析的,

1、我们在第10行将变量p先用Mutex包装一层,然后在外层再使用Arc智能指针包装一层。

2、第13行,我们在循环体内,子线程外,给变量p克隆出一个pp。

3、第15行,我们使用pp.lock().unwrap()得到Mutex包装的变量值。

4、后面就是对于p0在子线程中的操作。

最后打印出来p的x坐标,执行结果:

thread-0::1
thread-1::2
thread-4::3
thread-3::4
thread-2::5
thread-5::6
thread-6::7
thread-7::8
thread-8::9
thread-9::10
total: 11

共享变量p的x坐标值被10个线程所修改,每个线程都对其进行了加1操作,最终该共享变量p的x坐标变为了11,结果符合预期。

11、后记

Rust语言在完成多线程修改共享变量这件事上面,编写难度是远大于java的。但Rust版本一旦执行成功,它的稳定性是要远高于java,目前为止,还没有出现过运行一段时间后内存溢出、指针异常等java版本常见的错误。这其实就突出了Rust语言的编程思想,它是希望各种编码语法以及类库的配合,将错误异常封杀在编码阶段,通过复杂的编写方式来换取安全优质的执行环境。

语言的本质是对操作系统应用的更优策略。

本篇还有很多瑕疵,例如java实现的版本没有锁的控制,后面会单独出java多线程精进的博文。例如Rust更多更丰富的语法没有被覆盖到。

参考资料

更多文章请转到一面千人的博客园

posted @ 2022-06-01 23:54  一面千人  阅读(4938)  评论(5编辑  收藏  举报