15.无畏并发

并发编程(concurrent programming):允许程序中的不同部分独立运行;并行编程(parallel programming):允许程序中的不同部分同时执行;无畏并发可以让你编写出没有诡异缺陷的代码,并且易于重构而不会引入新的缺陷。

一、使用线程同时运行代码

在大部分现代操作系统中,执行程序的代码会运行在进程(process)中,操作系统会同时管理多个进程。类似的,程序内部也可以拥有多个同时运行的独立部分,用来运行这些独立部分的就叫做线程(thread)。
由于多个线程可以同时运行,所以将程序中的计算操作拆分至多个线程可以提高性能。但这也增加了程序的复杂度,因为不同线程在执行过程中的具体顺序是无法确定的。这会导致一系列问题:

  • 当多个线程以不一致的舒徐访问数据或资源时产生的竞争状态(race condition);
  • 当两个线程同时尝试获取对方持有的资源时产生的死锁(deadlock),它会导致这两个线程无法继续运行;
  • 只会出现在特定情形下且难以稳定重现和修复的bug;
    现有的编程语言采用了不同的方式实现线程:
  • 许多操作系统都提供了用于创建新线程的API。这种直接利用操作系统API来创建线程的模型被称为1:1模型,它意味着一个操作系统线程对应一个语言线程;
  • 许多编程语言提供了它们自身特有的线程实现,这种由程序语言提供的线程被称为绿色线程(gren thread),使用绿色线程的语言会在拥有不同数量系统线程的环境下运行它们。绿色线程也被称为M:N模型,它表示M个绿色线程对应着N个系统线程,这里的M与N不必相等;
    每个模型都有自身的优势和取舍。对于Rust而言,设计过程中最重要的权衡因素在于是否需要提供运行时(runtime)支持。在当前语境下,运行时指语言中那些被包含在每一个可执行文件中的代码。不同的语言拥有不同大小的运行时代码。除汇编语言之外,编程语言总是会包含一定数量的运行代码。较小的运行时拥有较少的功能,但却可以生成较小的二进制文件,并可以使该语言能够方便地在众多场景下与其他语言组合使用。
    由于绿色线程的M:N模型需要一个较大的运行时来管理线程,所以Rust标准库只提供了1:1线程模型的实现。你可以通过Rust社区中的第三方包来支持M:N线程模型。但你需要选择付出一定的开销来获得期望的特性。

1、使用spawn创建线程

我们可以调用thread::spaw函数来创建线程,它接收一个闭包作为参数,该闭包会包含我们想要在新线程中运行的代码。

//示例16-1:创建新线程来打印部分信息,并由主线程打印出另外一部分信息
use std::thread;

use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1.. 5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

需要注意的是,只要这段程序中的主线程运行结束,创建出的新线程就会相应停止,而不管它的打印任务是否完成。每次运行这段程序都有可能产生不同的输出。

调用thread::sleep会强制当前的线程停止执行一段时间,并允许一个不同的线程继续运行。这些线程可能会交替执行,但我们无法对它们的执行顺序做出任何保证:执行顺序由操作系统的线程调度策略决定

2、使用join句柄等待所有线程结束

由于主线程的停止,示例16-1中的代码会在大部分情形下提前终止新线程,它甚至不能保证新线程一定会得到执行。这同样是因为我们无法对线程的执行顺序做出任何保证而导致的。
我们可以通过将thread::spawn返回的结果保存在一个变量中,来避免新线程出现不执行或不能完全执行的情形。thread::spawn的返回值类型是一个自持有所有权的JoinHandle,调用它的join方法可以阻塞当前线程直到对应的新线程结束。

//示例16-2:保存thread::spawn的JoinHandle来保证新线程能够执行完毕
use std::thread;
use std::time::Duration;

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

    for i in 1.. 5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

在线程句柄上调用join函数会阻塞当前线程,直到句柄代表的线程结束。阻塞线程意味着阻止一个线程继续运行或使其退出。由于我们将join函数放置到了主线程的for循环之后,所以运行示例如下:

这两个线程依然交替地打印出了信息,但由于我们调用了handle.join(),所以主线程只会在新线程运行结束后退出。
如果我们将handle.join()放置到main函数的for循环之前,那么就会发生如下情形:

use std::thread;
use std::time::Duration;

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

    for i in 1.. 5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }   
}

在这段代码中,由于主线程会等到新线程执行完毕后才开始执行自己的for循环,所以它的输出将不再出现交替的情形:

在并发编程中,诸如在哪里调用join等微小细节也会影响多个线程是否能够同时运行。

3、在线程中使用move闭包

move闭包常被用来与thread::spawn函数配合使用,它允许你在某个线程中使用来自另一个线程的数据。
在闭包的参数列表前使用move关键字来强制闭包从外部环境中捕获值的所有权。这一技术可以帮助我们跨线程地传递某些值的所有权
注意,示例16-1中传递给thread::spawn的闭包没有捕获任何参数,因为新线程的代码并不依赖于主线程中的数据。但是,为了使用主线程中的数据,新线程的闭包必须捕获它所需要的值。示例16-3的代码试图在主线中创建一个动态数组,并接着在新线程中使用它,但是这样写法是不行的。

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

由于代码中的闭包使用了v,所以它会捕获v并使其成为闭包环境的一部分。又因为thread::spawn会在新线程中运行这个闭包,所以我们应当能够在线程中访问v。但是,出现了如下错误:

Rust会推断如何捕获v,因为println!只需要v的引用,闭包尝试借用v,但这就出现了一个问题:Rust不会这个新建线程会执行多久,所以无法知晓v的引用是否一致有效
示例16-4中代码展示了这样一个场景:新线程捕获的v的引用在使用时极有可能不再有效了:

//示例16-4:新线程的闭包尝试从主线程中捕获的v的引用会在随后被丢弃
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    drop(v);

    handle.join().unwrap();
}

如果Rust允许我们运行这段代码,那么新线程有极大的概率会在创建后被立即置入后台,不再被执行。此时的新线程在内部持有了v的引用,但主线程却已经通过drop函数丢弃了v。当新线程随后开始执行时,v和指向它的引用就全部失效了。

为了修复示例16-3的错误,通过在闭包前添加move关键字,我们会强制闭包获得它所需之的所有权,而不仅仅是基于Rust的推导来获得值的借用。

//示例16-5:使用move关键字来强制闭包获得它所需值的所有权
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}


我们在示例16-4中添加了move闭包后,该示例依然会因为其他原因编译失败。由于move将v移动到了闭包的环境中,所以我们无法在主线程中继续使用它调用drop函数。

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    drop(v);

    handle.join().unwrap();
}


示例16-3中出现的错误是因为Rust只会在新线程中保守地借用v,这也意味着主线程可以从理论上让新线程持有地引用失效。通过将v地所有权转移给新县城,我们就可以向Rust保证主线程不会再次使用v。
如果我们采用类似的方法修改示例16-4中的代码,那么就会在主线程继续使用v时违反所有权规则。move关键字覆盖了Rust的默认借用规则;当然,这并不意味着它会允许我们去违反任何的所有权规则。

二、使用消息传递在线程间转移数据

使用消息传递(message passing)机制来保证并发安全。这种机制中,线程或actor之间通过给彼此发送包含数据的消息来进行通信。Go编程语言文档中的口号正体现了这一思想:不要通过共享内存来通信,而是通过通信来共享内存
Rust在标准库中实现了一个名为通道(channel)的编程概念,它可以被用来实现基于消息传递的并发机制。编程中的通道由发送者(transmitter)和接收者(receiver)两个部分组成。发送者位于通道的上游,而接收者则位于通道的下游。某一处代码可以通过调用发送者的方法来传送数据,而另一处代码则可以通过检查接收者来获取数据。当你丢弃发送者或接收者的任何一端时,我们就称相应的通道被关闭(closed)了
接下来我们编写的程序会拥有两个线程,其中一个线程会产生一些值并将它们传入通道,而另外一个线程则会接收这些值并将它们打印出来。示例16-6创建了一个不执行任何操作的通道。当前代码无法通过编译,因为Rust不能推导出我们希望在通道中传递的值类型。

//示例16-6:创建一个通道,并将两端分别赋给tx和rx
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

上面的代码使用mpsc::channel函数创建了一个新的通道。路径中的mpsc是英文"multiple producer, single consummer"(多个生产者,单个消费者)的缩写。简单来说,Rust标准库中特定的实现方式使得通道可以拥有多个生产内容的发送端,但只能拥有一个消耗内容的接收端。我们从单个生产者开始编写程序,并在这个示例运行成功后再扩展至拥有多个生产者的场景。
函数mpsc::channel会返回一个含有发送端与接收端的元组。代码中用来绑定它们的变量名称为txrx,这也是许多场景下发送者和接收者的惯用简写。
接下来,我们将发送到移动到新线程中,并接着发送一个字符串来完成新线程与主线程的通信,示例16-7:

//示例16-7:将tx移动到新线程中并发送值"hi"
use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwarp();
    });
}

我们再次使用thread::spawn生成了一个新线程。为了让新线程拥有tx的所有权,我们使用move关键字将tx移动到了闭包的环境中。新线程必须拥有通道发送端的所有权才能通过通道来发送消息。
发送端提供了send方法来接收我们想要发送的值。这个方法会返回Result<T, E>类型的值作为结果:当接收端已经被丢弃而无法继续传递内容时,执行发送操作便会返回一个错误。在这个示例中,我们在出现错误时直接调用了unwrap来触发panic
示例16-8的主线程中,我们会从通道的接收端获得传入的值。

//示例16-8:在主线程中接收并打印值"hi"
use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Gto: {}", received);
}

通道的接收端有两个可用于获取消息的方法:recvtry_recv。我们使用的recv会阻塞主线程的执行直到有值被传入通道。一旦有值被传入通道,recv就会将它包裹在Result<T, E>中返回。而如果通道的发送端全部关闭了,recv则会返回一个错误来表明当前通道再也没有可接收的值
try_recv方法不会阻塞线程,它会立即返回Result<T, E>:当通道中存在消息时,返回包含该消息的Ok变体;否则便返回Err变体。当某个线程需要一边等待消息一边完成其他任务时,try_recv方法非常有用。我们可以编写出一个不断调用try_recv方法的循环,并在消息到来时对其进行处理,而在没有消息时执行其他指令。

1、通道和所有权转移

所有权规则在消息传递的过程中扮演了至关重要的角色,因为它可以帮助你写出安全的并发代码。通过不断地在编写Rust代码时思考所有权问题,我们可以有效地避免并发编程中的常见错误。下面的示例演示了通道和所有权规则是如何通过协作来规避问题。

//示例16-9:将val发送给通道后再尝试使用它
use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {}", val);
    });

    let received = rx.recv().unwrap();
    println!("Gto: {}", received);
}

上面的代码首先通过调用tx.sendval值发送给通道,接着又继续尝试打印这个值。允许这样的操作可不是什么好主意:一旦这个值被发送到了另一个线程中,那么这个线程就可以在我们尝试重新使用这个值之间修改或丢弃它。这些修改极有可能造成不一致或产生原本不存在的数据,最终导致错误或出乎意料的结果。这就出现了如下报错:

我们并发却笑造成了一个编译时错误。send函数会获取参数的所有权,并在参数传递时将所有权转移给接收者。这可以阻止我们意外地使用已经发送的值,所有权系统会在编译时确保程序地每个部分都符合规则。

2、发送多个值并观察接收者的等待过程

我们很难确定示例16-8的两个独立线程是否正在基于通道互相通信。因此,我们再示例16-10中修改好了部分代码来证明示例16-8中的代码是并发执行的:新线程现在会发送多条信息,并在每次发送后暂停1分钟。

//示例16-10:发送多条消息并在每次发送后暂停1秒
use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

    for val in vals {
        tx.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

这段代码在新线程中创建了一个用于存储字符串的动态数组。我们会迭代动态数组来逐个发送其中的字符串,并在每次发送后调用Duration值为1秒的thread::sleep函数来稍作暂停。
在主线程中,我们会将rx视作迭代器,而不再显示地调用recv函数。迭代中地代码会打印出每个接收到地值,并在通道关闭时退出循环。

我们并没有在主线程的for循环中执行暂行或延迟指令,这也就表明主线程确实是在等待接收新线程中传递过来的值。

3、通过克隆发送者创建多个生产者

我们通过克隆通道的发送端来创建出多个能够发送值到同一个接收端的线程,如示例16-11:

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    let tx1 = mpsc::Sender::clone(&tx);

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

    for val in vals {
        tx1.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

    for val in vals {
        tx.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

我们在创建第一个新线程前调用了通道发送端的clone方法,这会为我们生成可以传入首个新线程的发送端句柄。随后,我们有将原始的通道发送端传入第二个新县城。这两个线程会各自发送不同的消息到通道的接收端。

三、共享状态的并发

消息传递确实是一种不错的并发通信机制,但它并不是唯一的解决方法。我们还可以通过共享内存来通信。从某种程度上来说,任何编程语言中的通道都有类似于单一所有权的概念,因为你不应该在值传递给通道后再次使用它。而基于共享内存的并发通信机制则更类似于多重所有权概念:多个线程可以同时访问相同的内存地址

1、互斥体一次只允许一个线程访问数据

互斥体(mutex)是英文mutual exclusion的缩写,一个互斥体在任意时刻只允许一个线程访问数据。为了访问互斥体中的数据,线程必须首先发出信号来获取互斥体的锁锁是互斥体的一部分,这种数据结构被用来记录当前谁拥有数据的唯一访问权。通过锁机制,互斥体守护了它锁持有的数据
对于互斥体的学习,你必须牢记下面两条规则:

  • 必须在使用数据前尝试获取锁;
  • 必须在使用完互斥体守护的数据后释放锁,这样其他线程才能继续完成获取锁的错做;
    对互斥体的比喻是:在一个单向隧道中,一次只有一辆车可以通行,当一辆车完全通过后另外的车辆才能通行。当有一辆车坏在隧道中,那么其他车辆也无法通行了。

1.Mutex<\T>的结构

为了便于演示,我们会首先在单线程环境中使用互斥体,如下示例:

//示例16-12:简单地探索单线程场景下的Mutex<T>接口
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {:?}", m);
}

与许多其他类型一样,我们可以使用关联函数new来创建Mutex<T>实例。为了访问Mutex<T>实例中的数据,我们首先需要调用它的lock方法来获取锁。这个调用会阻塞当前线程直到我们取得锁为止。
当前线程对于lock函数的调用会在其他某个持有锁的线程发生panic时失败。实际上,任何获取锁的请求都会在这种场景李以失败告终,所以示例中的代码选择使用unwrap在意外发生时触发当前线程的panic。
一旦获取了锁,我们便可以将它的返回值num视作一个指向内部数据的可变引用。Rust的类型系统会确保我们在使用m的值之前执行加锁操作:因为Mutex<i32>并不是i32的类型,所以我们必须获取锁才能使用i32值。我们无法忘记或忽略这一步骤,因为类型系统并不允许我们以其他方式访问内部的i32值。
Mutex<T>是一种智能指针。更准确地说,对lock的调用会返回一个MutexGuard的智能指针。这个智能指针通过实现Deref来指向存储在内部的数据,它还会通过实现Drop来完成自己离开作用域时的自动解锁操作。
上述示例中,这种释放过程会发生在内部作用域的结尾处。因此,我们不会因为忘记释放锁而导致其他线程无法继续使用该互斥体。锁的释放过程是自动发生的
在释放完锁之后,我们打印出了这个互斥体的值。

2.在多个线程间共享Mutex<T>

现在,让我们试着在多线程环境中使用Mutex<T>来共享数据。在下面的例子中,我们会依次启动10个线程,并在每个线程中分别为共享的计数器的值加1。一切顺利的话,这最终会让计数器的值从0累计到10。注意,接下来的几段示例代码都无法通过编译。我们需要通过示例中的错误来学习Mutex<T>

//示例16-13:在10个线程中分别为Mutext<T>守护的计数器的值加1
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    fn _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        })
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());

}

上面的代码首先创建了一个名为counter的变量来存储有i32值的Mutex<T>。随后,我们通过迭代数字范围创建出了10个线程。在调用thread::spawn创建线程的过程中,我们给所有创建的线程传入了同样的闭包。这个闭包会把计数器移动至线程中,它还会调用Mutex<T>的lock方法来进行加锁并为互斥体中的值加1。而当线程执行完毕后,num会在离开作用域时释放锁,从而让其他线程得到获取锁的机会。
与示例16-12类似,我们还在主线程中收集了所有的线程句柄并通过逐一调用句柄的join方法来确保所有生成的线程执行完毕。最后,主线程会获取锁并打印出程序的结果。

这段错误提示信息指出,counter被移动进了闭包中并在调用lock时被捕获了。这一描述与我们的设计思路似乎完全相符,但它却不是被允许的。
让我们简化一下程序。不在通过for循环来创建10个线程,而是去掉循环,并手动地生成两个线程:

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);

        let handle2 = thread::spawn(move || {
            let mut num2 = counter.lock().unwrap();

            *num2 += 1;
        });
        handles.push(handle2);

    println!("Result: {}", *counter.lock().unwrap());
}

新的代码创建了两个线程,第二个线程用到的相关变量被命名为handle2与handle。错误提示如下:

第一条提示信息指出,counter被移动到了handle指代的线程中。而这一行为组织了我们在第二线程中调用lock来再次捕获counter。Rust提醒我们不应该将counter的所有权移动到多个线程中。这个问题很难在前一个示例中被发现,因为我们使用了循环来创建线程,而Rust无法在提示信息中指出迭代过程中创建哪一个线程时出现了问题。

3.多线程与多重所有权

我们借助于智能指针Rc<T>提供的引用计数为单个值赋予了多个所有者。接下来,我们会尝试用相同的方法来解决当前的问题。如下示例中的代码使用Rc<T>来包裹Mutex<T>,并在每次需要移动所有权至线程时克隆Rc<T>。另外,鉴于我们已经发现了错误的原因,所以下面的代码重新使用了for循环,并依然为闭包使用了move关键字。

//示例16-14:尝试使用Rc<T>来允许多个线程持有Mutex<T>
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

再次编译出现了另外一个错误:

第一行错误表明我们新创建的std::rc::Rc<std::sync::Mutex<i32>>类型无法安全地在线程间传递。这个错误地原因被指明在随后地错误描述中:”the trait bound 'send' is not satisfied“,该类型不满足trait约束send。
不幸的是,Rc<T>在跨线程使用时并不安全。当Rc<T>管理引用计数时,它会在每次调用cloune的过程中增加引用计数,并在克隆出的实例并丢弃时减少引用计数,但它并没有使用任何并发原语来保证修改计数的过程不会被另一个线程打断。这极有可能导致计数错误并产生诡异bug,比如内存泄漏或值在使用时被提前释放。我们需要的是一个行为与Rc<T>一致,且能够保证线程安全的引用计数类型。

4.原子引用计数Arc<T>

我们拥有一种被称为Arc<T>的类型,它既拥有类似于Rc<T>的行为,又保证了自己可以被安全地用于并发场景。它名称中的A代表着原子(atomic),表明自己是一个原子引用计数类型。我们可以参考标准库文档中的std::sync::atomic部分来获得更多相关信息。现在只要知道:原子和原生类型的用法十分相似,并且可以安全地在多个线程间共享。
我们实现Arc<T>地时候需要付出一定的性能开销才能实现线程安全,而我们只应该在必要时为这种开销买单。如果你只是在单线程中对值进行操作,那么我们的代码可以因为无须原子的安全保证而运行的更快。
因为Arc<T>Rc<T>的接口完全一致,所以我们只需要简单地修改use代码行、对new地调用及对clone的调用即可。如下示例:

//示例16-15:使用Arc<T>包裹Mutex<T>来实现多线程共享所有权
use std::sync::{Mutex,Arc};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

运行结果:

2、RecfCell<T>/Rc<T>和Mutex<T>/Arc<T>之间的相似性

虽然counter本身不可变,但我们仍然能够获取其内部值的可变引用。这意味着,Mutex<T>Cell系列类型有着相似功能,它同样提供了内部可变性。
另一个还有一个值得注意的细节是,Rust并不能使你完全避免使用Mutex<T>过程中所有的逻辑错误。在之前讨论过,使用Rc<T>会有产生循环引用的风险,两个Rc<T>值在互相指向对方时会造成内存泄漏。与之类似,使用Mutex<T>也会产生死锁的风险。当某个操作需要同时锁住两个资源,而两个线程分别持有其中一个锁并相互请求另外一个锁时,这两个线程就会陷入无穷尽的等待过程

四、使用Sync trait和Send trait对并发进行扩展

1、允许线程间转移所有权的Send trait

只有实现了Send trait的类型才可以安全地在线程间转移所有权。除了Rc<T>等极少数的类型,几乎所有的Rust类型都实现了Send trait:如果你将克隆后的Rc<T>值的所有权转移到了另外一个线程中,那么两个线程就有可能同时更新引用计数值并进而导致计数错误。因此,Rc<T>只被设计在单线程场景中使用,它也无须为线程安全付出额外的性能开销。
因此,Rust的类型系统与trait约束能够阻止我们意外地跨线程传递Rc<T>示例。
任何完全由Send类型组成的复合类型都会被自动标记为Send。除了我们后期学习到的裸指针。

2、允许多线程同时访问的Sync trait

只有实现了Sync trait的类型才可以安全地被多个线程引用。换句话说,对于任何类型T,如果&T满足约束Send,那么T就是满足Sync。这意味着T的引用能够被安全地传递至另外的线程中。与Send类似,所有原生类型都满足Sync约束,而完全由满足Sync的类型组成的复合类型也都会被自动识别为满足Sync的类型。
智能指针Rc<T>同样不满足Sync约束,其原因与它不满足Send约束类型。RefCell<T>实现的运行时借用检查并没有提供有关线程安全的保证。RefCell<T>实现的运行时借用检查并没有提供有关线程安全的保证

3、手动实现Send和Sync是不安全的

当某个类型完全由实现了Send与Sync的类型组成时,它就会自动实现Send与Sync。因此,我们并不需要手动地为此类型实现相关trait。作为标签trait,Send与Sync甚至没有任何可供实现地方法,它们仅仅被用来强化与并发相关的不可变性。
手动实现这些trait涉及使用特殊的不安全Rust代码。我们后面讨论,目前需要注意的是,当你构建的自定义并发类型包含了没有实现Send或Sync类型时,你必须要非常谨慎地确保涉及能够满足线程间的安全性要求。Rust官网中的Introduction - The Rustonomicon (rust-lang.org)文档详细地讨论了此类安全性保证及如何满足安全性要求的具体计数。

posted @ 2023-02-10 10:46  Diligent_Maple  阅读(50)  评论(0编辑  收藏  举报